From 25b94aff0a02e4363c1a2b6123943cee427297ac Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Thu, 6 Aug 2026 09:13:38 -0300 Subject: [PATCH 1/4] move to PortRange struct --- dpd/src/nat.rs | 259 +++++++++++++++++++++++++++---------------------- 1 file changed, 143 insertions(+), 116 deletions(-) diff --git a/dpd/src/nat.rs b/dpd/src/nat.rs index 8c887a51..b2227722 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -16,52 +16,64 @@ use crate::types::{DpdError, DpdResult}; use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::NatTarget; -trait PortRange { - fn low(&self) -> u16; - fn high(&self) -> u16; +/// An inclusive range of ports, guaranteed by construction to have +/// `low <= high`. +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct PortRange { + low: u16, + high: u16, } -#[derive(PartialEq)] -pub(crate) struct Ipv6NatEntry { - pub low: u16, - pub high: u16, - pub tgt: NatTarget, +#[derive(Debug)] +pub(crate) struct InvalidPortRange; + +impl From for DpdError { + fn from(_: InvalidPortRange) -> Self { + DpdError::Invalid("invalid port range".into()) + } } -impl PortRange for Ipv6NatEntry { - fn low(&self) -> u16 { - self.low +impl PortRange { + fn new(low: u16, high: u16) -> Result { + if low <= high { + Ok(PortRange { low, high }) + } else { + Err(InvalidPortRange) + } } - fn high(&self) -> u16 { - self.high + + fn overlaps(self, other: PortRange) -> bool { + self.low <= other.high && self.high >= other.low } } -impl fmt::Display for Ipv6NatEntry { +impl fmt::Display for PortRange { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}-{}] -> {}", self.low, self.high, self.tgt) + write!(f, "[{}-{}]", self.low, self.high) } } -#[derive(Clone, PartialEq)] -pub(crate) struct Ipv4NatEntry { - pub low: u16, - pub high: u16, +#[derive(PartialEq)] +pub(crate) struct Ipv6NatEntry { + pub l4_ports: PortRange, pub tgt: NatTarget, } -impl PortRange for Ipv4NatEntry { - fn low(&self) -> u16 { - self.low - } - fn high(&self) -> u16 { - self.high +impl fmt::Display for Ipv6NatEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", self.l4_ports, self.tgt) } } +#[derive(Clone, PartialEq)] +pub(crate) struct Ipv4NatEntry { + pub l4_ports: PortRange, + pub tgt: NatTarget, +} + impl fmt::Display for Ipv4NatEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}-{}] -> {}", self.low, self.high, self.tgt) + write!(f, "{} -> {}", self.l4_ports, self.tgt) } } pub struct NatData { @@ -78,51 +90,38 @@ fn ipv4_entry(ipv4: Ipv4Addr, e: &Ipv4NatEntry) -> String { format!("{ipv4}/{e}") } -fn overlaps(e: &T, low: u16, high: u16) -> bool { - let elow = e.low(); - let ehigh = e.high(); - - (elow >= low && elow <= high) - || (ehigh >= low && ehigh <= high) - || (elow <= low && ehigh >= high) -} - /// find index of first mapping that overlaps with supplied port range -fn find_first_mapping( - entries: &[T], - low: u16, - high: u16, +fn find_first_mapping( + mut ranges: impl Iterator, + range_to_find: PortRange, ) -> Option { - entries.iter().position(|e| overlaps(e, low, high)) + ranges.position(|e| e.overlaps(range_to_find)) } /// find indices of all mappings that overlap with supplied port range -fn find_mappings( - entries: &[T], - low: u16, - high: u16, +fn find_mappings( + ranges: impl Iterator, + range_to_find: PortRange, ) -> Vec { - entries - .iter() + ranges .enumerate() - .filter(|(_, e)| overlaps(*e, low, high)) + .filter(|(_, e)| e.overlaps(range_to_find)) .map(|(i, _)| i) .collect() } -fn find_space( - entries: &[T], - low: u16, - high: u16, +fn find_space( + ranges: impl ExactSizeIterator, + candidate_range: PortRange, ) -> Option { - let len = entries.len(); + let len = ranges.len(); + let iter = ranges.enumerate(); - for (idx, e) in entries.iter().enumerate() { - if overlaps(e, low, high) { + for (idx, e) in iter { + if e.overlaps(candidate_range) { return None; } - if e.low() >= high && (idx == len - 1 || entries[idx + 1].low() >= high) - { + if e.low >= candidate_range.high { return Some(idx); } } @@ -131,41 +130,43 @@ fn find_space( #[test] fn test_mapping() { - use super::MacAddr; - use common::network::Vni; + let entries = [ + PortRange::new(1, 4).unwrap(), + PortRange::new(7, 10).unwrap(), + PortRange::new(12, 18).unwrap(), + ]; - let dummy_target = NatTarget { - internal_ip: Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0), - inner_mac: MacAddr::new(0, 0, 0, 0, 0, 0), - vni: Vni::new(0).unwrap(), + let first_mapping = |low, high| { + find_first_mapping( + entries.iter().copied(), + PortRange::new(low, high).unwrap(), + ) + }; + let space = |low, high| { + find_space(entries.iter().copied(), PortRange::new(low, high).unwrap()) }; - let entries = vec![ - Ipv4NatEntry { low: 1, high: 4, tgt: dummy_target }, - Ipv4NatEntry { low: 7, high: 10, tgt: dummy_target }, - Ipv4NatEntry { low: 12, high: 18, tgt: dummy_target }, - ]; - - assert_eq!(find_first_mapping(&entries, 2, 2), Some(0)); - assert_eq!(find_first_mapping(&entries, 4, 5), Some(0)); - assert_eq!(find_first_mapping(&entries, 5, 6), None); - assert_eq!(find_first_mapping(&entries, 5, 7), Some(1)); - assert_eq!(find_first_mapping(&entries, 2, 6), Some(0)); - assert_eq!(find_first_mapping(&entries, 5, 5), None); - assert_eq!(find_first_mapping(&entries, 5, 20), Some(1)); - assert_eq!(find_first_mapping(&entries, 12, 12), Some(2)); - assert_eq!(find_first_mapping(&entries, 18, 18), Some(2)); - assert_eq!(find_first_mapping(&entries, 19, 19), None); - assert_eq!(find_first_mapping(&entries, 19, 40), None); - assert_eq!(find_first_mapping(&entries, 0, 0), None); - assert_eq!(find_first_mapping(&entries, 0, 2), Some(0)); - assert_eq!(find_space(&entries, 0, 0), Some(0)); - assert_eq!(find_space(&entries, 0, 1), None); - assert_eq!(find_space(&entries, 11, 11), Some(2)); - assert_eq!(find_space(&entries, 19, 32), Some(3)); - assert_eq!(find_space(&entries, 0, 2), None); - assert_eq!(find_space(&entries, 3, 5), None); - assert_eq!(find_space(&entries, 3, 8), None); + assert_eq!(first_mapping(2, 2), Some(0)); + assert_eq!(first_mapping(4, 5), Some(0)); + assert_eq!(first_mapping(5, 6), None); + assert_eq!(first_mapping(5, 7), Some(1)); + assert_eq!(first_mapping(2, 6), Some(0)); + assert_eq!(first_mapping(5, 5), None); + assert_eq!(first_mapping(5, 20), Some(1)); + assert_eq!(first_mapping(12, 12), Some(2)); + assert_eq!(first_mapping(18, 18), Some(2)); + assert_eq!(first_mapping(19, 19), None); + assert_eq!(first_mapping(19, 40), None); + assert_eq!(first_mapping(0, 0), None); + assert_eq!(first_mapping(0, 2), Some(0)); + assert_eq!(space(0, 0), Some(0)); + assert_eq!(space(0, 1), None); + assert_eq!(space(5, 8), None); + assert_eq!(space(11, 11), Some(2)); + assert_eq!(space(19, 32), Some(3)); + assert_eq!(space(0, 2), None); + assert_eq!(space(3, 5), None); + assert_eq!(space(3, 8), None); } pub fn get_ipv6_addrs_range( @@ -206,11 +207,11 @@ pub fn get_ipv6_mappings_range( let mut entries = Vec::new(); for m in mappings { - if m.low >= port { + if m.l4_ports.low >= port { entries.push(Ipv6Nat { external, - low: m.low, - high: m.high, + low: m.l4_ports.low, + high: m.l4_ports.high, target: m.tgt, }); if entries.len() >= max { @@ -229,9 +230,11 @@ pub fn get_ipv6_mapping( low: u16, high: u16, ) -> DpdResult { + let range = PortRange::new(low, high)?; let nat = switch.nat.lock().unwrap(); if let Some(v) = nat.ipv6_mappings.get(&nat_ip) - && let Some(idx) = find_first_mapping(v, low, high) + && let Some(idx) = + find_first_mapping(v.iter().map(|e| e.l4_ports), range) { return Ok(v[idx].tgt); } @@ -245,14 +248,11 @@ pub fn set_ipv6_mapping( high: u16, tgt: NatTarget, ) -> DpdResult<()> { - let new_entry = Ipv6NatEntry { low, high, tgt }; + let l4_ports = PortRange::new(low, high)?; + let new_entry = Ipv6NatEntry { l4_ports, tgt }; let full = ipv6_entry(nat_ip, &new_entry); trace!(switch.log, "adding nat entry {}", full); - if high < low { - return Err(DpdError::Invalid("invalid port range".into())); - } - let mut nat = switch.nat.lock().unwrap(); let (entries, idx) = match nat.ipv6_mappings.get_mut(&nat_ip) { Some(e) => { @@ -260,7 +260,7 @@ pub fn set_ipv6_mapping( // entry already exists return Ok(()); } - match find_space(e, low, high) { + match find_space(e.iter().map(|x| x.l4_ports), l4_ports) { Some(i) => (e, i), None => { trace!( @@ -298,18 +298,25 @@ pub fn clear_ipv6_mapping( low: u16, high: u16, ) -> DpdResult<()> { + let range = PortRange::new(low, high)?; let mut nat = switch.nat.lock().unwrap(); trace!(switch.log, "clearing nat entry {}/{}-{}", nat_ip, low, high); if let Some(mappings) = nat.ipv6_mappings.get_mut(&nat_ip) - && let Some(idx) = find_first_mapping(mappings, low, high) + && let Some(idx) = + find_first_mapping(mappings.iter().map(|e| e.l4_ports), range) { let ent = mappings.remove(idx); if mappings.is_empty() { nat.ipv6_mappings.remove(&nat_ip); } let full = ipv6_entry(nat_ip, &ent); - return match nat::delete_ipv6_entry(switch, nat_ip, ent.low, ent.high) { + return match nat::delete_ipv6_entry( + switch, + nat_ip, + ent.l4_ports.low, + ent.l4_ports.high, + ) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); Err(e) @@ -362,11 +369,11 @@ pub fn get_ipv4_mappings_range( let mut entries = Vec::new(); for m in mappings { - if m.low >= port { + if m.l4_ports.low >= port { entries.push(Ipv4Nat { external, - low: m.low, - high: m.high, + low: m.l4_ports.low, + high: m.l4_ports.high, target: m.tgt, }); if entries.len() >= max { @@ -385,9 +392,11 @@ pub fn get_ipv4_mapping( low: u16, high: u16, ) -> DpdResult { + let range = PortRange::new(low, high)?; let nat = switch.nat.lock().unwrap(); if let Some(v) = nat.ipv4_mappings.get(&nat_ip) - && let Some(idx) = find_first_mapping(v, low, high) + && let Some(idx) = + find_first_mapping(v.iter().map(|e| e.l4_ports), range) { return Ok(v[idx].tgt); } @@ -414,14 +423,11 @@ pub fn set_ipv4_mapping( high: u16, tgt: NatTarget, ) -> DpdResult<()> { - let new_entry = Ipv4NatEntry { low, high, tgt }; + let l4_ports = PortRange::new(low, high)?; + let new_entry = Ipv4NatEntry { l4_ports, tgt }; let full = ipv4_entry(nat_ip, &new_entry); trace!(switch.log, "adding nat entry {}", full); - if high < low { - return Err(DpdError::Invalid("invalid port range".into())); - } - let mut nat = switch.nat.lock().unwrap(); let (entries, idx) = match nat.ipv4_mappings.get_mut(&nat_ip) { Some(e) => { @@ -429,7 +435,7 @@ pub fn set_ipv4_mapping( // entry already exists return Ok(()); } - match find_space(e, low, high) { + match find_space(e.iter().map(|x| x.l4_ports), l4_ports) { Some(i) => (e, i), None => { error!( @@ -479,6 +485,7 @@ pub fn clear_ipv4_mapping( low: u16, high: u16, ) -> DpdResult<()> { + let range = PortRange::new(low, high)?; let mut nat = switch.nat.lock().unwrap(); trace!( switch.log, @@ -486,14 +493,20 @@ pub fn clear_ipv4_mapping( ); if let Some(mappings) = nat.ipv4_mappings.get_mut(&nat_ip) - && let Some(idx) = find_first_mapping(mappings, low, high) + && let Some(idx) = + find_first_mapping(mappings.iter().map(|e| e.l4_ports), range) { let ent = mappings.remove(idx); if mappings.is_empty() { nat.ipv4_mappings.remove(&nat_ip); } let full = ipv4_entry(nat_ip, &ent); - return match nat::delete_ipv4_entry(switch, nat_ip, ent.low, ent.high) { + return match nat::delete_ipv4_entry( + switch, + nat_ip, + ent.l4_ports.low, + ent.l4_ports.high, + ) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); Err(e) @@ -532,6 +545,7 @@ pub fn clear_overlapping_mappings_v4( low: u16, high: u16, ) -> DpdResult<()> { + let range = PortRange::new(low, high)?; let mut nat = switch.nat.lock().unwrap(); trace!( switch.log, @@ -539,14 +553,20 @@ pub fn clear_overlapping_mappings_v4( ); if let Some(mappings) = nat.ipv4_mappings.get_mut(&nat_ip) { - let mut mappings_to_delete = find_mappings(mappings, low, high); + let mut mappings_to_delete = + find_mappings(mappings.iter().map(|e| e.l4_ports), range); // delete starting with the last index first, or you'll end up shifting the // collection underneath you mappings_to_delete.reverse(); for idx in mappings_to_delete { let ent = mappings.remove(idx); let full = ipv4_entry(nat_ip, &ent); - match nat::delete_ipv4_entry(switch, nat_ip, ent.low, ent.high) { + match nat::delete_ipv4_entry( + switch, + nat_ip, + ent.l4_ports.low, + ent.l4_ports.high, + ) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); return Err(e); @@ -570,6 +590,7 @@ pub fn clear_overlapping_mappings_v6( low: u16, high: u16, ) -> DpdResult<()> { + let range = PortRange::new(low, high)?; let mut nat = switch.nat.lock().unwrap(); trace!( switch.log, @@ -577,14 +598,20 @@ pub fn clear_overlapping_mappings_v6( ); if let Some(mappings) = nat.ipv6_mappings.get_mut(&nat_ip) { - let mut mappings_to_delete = find_mappings(mappings, low, high); + let mut mappings_to_delete = + find_mappings(mappings.iter().map(|e| e.l4_ports), range); // delete starting with the last index first, or you'll end up shifting the // collection underneath you mappings_to_delete.reverse(); for idx in mappings_to_delete { let ent = mappings.remove(idx); let full = ipv6_entry(nat_ip, &ent); - match nat::delete_ipv6_entry(switch, nat_ip, ent.low, ent.high) { + match nat::delete_ipv6_entry( + switch, + nat_ip, + ent.l4_ports.low, + ent.l4_ports.high, + ) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); return Err(e); From 75f67a5a1c242fadee2c9f11f7aba698999a4e70 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Thu, 6 Aug 2026 09:22:35 -0300 Subject: [PATCH 2/4] introduce NatAddress trait for the NAT ingress tables Tie each IP address family to its p4 table, match key, and action types via a trait, with the table operations provided as default methods. Replaces the duplicated per-family entry points. --- dpd/src/nat.rs | 61 ++++--------- dpd/src/table/mod.rs | 9 +- dpd/src/table/nat.rs | 212 +++++++++++++++++++++---------------------- 3 files changed, 127 insertions(+), 155 deletions(-) diff --git a/dpd/src/nat.rs b/dpd/src/nat.rs index b2227722..5234938d 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -11,7 +11,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::Bound; use crate::Switch; -use crate::table::nat; +use crate::table; +use crate::table::nat::{add_entry, delete_entry}; use crate::types::{DpdError, DpdResult}; use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::NatTarget; @@ -45,6 +46,14 @@ impl PortRange { fn overlaps(self, other: PortRange) -> bool { self.low <= other.high && self.high >= other.low } + + pub(crate) fn low(self) -> u16 { + self.low + } + + pub(crate) fn high(self) -> u16 { + self.high + } } impl fmt::Display for PortRange { @@ -277,7 +286,7 @@ pub fn set_ipv6_mapping( } }; - match nat::add_ipv6_entry(switch, nat_ip, low, high, tgt) { + match add_entry(switch, nat_ip, l4_ports, tgt) { Err(e) => { error!(switch.log, "failed to add {}: {:?}", full, e); Err(e) @@ -311,12 +320,7 @@ pub fn clear_ipv6_mapping( nat.ipv6_mappings.remove(&nat_ip); } let full = ipv6_entry(nat_ip, &ent); - return match nat::delete_ipv6_entry( - switch, - nat_ip, - ent.l4_ports.low, - ent.l4_ports.high, - ) { + return match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); Err(e) @@ -452,7 +456,7 @@ pub fn set_ipv4_mapping( } }; - match nat::add_ipv4_entry(switch, nat_ip, low, high, tgt) { + match add_entry(switch, nat_ip, l4_ports, tgt) { Err(e) => { error!(switch.log, "failed to add nat entry {}: {:?}", full, e); Err(e) @@ -501,12 +505,7 @@ pub fn clear_ipv4_mapping( nat.ipv4_mappings.remove(&nat_ip); } let full = ipv4_entry(nat_ip, &ent); - return match nat::delete_ipv4_entry( - switch, - nat_ip, - ent.l4_ports.low, - ent.l4_ports.high, - ) { + return match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); Err(e) @@ -561,12 +560,7 @@ pub fn clear_overlapping_mappings_v4( for idx in mappings_to_delete { let ent = mappings.remove(idx); let full = ipv4_entry(nat_ip, &ent); - match nat::delete_ipv4_entry( - switch, - nat_ip, - ent.l4_ports.low, - ent.l4_ports.high, - ) { + match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); return Err(e); @@ -606,12 +600,7 @@ pub fn clear_overlapping_mappings_v6( for idx in mappings_to_delete { let ent = mappings.remove(idx); let full = ipv6_entry(nat_ip, &ent); - match nat::delete_ipv6_entry( - switch, - nat_ip, - ent.l4_ports.low, - ent.l4_ports.high, - ) { + match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); return Err(e); @@ -632,27 +621,17 @@ pub fn clear_overlapping_mappings_v6( pub fn reset_ipv6(switch: &Switch) -> DpdResult<()> { let mut nat = switch.nat.lock().unwrap(); - debug!(switch.log, "resetting ipv6 nat tables"); + table::nat::reset::(switch)?; nat.ipv6_mappings.clear(); - if let Err(e) = nat::reset_ipv6(switch) { - error!(switch.log, "failed to reset ipv6 nat table: {:?}", e); - Err(e) - } else { - Ok(()) - } + Ok(()) } pub fn reset_ipv4(switch: &Switch) -> DpdResult<()> { let mut nat = switch.nat.lock().unwrap(); - debug!(switch.log, "resetting ipv4 nat tables"); + table::nat::reset::(switch)?; nat.ipv4_mappings.clear(); - if let Err(e) = nat::reset_ipv4(switch) { - error!(switch.log, "failed to reset ipv4 nat table: {:?}", e); - Err(e) - } else { - Ok(()) - } + Ok(()) } pub fn set_nat_generation(switch: &Switch, generation: i64) { diff --git a/dpd/src/table/mod.rs b/dpd/src/table/mod.rs index 3afbd8cc..5a98e7fc 100644 --- a/dpd/src/table/mod.rs +++ b/dpd/src/table/mod.rs @@ -6,6 +6,7 @@ use std::convert::TryFrom; use std::hash::Hash; +use std::net::{Ipv4Addr, Ipv6Addr}; use common::table; use slog::debug; @@ -233,10 +234,10 @@ pub fn get_entries( } TableType::ArpIpv4 => arp_ipv4::table_dump(switch, from_hardware), TableType::NatIngressIpv4 => { - nat::ipv4_table_dump(switch, from_hardware) + nat::table_dump::(switch, from_hardware) } TableType::NatIngressIpv6 => { - nat::ipv6_table_dump(switch, from_hardware) + nat::table_dump::(switch, from_hardware) } TableType::AttachedSubnetIpv4 => { attached_subnet_v4::table_dump(switch, from_hardware) @@ -329,10 +330,10 @@ pub fn get_counters( TableType::ArpIpv4 => arp_ipv4::counter_fetch(switch, force_sync), TableType::PortMacAddress => mac::counter_fetch(switch, force_sync), TableType::NatIngressIpv4 => { - nat::ipv4_counter_fetch(switch, force_sync) + nat::counter_fetch::(switch, force_sync) } TableType::NatIngressIpv6 => { - nat::ipv6_counter_fetch(switch, force_sync) + nat::counter_fetch::(switch, force_sync) } TableType::PortAddrIpv4 => { port_ip::ipv4_counter_fetch(switch, force_sync) diff --git a/dpd/src/table/nat.rs b/dpd/src/table/nat.rs index 5d15f3ca..edba403e 100644 --- a/dpd/src/table/nat.rs +++ b/dpd/src/table/nat.rs @@ -7,19 +7,114 @@ use dpd_types::table; use std::convert::TryInto; use std::fmt; +use std::hash::Hash; use std::net::{Ipv4Addr, Ipv6Addr}; -use slog::debug; +use slog::{debug, error}; use aal::{ActionParse, MatchParse, MatchRange}; use aal_macros::*; use crate::Switch; +use crate::nat::PortRange; use crate::table::*; use common::network::{MacAddr, NatTarget}; +pub(crate) trait NatAddress: Copy + Ord + fmt::Display { + const TABLE: TableType; + const NAME: &'static str; + + type MatchKey: MatchParse + Hash + fmt::Display; + type Action: ActionParse; + + fn match_key(self, ports: PortRange) -> Self::MatchKey; + fn action(tgt: NatTarget) -> Self::Action; +} + +pub(crate) fn add_entry( + s: &Switch, + nat_ip: A, + ports: PortRange, + tgt: NatTarget, +) -> DpdResult<()> { + let key = nat_ip.match_key(ports); + debug!(s.log, "add nat entry {} -> {:?}", key, tgt); + s.table_entry_add(A::TABLE, &key, &A::action(tgt)) +} + +pub(crate) fn delete_entry( + s: &Switch, + nat_ip: A, + ports: PortRange, +) -> DpdResult<()> { + let key = nat_ip.match_key(ports); + debug!(s.log, "remove nat entry {}", key); + s.table_entry_del(A::TABLE, &key) +} + +pub(super) fn table_dump( + s: &Switch, + from_hardware: bool, +) -> DpdResult { + s.table_dump::(A::TABLE, from_hardware) +} + +pub(super) fn counter_fetch( + s: &Switch, + force_sync: bool, +) -> DpdResult> { + s.counter_fetch::(force_sync, A::TABLE) +} + +pub(crate) fn reset(s: &Switch) -> DpdResult<()> { + debug!(s.log, "resetting {} nat table", A::NAME); + s.table_clear(A::TABLE).inspect_err(|e| { + error!(s.log, "failed to reset {} nat table: {:?}", A::NAME, e); + }) +} + +impl NatAddress for Ipv4Addr { + const TABLE: TableType = TableType::NatIngressIpv4; + const NAME: &'static str = "ipv4"; + + type MatchKey = Ipv4MatchKey; + type Action = Ipv4Action; + + fn match_key(self, ports: PortRange) -> Ipv4MatchKey { + Ipv4MatchKey::new(self, ports.low(), ports.high()) + } + + fn action(tgt: NatTarget) -> Ipv4Action { + Ipv4Action::Forward { + target: tgt.internal_ip, + inner_mac: tgt.inner_mac, + vni: tgt.vni.as_u32(), + } + } +} + +impl NatAddress for Ipv6Addr { + const TABLE: TableType = TableType::NatIngressIpv6; + const NAME: &'static str = "ipv6"; + + type MatchKey = Ipv6MatchKey; + type Action = Ipv6Action; + + fn match_key(self, ports: PortRange) -> Ipv6MatchKey { + Ipv6MatchKey::new(self, ports.low(), ports.high()) + } + + fn action(tgt: NatTarget) -> Ipv6Action { + Ipv6Action::Forward { + target: tgt.internal_ip, + inner_mac: tgt.inner_mac, + vni: tgt.vni.as_u32(), + } + } +} + #[derive(MatchParse, Hash)] -struct Ipv6MatchKey { +pub(crate) struct Ipv6MatchKey { dst_addr: Ipv6Addr, #[match_xlate(name = "l4_dst_port", type = "range")] @@ -27,7 +122,7 @@ struct Ipv6MatchKey { } impl Ipv6MatchKey { - pub fn new(dst_addr: Ipv6Addr, low: T, high: T) -> Self + fn new(dst_addr: Ipv6Addr, low: T, high: T) -> Self where T: std::convert::Into, { @@ -45,7 +140,7 @@ impl fmt::Display for Ipv6MatchKey { } #[derive(MatchParse, Hash)] -struct Ipv4MatchKey { +pub(crate) struct Ipv4MatchKey { dst_addr: Ipv4Addr, #[match_xlate(name = "l4_dst_port", type = "range")] @@ -53,7 +148,7 @@ struct Ipv4MatchKey { } impl Ipv4MatchKey { - pub fn new(dst_addr: Ipv4Addr, low: T, high: T) -> Self + fn new(dst_addr: Ipv4Addr, low: T, high: T) -> Self where T: std::convert::Into, { @@ -71,116 +166,13 @@ impl fmt::Display for Ipv4MatchKey { } #[derive(ActionParse)] -enum Ipv6Action { +pub(crate) enum Ipv6Action { #[action_xlate(name = "forward_ipv6_to")] Forward { target: Ipv6Addr, inner_mac: MacAddr, vni: u32 }, } #[derive(ActionParse)] -enum Ipv4Action { +pub(crate) enum Ipv4Action { #[action_xlate(name = "forward_ipv4_to")] Forward { target: Ipv6Addr, inner_mac: MacAddr, vni: u32 }, } - -pub fn add_ipv6_entry( - s: &Switch, - nat_ip: Ipv6Addr, - nat_port_low: u16, - nat_port_high: u16, - tgt: NatTarget, -) -> DpdResult<()> { - let match_key = Ipv6MatchKey::new(nat_ip, nat_port_low, nat_port_high); - let action_key = Ipv6Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - - debug!(s.log, "add nat entry {} -> {:?}", match_key, tgt); - - s.table_entry_add(TableType::NatIngressIpv6, &match_key, &action_key) -} - -pub fn delete_ipv6_entry( - s: &Switch, - nat_ip: Ipv6Addr, - nat_port_low: u16, - nat_port_high: u16, -) -> DpdResult<()> { - let match_key = Ipv6MatchKey::new(nat_ip, nat_port_low, nat_port_high); - debug!(s.log, "remove nat entry {}", match_key); - s.table_entry_del(TableType::NatIngressIpv6, &match_key) -} - -pub fn reset_ipv6(s: &Switch) -> DpdResult<()> { - s.table_clear(TableType::NatIngressIpv6) -} - -pub fn add_ipv4_entry( - s: &Switch, - nat_ip: Ipv4Addr, - nat_port_low: u16, - nat_port_high: u16, - tgt: NatTarget, -) -> DpdResult<()> { - let match_key = Ipv4MatchKey::new(nat_ip, nat_port_low, nat_port_high); - let action_key = Ipv4Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - - debug!(s.log, "add nat entry {} -> {:?}", match_key, tgt); - - s.table_entry_add(TableType::NatIngressIpv4, &match_key, &action_key) -} - -pub fn delete_ipv4_entry( - s: &Switch, - nat_ip: Ipv4Addr, - nat_port_low: u16, - nat_port_high: u16, -) -> DpdResult<()> { - let match_key = Ipv4MatchKey::new(nat_ip, nat_port_low, nat_port_high); - debug!(s.log, "remove nat entry {}", match_key); - s.table_entry_del(TableType::NatIngressIpv4, &match_key) -} - -pub fn ipv4_table_dump( - s: &Switch, - from_hardware: bool, -) -> DpdResult { - s.table_dump::( - TableType::NatIngressIpv4, - from_hardware, - ) -} - -pub fn ipv6_table_dump( - s: &Switch, - from_hardware: bool, -) -> DpdResult { - s.table_dump::( - TableType::NatIngressIpv6, - from_hardware, - ) -} - -pub fn ipv4_counter_fetch( - s: &Switch, - force_sync: bool, -) -> DpdResult> { - s.counter_fetch::(force_sync, TableType::NatIngressIpv4) -} - -pub fn ipv6_counter_fetch( - s: &Switch, - force_sync: bool, -) -> DpdResult> { - s.counter_fetch::(force_sync, TableType::NatIngressIpv6) -} - -/// Delete many IPv6 address from the ASIC tables. -pub fn reset_ipv4(s: &Switch) -> DpdResult<()> { - s.table_clear(TableType::NatIngressIpv4) -} From 55a6051fdc17caf274231be81cac7637952d3a39 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Thu, 6 Aug 2026 10:46:25 -0300 Subject: [PATCH 3/4] unify the per-family NAT mapping code Replace the duplicated v4/v6 mapping storage and logic with a single NatEntry type and a NatMap generic over NatAddress; the public API keeps its per-family signatures as thin wrappers. --- dpd/src/api_server.rs | 35 ++- dpd/src/main.rs | 4 +- dpd/src/nat.rs | 496 ++++++++++++------------------------------ dpd/src/rpw/mod.rs | 4 +- dpd/src/table/nat.rs | 24 ++ 5 files changed, 180 insertions(+), 383 deletions(-) diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index e65d6ddb..ea111235 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -105,13 +105,14 @@ use crate::attached_subnet; use crate::counters; #[cfg(feature = "multicast")] use crate::mcast; +use crate::nat; use crate::oxstats; use crate::rpw::Task; use crate::switch_port::FixedSideDevice; use crate::switch_port::LedState; use crate::transceivers::PowerState; use crate::types::DpdError; -use crate::{Switch, arp, loopback, nat, ports, route}; +use crate::{Switch, arp, loopback, ports, route}; use common::attached_subnet::AttachedSubnetEntry; use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::{InstanceTarget, MacAddr, NatTarget}; @@ -1422,7 +1423,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(Ipv6Token { ip }) => Some(*ip), }; - let entries = nat::get_ipv6_addrs_range( + let entries = nat::get_addrs_range( switch, last_addr, usize::try_from(max).expect("invalid usize"), @@ -1449,7 +1450,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(NatToken { port }) => Some(*port), }; - let entries = nat::get_ipv6_mappings_range( + let entries = nat::get_mappings_range( switch, params.ipv6, port, @@ -1469,8 +1470,7 @@ impl DpdApi for DpdApiImpl { ) -> Result, HttpError> { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - match nat::get_ipv6_mapping(switch, params.ipv6, params.low, params.low) - { + match nat::get_mapping(switch, params.ipv6, params.low, params.low) { Ok(tgt) => Ok(HttpResponseOk(tgt)), Err(e) => Err(e.into()), } @@ -1483,7 +1483,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - match nat::set_ipv6_mapping( + match nat::add_mapping( switch, params.ipv6, params.low, @@ -1501,7 +1501,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - nat::clear_ipv6_mapping(switch, params.ipv6, params.low, params.low) + nat::remove_mapping(switch, params.ipv6, params.low, params.low) .map(|_| HttpResponseDeleted()) .map_err(HttpError::from) } @@ -1511,7 +1511,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); - match nat::reset_ipv6(switch) { + match nat::reset::(switch) { Ok(_) => Ok(HttpResponseUpdatedNoContent()), Err(e) => Err(e.into()), } @@ -1530,7 +1530,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(Ipv4Token { ip }) => Some(*ip), }; - let entries = nat::get_ipv4_addrs_range( + let entries = nat::get_addrs_range( switch, last_addr, usize::try_from(max).expect("invalid usize"), @@ -1558,7 +1558,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(NatToken { port }) => Some(*port), }; - let entries = nat::get_ipv4_mappings_range( + let entries = nat::get_mappings_range( switch, params.ipv4, port, @@ -1578,8 +1578,7 @@ impl DpdApi for DpdApiImpl { ) -> Result, HttpError> { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - match nat::get_ipv4_mapping(switch, params.ipv4, params.low, params.low) - { + match nat::get_mapping(switch, params.ipv4, params.low, params.low) { Ok(tgt) => Ok(HttpResponseOk(tgt)), Err(e) => Err(e.into()), } @@ -1592,7 +1591,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - match nat::set_ipv4_mapping( + match nat::add_mapping( switch, params.ipv4, params.low, @@ -1610,7 +1609,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let params = path.into_inner(); - nat::clear_ipv4_mapping(switch, params.ipv4, params.low, params.low) + nat::remove_mapping(switch, params.ipv4, params.low, params.low) .map(|_| HttpResponseDeleted()) .map_err(HttpError::from) } @@ -1620,7 +1619,7 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); - match nat::reset_ipv4(switch) { + match nat::reset::(switch) { Ok(_) => Ok(HttpResponseUpdatedNoContent()), Err(e) => Err(e.into()), } @@ -1744,11 +1743,11 @@ impl DpdApi for DpdApiImpl { error!(switch.log, "failed to clear all link state: {:?}", e); err = Some(e); } - if let Err(e) = nat::reset_ipv4(switch) { + if let Err(e) = nat::reset::(switch) { error!(switch.log, "failed to reset ipv4 nat table: {:?}", e); err = Some(e); } - if let Err(e) = nat::reset_ipv6(switch) { + if let Err(e) = nat::reset::(switch) { error!(switch.log, "failed to reset ipv6 nat table: {:?}", e); err = Some(e); } @@ -1913,7 +1912,7 @@ impl DpdApi for DpdApiImpl { ) -> Result, HttpError> { let switch = rqctx.context(); - Ok(HttpResponseOk(nat::get_nat_generation(switch))) + Ok(HttpResponseOk(nat::generation(switch))) } async fn nat_trigger_update( diff --git a/dpd/src/main.rs b/dpd/src/main.rs index e978abbd..1e2d91c4 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -193,7 +193,7 @@ pub struct Switch { pub links: Mutex, pub routes: TokioMutex, pub arp: Mutex, - pub nat: Mutex, + pub nat: nat::Nat, pub attached_subnet: Mutex, pub loopback: Mutex, pub identifiers: Mutex>, @@ -308,7 +308,7 @@ impl Switch { counters, routes: TokioMutex::new(route_data), arp: Mutex::new(arp::init()), - nat: Mutex::new(nat::init()), + nat: nat::Nat::new(), attached_subnet: Mutex::new(attached_subnet::init()), loopback: Mutex::new(loopback::init()), switch_ports, diff --git a/dpd/src/nat.rs b/dpd/src/nat.rs index 5234938d..c6031132 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -9,12 +9,12 @@ use std::collections::BTreeMap; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::Bound; +use std::sync::{Mutex, MutexGuard}; use crate::Switch; use crate::table; -use crate::table::nat::{add_entry, delete_entry}; +use crate::table::nat::{NatAddress, add_entry, delete_entry}; use crate::types::{DpdError, DpdResult}; -use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::NatTarget; /// An inclusive range of ports, guaranteed by construction to have @@ -62,42 +62,17 @@ impl fmt::Display for PortRange { } } -#[derive(PartialEq)] -pub(crate) struct Ipv6NatEntry { - pub l4_ports: PortRange, - pub tgt: NatTarget, -} - -impl fmt::Display for Ipv6NatEntry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", self.l4_ports, self.tgt) - } -} - #[derive(Clone, PartialEq)] -pub(crate) struct Ipv4NatEntry { +pub(crate) struct NatEntry { pub l4_ports: PortRange, pub tgt: NatTarget, } -impl fmt::Display for Ipv4NatEntry { +impl fmt::Display for NatEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} -> {}", self.l4_ports, self.tgt) } } -pub struct NatData { - ipv6_mappings: BTreeMap>, - ipv4_mappings: BTreeMap>, - ipv4_generation: i64, -} - -fn ipv6_entry(ipv6: Ipv6Addr, e: &Ipv6NatEntry) -> String { - format!("{ipv6}/{e}") -} - -fn ipv4_entry(ipv4: Ipv4Addr, e: &Ipv4NatEntry) -> String { - format!("{ipv4}/{e}") -} /// find index of first mapping that overlaps with supplied port range fn find_first_mapping( @@ -178,227 +153,115 @@ fn test_mapping() { assert_eq!(space(3, 8), None); } -pub fn get_ipv6_addrs_range( - switch: &Switch, - last_addr: Option, - mut max: usize, -) -> Vec { - max = std::cmp::min(max, 64); - let nat = switch.nat.lock().unwrap(); - - let range = match last_addr { - Some(a) => (Bound::Excluded(a), Bound::Unbounded), - None => (Bound::Unbounded, Bound::Unbounded), - }; +type NatMappings = BTreeMap>; - nat.ipv6_mappings.range(range).take(max).map(|(ip, _)| *ip).collect() +pub struct NatData { + ipv4: NatMappings, + ipv6: NatMappings, + generation: i64, } -/// Paginates through `Ipv6Nat` using `last_port` as the starting offset -pub fn get_ipv6_mappings_range( - switch: &Switch, - external: Ipv6Addr, - last_port: Option, - mut max: usize, -) -> Vec { - max = std::cmp::min(max, 64); - let nat = switch.nat.lock().unwrap(); - let mappings = match nat.ipv6_mappings.get(&external) { - Some(m) => m, - None => return Vec::new(), - }; - - let port = match last_port { - None => 0, - Some(l) => l + 1, - }; +/// Ties an address family to its NAT table inside `NatData`. +pub(crate) trait NatFamily: NatAddress { + fn mappings(data: &mut NatData) -> &mut NatMappings; +} - let mut entries = Vec::new(); - - for m in mappings { - if m.l4_ports.low >= port { - entries.push(Ipv6Nat { - external, - low: m.l4_ports.low, - high: m.l4_ports.high, - target: m.tgt, - }); - if entries.len() >= max { - break; - } - } +impl NatFamily for Ipv4Addr { + fn mappings(data: &mut NatData) -> &mut NatMappings { + &mut data.ipv4 } - entries } -/// Find the first `NatTarget` where its `Ipv6NatEntry` matches the provided -/// `Ipv6Addr` and overlaps with the provided port range -pub fn get_ipv6_mapping( - switch: &Switch, - nat_ip: Ipv6Addr, - low: u16, - high: u16, -) -> DpdResult { - let range = PortRange::new(low, high)?; - let nat = switch.nat.lock().unwrap(); - if let Some(v) = nat.ipv6_mappings.get(&nat_ip) - && let Some(idx) = - find_first_mapping(v.iter().map(|e| e.l4_ports), range) - { - return Ok(v[idx].tgt); +impl NatFamily for Ipv6Addr { + fn mappings(data: &mut NatData) -> &mut NatMappings { + &mut data.ipv6 } - Err(DpdError::Missing("no mapping".into())) } -pub fn set_ipv6_mapping( - switch: &Switch, - nat_ip: Ipv6Addr, - low: u16, - high: u16, - tgt: NatTarget, -) -> DpdResult<()> { - let l4_ports = PortRange::new(low, high)?; - let new_entry = Ipv6NatEntry { l4_ports, tgt }; - let full = ipv6_entry(nat_ip, &new_entry); - trace!(switch.log, "adding nat entry {}", full); +pub struct Nat(Mutex); - let mut nat = switch.nat.lock().unwrap(); - let (entries, idx) = match nat.ipv6_mappings.get_mut(&nat_ip) { - Some(e) => { - if e.contains(&new_entry) { - // entry already exists - return Ok(()); - } - match find_space(e.iter().map(|x| x.l4_ports), l4_ports) { - Some(i) => (e, i), - None => { - trace!( - switch.log, - "unable to add nat entry {}: conflicting mapping", full - ); - return Err(DpdError::Exists("conflicting mapping".into())); - } - } - } - None => { - nat.ipv6_mappings.insert(nat_ip, Vec::new()); - (nat.ipv6_mappings.get_mut(&nat_ip).unwrap(), 0) - } - }; +impl Nat { + pub(crate) fn new() -> Self { + Nat(Mutex::new(NatData { + ipv4: BTreeMap::new(), + ipv6: BTreeMap::new(), + generation: 0, + })) + } - match add_entry(switch, nat_ip, l4_ports, tgt) { - Err(e) => { - error!(switch.log, "failed to add {}: {:?}", full, e); - Err(e) - } - _ => { - debug!(switch.log, "added nat entry {}", full); - entries.insert(idx, new_entry); - Ok(()) - } + fn lock(&self) -> MutexGuard<'_, NatData> { + self.0.lock().unwrap() } } -/// Find the first `NatTarget` where its `Ipv6NatEntry` matches the provided -/// `Ipv6Addr` and overlaps with the provided port range, then remove it. -pub fn clear_ipv6_mapping( - switch: &Switch, - nat_ip: Ipv6Addr, - low: u16, - high: u16, -) -> DpdResult<()> { - let range = PortRange::new(low, high)?; - let mut nat = switch.nat.lock().unwrap(); - trace!(switch.log, "clearing nat entry {}/{}-{}", nat_ip, low, high); - - if let Some(mappings) = nat.ipv6_mappings.get_mut(&nat_ip) - && let Some(idx) = - find_first_mapping(mappings.iter().map(|e| e.l4_ports), range) - { - let ent = mappings.remove(idx); - if mappings.is_empty() { - nat.ipv6_mappings.remove(&nat_ip); - } - let full = ipv6_entry(nat_ip, &ent); - return match delete_entry(switch, nat_ip, ent.l4_ports) { - Err(e) => { - error!(switch.log, "failed to clear {}: {:?}", full, e); - Err(e) - } - _ => { - debug!(switch.log, "cleared nat entry {}", full); - Ok(()) - } - }; - } +pub(crate) fn generation(switch: &Switch) -> i64 { + let data = switch.nat.lock(); + trace!(switch.log, "fetching nat generation"); + data.generation +} - Ok(()) +pub(crate) fn set_generation(switch: &Switch, generation: i64) { + let mut data = switch.nat.lock(); + trace!(switch.log, "setting nat generation {generation}"); + data.generation = generation; } -pub fn get_ipv4_addrs_range( +pub(crate) fn get_addrs_range( switch: &Switch, - last_addr: Option, - mut max: usize, -) -> Vec { - max = std::cmp::min(max, 64); - let nat = switch.nat.lock().unwrap(); + last_addr: Option, + max: usize, +) -> Vec { + let max = max.min(64); let range = match last_addr { Some(a) => (Bound::Excluded(a), Bound::Unbounded), None => (Bound::Unbounded, Bound::Unbounded), }; - nat.ipv4_mappings.range(range).take(max).map(|(ip, _)| *ip).collect() + let mut data = switch.nat.lock(); + A::mappings(&mut data).range(range).take(max).map(|(ip, _)| *ip).collect() } -/// Paginates through `Ipv4Nat` using `last_port` as the starting offset -pub fn get_ipv4_mappings_range( +/// Paginates through the mappings for one address, using `last_port` as +/// the starting offset +pub(crate) fn get_mappings_range( switch: &Switch, - external: Ipv4Addr, + external: A, last_port: Option, - mut max: usize, -) -> Vec { - max = std::cmp::min(max, 64); - let nat = switch.nat.lock().unwrap(); - let mappings = match nat.ipv4_mappings.get(&external) { - Some(m) => m, - None => return Vec::new(), - }; + max: usize, +) -> Vec { + let max = max.min(64); let port = match last_port { None => 0, Some(l) => l + 1, }; - let mut entries = Vec::new(); - - for m in mappings { - if m.l4_ports.low >= port { - entries.push(Ipv4Nat { - external, - low: m.l4_ports.low, - high: m.l4_ports.high, - target: m.tgt, - }); - if entries.len() >= max { - break; - } - } - } - entries -} - -/// Find the first `NatTarget` where its `Ipv4NatEntry` matches the provided -/// `Ipv4Addr` and overlaps with the provided port range -pub fn get_ipv4_mapping( + let mut data = switch.nat.lock(); + A::mappings(&mut data) + .get(&external) + .map(|entries| { + entries + .iter() + .filter(|e| e.l4_ports.low >= port) + .take(max) + .map(|e| external.reservation(e.l4_ports, e.tgt)) + .collect() + }) + .unwrap_or_default() +} + +/// Find the first `NatTarget` where its `NatEntry` overlaps with the +/// provided port range +pub(crate) fn get_mapping( switch: &Switch, - nat_ip: Ipv4Addr, + nat_ip: A, low: u16, high: u16, ) -> DpdResult { let range = PortRange::new(low, high)?; - let nat = switch.nat.lock().unwrap(); - if let Some(v) = nat.ipv4_mappings.get(&nat_ip) + let mut data = switch.nat.lock(); + if let Some(v) = A::mappings(&mut data).get(&nat_ip) && let Some(idx) = find_first_mapping(v.iter().map(|e| e.l4_ports), range) { @@ -407,58 +270,33 @@ pub fn get_ipv4_mapping( Err(DpdError::Missing("no mapping".into())) } -pub fn set_mapping( - switch: &Switch, - nat_ip: IpAddr, - low: u16, - high: u16, - tgt: NatTarget, -) -> DpdResult<()> { - match nat_ip { - IpAddr::V4(nat_ip) => set_ipv4_mapping(switch, nat_ip, low, high, tgt), - IpAddr::V6(nat_ip) => set_ipv6_mapping(switch, nat_ip, low, high, tgt), - } -} - -pub fn set_ipv4_mapping( +pub(crate) fn add_mapping( switch: &Switch, - nat_ip: Ipv4Addr, + nat_ip: A, low: u16, high: u16, tgt: NatTarget, ) -> DpdResult<()> { let l4_ports = PortRange::new(low, high)?; - let new_entry = Ipv4NatEntry { l4_ports, tgt }; - let full = ipv4_entry(nat_ip, &new_entry); + let new_entry = NatEntry { l4_ports, tgt }; + let full = format!("{nat_ip}/{new_entry}"); trace!(switch.log, "adding nat entry {}", full); - let mut nat = switch.nat.lock().unwrap(); - let (entries, idx) = match nat.ipv4_mappings.get_mut(&nat_ip) { - Some(e) => { - if e.contains(&new_entry) { - // entry already exists - return Ok(()); - } - match find_space(e.iter().map(|x| x.l4_ports), l4_ports) { - Some(i) => (e, i), - None => { - error!( - switch.log, - "unable to add {}: conflicting mapping", full - ); - return Err(DpdError::Exists("conflicting mapping".into())); - } - } - } - None => { - nat.ipv4_mappings.insert(nat_ip, Vec::new()); - (nat.ipv4_mappings.get_mut(&nat_ip).unwrap(), 0) - } + let mut data = switch.nat.lock(); + let entries = A::mappings(&mut data).entry(nat_ip).or_default(); + if entries.contains(&new_entry) { + // entry already exists + return Ok(()); + } + let Some(idx) = find_space(entries.iter().map(|e| e.l4_ports), l4_ports) + else { + error!(switch.log, "unable to add {}: conflicting mapping", full); + return Err(DpdError::Exists("conflicting mapping".into())); }; match add_entry(switch, nat_ip, l4_ports, tgt) { Err(e) => { - error!(switch.log, "failed to add nat entry {}: {:?}", full, e); + error!(switch.log, "failed to add {}: {:?}", full, e); Err(e) } _ => { @@ -469,42 +307,41 @@ pub fn set_ipv4_mapping( } } -pub fn clear_mapping( +pub(crate) fn set_mapping( switch: &Switch, nat_ip: IpAddr, low: u16, high: u16, + tgt: NatTarget, ) -> DpdResult<()> { match nat_ip { - IpAddr::V4(nat_ip) => clear_ipv4_mapping(switch, nat_ip, low, high), - IpAddr::V6(nat_ip) => clear_ipv6_mapping(switch, nat_ip, low, high), + IpAddr::V4(ip) => add_mapping(switch, ip, low, high, tgt), + IpAddr::V6(ip) => add_mapping(switch, ip, low, high, tgt), } } -/// Find the first `NatTarget` where its `Ipv4NatEntry` matches the provided -/// `Ipv4Addr` and overlaps with the provided port range, then remove it. -pub fn clear_ipv4_mapping( +/// Find the first `NatEntry` that overlaps with the provided port range, +/// then remove it. +pub(crate) fn remove_mapping( switch: &Switch, - nat_ip: Ipv4Addr, + nat_ip: A, low: u16, high: u16, ) -> DpdResult<()> { let range = PortRange::new(low, high)?; - let mut nat = switch.nat.lock().unwrap(); - trace!( - switch.log, - "clearing nat entry covering {}/{}-{}", nat_ip, low, high - ); + trace!(switch.log, "clearing nat entry covering {}/{}", nat_ip, range); - if let Some(mappings) = nat.ipv4_mappings.get_mut(&nat_ip) + let mut data = switch.nat.lock(); + let mappings = A::mappings(&mut data); + if let Some(entries) = mappings.get_mut(&nat_ip) && let Some(idx) = - find_first_mapping(mappings.iter().map(|e| e.l4_ports), range) + find_first_mapping(entries.iter().map(|e| e.l4_ports), range) { - let ent = mappings.remove(idx); - if mappings.is_empty() { - nat.ipv4_mappings.remove(&nat_ip); + let ent = entries.remove(idx); + if entries.is_empty() { + mappings.remove(&nat_ip); } - let full = ipv4_entry(nat_ip, &ent); + let full = format!("{nat_ip}/{ent}"); return match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); @@ -520,86 +357,48 @@ pub fn clear_ipv4_mapping( Ok(()) } -pub fn clear_overlapping_mappings( +pub(crate) fn clear_mapping( switch: &Switch, nat_ip: IpAddr, low: u16, high: u16, ) -> DpdResult<()> { match nat_ip { - IpAddr::V4(nat_ip) => { - clear_overlapping_mappings_v4(switch, nat_ip, low, high) - } - IpAddr::V6(nat_ip) => { - clear_overlapping_mappings_v6(switch, nat_ip, low, high) - } + IpAddr::V4(ip) => remove_mapping(switch, ip, low, high), + IpAddr::V6(ip) => remove_mapping(switch, ip, low, high), } } -/// Deletes any `Ipv4NatEntry` where each entry matches the provided -/// `Ipv4Addr` and overlaps with the provided port range -pub fn clear_overlapping_mappings_v4( - switch: &Switch, - nat_ip: Ipv4Addr, - low: u16, - high: u16, -) -> DpdResult<()> { - let range = PortRange::new(low, high)?; - let mut nat = switch.nat.lock().unwrap(); - trace!( - switch.log, - "clearing all nat entries overlapping with {}/{}-{}", nat_ip, low, high - ); - - if let Some(mappings) = nat.ipv4_mappings.get_mut(&nat_ip) { - let mut mappings_to_delete = - find_mappings(mappings.iter().map(|e| e.l4_ports), range); - // delete starting with the last index first, or you'll end up shifting the - // collection underneath you - mappings_to_delete.reverse(); - for idx in mappings_to_delete { - let ent = mappings.remove(idx); - let full = ipv4_entry(nat_ip, &ent); - match delete_entry(switch, nat_ip, ent.l4_ports) { - Err(e) => { - error!(switch.log, "failed to clear {}: {:?}", full, e); - return Err(e); - } - _ => { - debug!(switch.log, "cleared nat entry {}", full); - } - }; - } - if mappings.is_empty() { - nat.ipv4_mappings.remove(&nat_ip); - } - } +pub(crate) fn reset(switch: &Switch) -> DpdResult<()> { + let mut data = switch.nat.lock(); + table::nat::reset::(switch)?; + A::mappings(&mut data).clear(); Ok(()) } -pub fn clear_overlapping_mappings_v6( +/// Deletes any `NatEntry` that overlaps with the provided port range +pub(crate) fn remove_overlapping_mappings( switch: &Switch, - nat_ip: Ipv6Addr, - low: u16, - high: u16, + nat_ip: A, + l4_ports: PortRange, ) -> DpdResult<()> { - let range = PortRange::new(low, high)?; - let mut nat = switch.nat.lock().unwrap(); trace!( switch.log, - "clearing all nat entries overlapping with {}/{}-{}", nat_ip, low, high + "clearing all nat entries overlapping with {}/{}", nat_ip, l4_ports ); - if let Some(mappings) = nat.ipv6_mappings.get_mut(&nat_ip) { + let mut data = switch.nat.lock(); + let mappings = A::mappings(&mut data); + if let Some(entries) = mappings.get_mut(&nat_ip) { let mut mappings_to_delete = - find_mappings(mappings.iter().map(|e| e.l4_ports), range); + find_mappings(entries.iter().map(|e| e.l4_ports), l4_ports); // delete starting with the last index first, or you'll end up shifting the // collection underneath you mappings_to_delete.reverse(); for idx in mappings_to_delete { - let ent = mappings.remove(idx); - let full = ipv6_entry(nat_ip, &ent); + let ent = entries.remove(idx); + let full = format!("{nat_ip}/{ent}"); match delete_entry(switch, nat_ip, ent.l4_ports) { Err(e) => { error!(switch.log, "failed to clear {}: {:?}", full, e); @@ -610,48 +409,23 @@ pub fn clear_overlapping_mappings_v6( } }; } - if mappings.is_empty() { - nat.ipv6_mappings.remove(&nat_ip); + if entries.is_empty() { + mappings.remove(&nat_ip); } } Ok(()) } -pub fn reset_ipv6(switch: &Switch) -> DpdResult<()> { - let mut nat = switch.nat.lock().unwrap(); - - table::nat::reset::(switch)?; - nat.ipv6_mappings.clear(); - Ok(()) -} - -pub fn reset_ipv4(switch: &Switch) -> DpdResult<()> { - let mut nat = switch.nat.lock().unwrap(); - - table::nat::reset::(switch)?; - nat.ipv4_mappings.clear(); - Ok(()) -} - -pub fn set_nat_generation(switch: &Switch, generation: i64) { - let mut nat = switch.nat.lock().unwrap(); - - debug!(switch.log, "setting nat generation"); - nat.ipv4_generation = generation; -} - -pub fn get_nat_generation(switch: &Switch) -> i64 { - let nat = switch.nat.lock().unwrap(); - - debug!(switch.log, "fetching nat generation"); - nat.ipv4_generation -} - -pub fn init() -> NatData { - NatData { - ipv6_mappings: BTreeMap::new(), - ipv4_mappings: BTreeMap::new(), - ipv4_generation: 0, +pub(crate) fn clear_overlapping_mappings( + switch: &Switch, + nat_ip: IpAddr, + low: u16, + high: u16, +) -> DpdResult<()> { + let l4_ports = PortRange::new(low, high)?; + match nat_ip { + IpAddr::V4(ip) => remove_overlapping_mappings(switch, ip, l4_ports), + IpAddr::V6(ip) => remove_overlapping_mappings(switch, ip, l4_ports), } } diff --git a/dpd/src/rpw/mod.rs b/dpd/src/rpw/mod.rs index 5b3ef261..99772309 100644 --- a/dpd/src/rpw/mod.rs +++ b/dpd/src/rpw/mod.rs @@ -101,7 +101,7 @@ pub async fn nat_workflow( wait(timer.clone()).await; debug!(log, "starting nat reconciliation"); - let generation = nat::get_nat_generation(&switch); + let generation = nat::generation(&switch); debug!(log, "we are currently at nat generation: {}", generation); let mut updates = @@ -216,7 +216,7 @@ fn apply_updates( } // update gen if nat entry update was successful generation = entry.r#gen; - nat::set_nat_generation(switch, generation); + nat::set_generation(switch, generation); } generation } diff --git a/dpd/src/table/nat.rs b/dpd/src/table/nat.rs index edba403e..c27e13c7 100644 --- a/dpd/src/table/nat.rs +++ b/dpd/src/table/nat.rs @@ -18,6 +18,7 @@ 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 { @@ -26,9 +27,12 @@ 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( @@ -79,6 +83,7 @@ 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()) @@ -91,6 +96,15 @@ 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 { @@ -99,6 +113,7 @@ 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()) @@ -111,6 +126,15 @@ 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)] From 273483383db59ea4bd55e41f4a1ffffb370d8739 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Fri, 7 Aug 2026 16:21:18 -0300 Subject: [PATCH 4/4] add tagged NAT apply API --- Cargo.lock | 1 + dpd-api/src/lib.rs | 92 ++- dpd-client/tests/integration_tests/nat.rs | 407 ++++++++++ dpd-types/versions/src/impls/mod.rs | 1 + dpd-types/versions/src/impls/nat.rs | 97 +++ dpd-types/versions/src/latest.rs | 11 + dpd-types/versions/src/lib.rs | 2 + .../versions/src/nat_tagged_apply/mod.rs | 13 + .../versions/src/nat_tagged_apply/nat.rs | 97 +++ dpd/src/api_server.rs | 91 ++- dpd/src/nat.rs | 700 +++++++++++++++++- openapi/dpd/dpd-12.0.0-a135ff.json.gitstub | 1 + ...0.0-a135ff.json => dpd-13.0.0-ebcb20.json} | 345 ++++++++- openapi/dpd/dpd-latest.json | 2 +- swadm/Cargo.toml | 1 + swadm/src/nat.rs | 203 ++++- 16 files changed, 2052 insertions(+), 12 deletions(-) create mode 100644 dpd-types/versions/src/impls/nat.rs create mode 100644 dpd-types/versions/src/nat_tagged_apply/mod.rs create mode 100644 dpd-types/versions/src/nat_tagged_apply/nat.rs create mode 100644 openapi/dpd/dpd-12.0.0-a135ff.json.gitstub rename openapi/dpd/{dpd-12.0.0-a135ff.json => dpd-13.0.0-ebcb20.json} (96%) diff --git a/Cargo.lock b/Cargo.lock index d9876e5b..d1cac410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7288,6 +7288,7 @@ dependencies = [ "oxnet", "regex", "reqwest 0.13.2", + "serde_json", "slog", "tabwriter", "tokio", diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 3aac525b..d12b119e 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -39,6 +39,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (13, NAT_TAGGED_APPLY), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), (10, ASIC_DETAILS), @@ -1353,6 +1354,7 @@ pub trait DpdApi { /** * Clear all IPv6 NAT mappings. */ + // Note: this clears every mapping, including tagged entries. #[endpoint { method = DELETE, path = "/nat/ipv6" @@ -1436,6 +1438,7 @@ pub trait DpdApi { /** * Clear all IPv4 NAT mappings. */ + // Note: this clears every mapping, including tagged entries. #[endpoint { method = DELETE, path = "/nat/ipv4" @@ -1444,6 +1447,92 @@ pub trait DpdApi { rqctx: RequestContext, ) -> Result; + /** + * Apply the complete set of IPv4 NAT entries for a tag. + * + * The request body is the full desired set of IPv4 NAT entries for this + * tag; dpd diffs it against current state and converges, creating missing + * entries and removing tagged entries absent from the request. + * + * An invalid request (a malformed port range or entries that overlap + * within the request) is rejected wholesale. Otherwise every entry is + * attempted: entries that conflict with mappings not carrying this tag + * and entries whose dataplane update fails are reported per-entry in + * `add_failures`/`remove_failures` rather than failing the request. + * + * Re-applying the same set is idempotent and performs no dataplane + * operations. + */ + #[endpoint { + method = PUT, + path = "/nat/tagged/{tag}/ipv4", + versions = VERSION_NAT_TAGGED_APPLY.., + }] + async fn nat_tagged_ipv4_apply( + rqctx: RequestContext, + path: Path, + body: TypedBody>, + ) -> Result, HttpError>; + + /** + * Apply the complete set of IPv6 NAT entries for a tag. + * + * The request body is the full desired set of IPv6 NAT entries for this + * tag; dpd diffs it against current state and converges, creating missing + * entries and removing tagged entries absent from the request. + * + * An invalid request (a malformed port range or entries that overlap + * within the request) is rejected wholesale. Otherwise every entry is + * attempted: entries that conflict with mappings not carrying this tag + * and entries whose dataplane update fails are reported per-entry in + * `add_failures`/`remove_failures` rather than failing the request. + * + * Re-applying the same set is idempotent and performs no dataplane + * operations. + */ + #[endpoint { + method = PUT, + path = "/nat/tagged/{tag}/ipv6", + versions = VERSION_NAT_TAGGED_APPLY.., + }] + async fn nat_tagged_ipv6_apply( + rqctx: RequestContext, + path: Path, + body: TypedBody>, + ) -> Result, HttpError>; + + /** + * Get all of the IPv4 NAT entries carrying a tag. + */ + #[endpoint { + method = GET, + path = "/nat/tagged/{tag}/ipv4", + versions = VERSION_NAT_TAGGED_APPLY.., + }] + async fn nat_tagged_ipv4_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + + /** + * Get all of the IPv6 NAT entries carrying a tag. + */ + #[endpoint { + method = GET, + path = "/nat/tagged/{tag}/ipv6", + versions = VERSION_NAT_TAGGED_APPLY.., + }] + async fn nat_tagged_ipv6_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + /** * Get all of the external subnets with internal mappings */ @@ -1525,7 +1614,8 @@ pub trait DpdApi { /// - All ARP or NDP table entries. /// - All routes /// - All links on all switch ports - // Note: This endpoint does not clear multicast groups. + // Note: This endpoint does not clear multicast groups or tagged NAT + // entries. // TODO-security: This endpoint should probably not exist. #[endpoint { method = DELETE, diff --git a/dpd-client/tests/integration_tests/nat.rs b/dpd-client/tests/integration_tests/nat.rs index 9d7372a1..001a4067 100644 --- a/dpd-client/tests/integration_tests/nat.rs +++ b/dpd-client/tests/integration_tests/nat.rs @@ -6,10 +6,12 @@ use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use std::num::NonZeroU32; use std::sync::Arc; use anyhow::anyhow; use oxnet::Ipv6Net; +use reqwest::StatusCode; use ::common::network::MacAddr; use ::common::network::Vni; @@ -702,3 +704,408 @@ async fn test_ingress_ipv6_tcp() -> TestResult { let switch = &*get_switch().await; test_ingress_ipv6(switch, L4Protocol::Tcp).await } + +fn nat_tag(tag: &str) -> types::NatTag { + tag.parse().expect("valid NAT tag") +} + +fn test_target(vni: u32) -> types::NatTarget { + types::NatTarget { + internal_ip: "fd00:1122:7788:0101::4".parse().unwrap(), + inner_mac: MacAddr::new(2, 4, 6, 8, 10, 12).into(), + vni: Vni::new(vni).unwrap().into(), + } +} + +fn v4_nat( + external: Ipv4Addr, + low: u16, + high: u16, + target: &types::NatTarget, +) -> types::Ipv4Nat { + types::Ipv4Nat { external, low, high, target: target.clone() } +} + +fn v6_nat( + external: Ipv6Addr, + low: u16, + high: u16, + target: &types::NatTarget, +) -> types::Ipv6Nat { + types::Ipv6Nat { external, low, high, target: target.clone() } +} + +async fn tagged_v4( + switch: &Switch, + tag: &types::NatTag, +) -> Vec { + switch + .client + .nat_tagged_ipv4_list_stream(tag, None) + .try_collect() + .await + .expect("should be able to list tagged IPv4 NAT entries") +} + +async fn tagged_v6( + switch: &Switch, + tag: &types::NatTag, +) -> Vec { + switch + .client + .nat_tagged_ipv6_list_stream(tag, None) + .try_collect() + .await + .expect("should be able to list tagged IPv6 NAT entries") +} + +async fn list_v4(switch: &Switch, external: &Ipv4Addr) -> Vec { + switch + .client + .nat_ipv4_list_stream(external, None) + .try_collect() + .await + .expect("should be able to list IPv4 NAT entries") +} + +async fn apply_v4_expect_status( + switch: &Switch, + tag: &types::NatTag, + request: &[types::Ipv4Nat], + status: StatusCode, +) { + let err = switch + .client + .nat_tagged_ipv4_apply(tag, &request.to_vec()) + .await + .expect_err("tagged NAT apply should fail"); + let dpd_client::Error::ErrorResponse(inner) = err else { + panic!("expected an error response, got: {err:?}"); + }; + assert_eq!(inner.status(), status); +} + +// Apply `request` expecting every entry to fail as a conflict, and return +// the failure reasons. +async fn apply_v4_expect_conflicts( + switch: &Switch, + tag: &types::NatTag, + request: &[types::Ipv4Nat], +) -> Vec { + let result = switch + .client + .nat_tagged_ipv4_apply(tag, &request.to_vec()) + .await + .expect("tagged NAT apply should succeed") + .into_inner(); + assert!(result.added.is_empty()); + assert!(result.unchanged.is_empty()); + assert!(result.removed.is_empty()); + assert!(result.remove_failures.is_empty()); + assert_eq!(result.add_failures.len(), request.len()); + result.add_failures.into_iter().map(|f| f.error).collect() +} + +// A tagged apply only affects entries carrying its tag: untagged entries +// survive, and applying an empty set removes exactly the tagged entries. +#[tokio::test] +#[ignore] +async fn test_tagged_apply_isolation() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(222); + + let ext_untagged = Ipv4Addr::new(10, 0, 0, 1); + let ext_tagged = Ipv4Addr::new(10, 0, 0, 2); + let ext6_untagged = "fd00:9999::1".parse::().unwrap(); + let ext6_tagged = "fd00:9999::2".parse::().unwrap(); + + client.nat_ipv4_create(&ext_untagged, 100, 199, &tgt).await?; + client.nat_ipv6_create(&ext6_untagged, 100, 199, &tgt).await?; + + let tag = nat_tag("svc-a"); + let req_v4 = vec![ + v4_nat(ext_tagged, 1000, 1999, &tgt), + v4_nat(ext_tagged, 2000, 2999, &tgt), + ]; + let req_v6 = vec![v6_nat(ext6_tagged, 1000, 1999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &req_v4).await?.into_inner(); + assert_eq!(result.added.len(), 2); + assert!(result.unchanged.is_empty()); + assert!(result.removed.is_empty()); + assert!(result.add_failures.is_empty()); + assert!(result.remove_failures.is_empty()); + let result = + client.nat_tagged_ipv6_apply(&tag, &req_v6).await?.into_inner(); + assert_eq!(result.added.len(), 1); + assert!(result.add_failures.is_empty()); + assert!(result.remove_failures.is_empty()); + + // The tagged listings show exactly the applied set. + assert_eq!(tagged_v4(switch, &tag).await, req_v4); + assert_eq!(tagged_v6(switch, &tag).await, req_v6); + + // The untagged entries are untouched. + assert_eq!(list_v4(switch, &ext_untagged).await.len(), 1); + + // Applying an empty set removes only the tagged entries. + let result = + client.nat_tagged_ipv4_apply(&tag, &vec![]).await?.into_inner(); + assert_eq!(result.removed.len(), 2); + let result = + client.nat_tagged_ipv6_apply(&tag, &vec![]).await?.into_inner(); + assert_eq!(result.removed.len(), 1); + assert!(tagged_v4(switch, &tag).await.is_empty()); + assert!(tagged_v6(switch, &tag).await.is_empty()); + + assert_eq!(list_v4(switch, &ext_untagged).await.len(), 1); + let v6_untagged: Vec = + client.nat_ipv6_list_stream(&ext6_untagged, None).try_collect().await?; + assert_eq!(v6_untagged.len(), 1); + + Ok(()) +} + +// An identical untagged entry is not adopted: any entry not carrying the +// tag is a conflict, and the untagged entry is left untouched. +#[tokio::test] +#[ignore] +async fn test_tagged_apply_no_adoption() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(333); + let ext = Ipv4Addr::new(10, 0, 1, 1); + + client.nat_ipv4_create(&ext, 1024, 2047, &tgt).await?; + let before = list_v4(switch, &ext).await; + + let tag = nat_tag("svc-adopt"); + let request = vec![v4_nat(ext, 1024, 2047, &tgt)]; + apply_v4_expect_conflicts(switch, &tag, &request).await; + + // The untagged entry is untouched and remains untagged. + assert_eq!(list_v4(switch, &ext).await, before); + assert!(tagged_v4(switch, &tag).await.is_empty()); + + Ok(()) +} + +// Re-applying the same set is a no-op: everything is reported unchanged. +#[tokio::test] +#[ignore] +async fn test_tagged_apply_idempotent() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(222); + let ext = Ipv4Addr::new(10, 0, 2, 1); + let ext6 = "fd00:9999::3".parse::().unwrap(); + + let tag = nat_tag("svc-idem"); + let req_v4 = + vec![v4_nat(ext, 1000, 1999, &tgt), v4_nat(ext, 2000, 2999, &tgt)]; + let req_v6 = vec![v6_nat(ext6, 1000, 1999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &req_v4).await?.into_inner(); + assert_eq!(result.added.len(), 2); + let result = + client.nat_tagged_ipv6_apply(&tag, &req_v6).await?.into_inner(); + assert_eq!(result.added.len(), 1); + + let result = + client.nat_tagged_ipv4_apply(&tag, &req_v4).await?.into_inner(); + assert_eq!(result.unchanged.len(), 2); + assert!(result.added.is_empty()); + assert!(result.removed.is_empty()); + let result = + client.nat_tagged_ipv6_apply(&tag, &req_v6).await?.into_inner(); + assert_eq!(result.unchanged.len(), 1); + assert!(result.added.is_empty()); + assert!(result.removed.is_empty()); + + assert_eq!(tagged_v4(switch, &tag).await, req_v4); + assert_eq!(tagged_v6(switch, &tag).await, req_v6); + + Ok(()) +} + +// Retargeting an entry replaces it, and out-of-band deletion through the +// classic per-entry API is healed by the next apply. +#[tokio::test] +#[ignore] +async fn test_tagged_apply_heals_drift() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(222); + let ext = Ipv4Addr::new(10, 0, 3, 1); + + let tag = nat_tag("svc-drift"); + let request = vec![v4_nat(ext, 1000, 1999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &request).await?.into_inner(); + assert_eq!(result.added.len(), 1); + + // Retargeting the same port range removes the old entry and adds the + // new one. + let tgt2 = test_target(555); + let retarget = vec![v4_nat(ext, 1000, 1999, &tgt2)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &retarget).await?.into_inner(); + assert_eq!(result.removed.len(), 1); + assert_eq!(result.added.len(), 1); + assert_eq!( + client.nat_ipv4_get(&ext, 1000).await?.into_inner(), + tgt2, + "retarget should be visible through the classic API", + ); + + // The classic API remains tag-oblivious: it can delete a tagged entry. + client.nat_ipv4_delete(&ext, 1000).await?; + assert!(tagged_v4(switch, &tag).await.is_empty()); + + // The next apply heals the drift. + let result = + client.nat_tagged_ipv4_apply(&tag, &retarget).await?.into_inner(); + assert_eq!(result.added.len(), 1); + assert!(result.unchanged.is_empty()); + assert_eq!(tagged_v4(switch, &tag).await, retarget); + + Ok(()) +} + +// Tagged listings paginate across external addresses and skip entries +// not carrying the tag. +#[tokio::test] +#[ignore] +async fn test_tagged_list_pagination() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(222); + + let addrs = [ + Ipv4Addr::new(10, 0, 4, 1), + Ipv4Addr::new(10, 0, 4, 2), + Ipv4Addr::new(10, 0, 4, 3), + ]; + let ext6 = "fd00:9999::4".parse::().unwrap(); + + // Interleave entries the listing must skip: an untagged entry and an + // entry carrying another tag, both on addresses the tag also uses. + client.nat_ipv4_create(&addrs[1], 7000, 7999, &tgt).await?; + let other = nat_tag("svc-other"); + let other_request = vec![v4_nat(addrs[0], 8000, 8999, &tgt)]; + client.nat_tagged_ipv4_apply(&other, &other_request).await?; + + let tag = nat_tag("svc-page"); + let mut req_v4 = Vec::new(); + for addr in addrs { + for low in [1000, 3000, 5000] { + req_v4.push(v4_nat(addr, low, low + 999, &tgt)); + } + } + let req_v6 = + vec![v6_nat(ext6, 1000, 1999, &tgt), v6_nat(ext6, 2000, 2999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &req_v4).await?.into_inner(); + assert_eq!(result.added.len(), 9); + let result = + client.nat_tagged_ipv6_apply(&tag, &req_v6).await?.into_inner(); + assert_eq!(result.added.len(), 2); + + // Stream with a small page size to force pagination; the stitched + // result must be exactly the applied set, in (address, low) order. + let paged: Vec = client + .nat_tagged_ipv4_list_stream(&tag, NonZeroU32::new(2)) + .try_collect() + .await?; + assert_eq!(paged, req_v4); + + let paged6: Vec = client + .nat_tagged_ipv6_list_stream(&tag, NonZeroU32::new(1)) + .try_collect() + .await?; + assert_eq!(paged6, req_v6); + + assert_eq!(tagged_v4(switch, &other).await, other_request); + + Ok(()) +} + +// Invalid requests are rejected as a whole; tag conflicts are +// reported per-entry without blocking the rest of the request. +#[tokio::test] +#[ignore] +async fn test_tagged_apply_conflicts() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let tgt = test_target(222); + let tgt2 = test_target(555); + let ext = Ipv4Addr::new(10, 0, 5, 1); + + // An untagged entry and an entry carrying another tag. + client.nat_ipv4_create(&ext, 1024, 2047, &tgt).await?; + let other = nat_tag("svc-other"); + let other_request = vec![v4_nat(ext, 3000, 3999, &tgt)]; + client.nat_tagged_ipv4_apply(&other, &other_request).await?; + + let tag = nat_tag("svc-conflict"); + let snapshot = list_v4(switch, &ext).await; + + // Every flavor of tag conflict is reported per-entry, with + // nothing applied: + // - identical key as the untagged entry, but a different target + // - overlap with the untagged entry + // - identical to an entry carrying another tag + // - overlap with an entry carrying another tag + for entry in [ + v4_nat(ext, 1024, 2047, &tgt2), + v4_nat(ext, 2000, 2500, &tgt), + v4_nat(ext, 3000, 3999, &tgt), + v4_nat(ext, 3500, 4500, &tgt), + ] { + apply_v4_expect_conflicts(switch, &tag, &[entry]).await; + assert_eq!(list_v4(switch, &ext).await, snapshot); + assert!(tagged_v4(switch, &tag).await.is_empty()); + } + + // Overlap within the request itself is invalid and rejected wholesale. + let request = + vec![v4_nat(ext, 5000, 5999, &tgt), v4_nat(ext, 5500, 6500, &tgt)]; + apply_v4_expect_status(switch, &tag, &request, StatusCode::BAD_REQUEST) + .await; + + // So is an invalid port range. + let request = vec![v4_nat(ext, 7000, 6000, &tgt)]; + apply_v4_expect_status(switch, &tag, &request, StatusCode::BAD_REQUEST) + .await; + + // Nothing was applied by any of the failed requests. + assert_eq!(list_v4(switch, &ext).await, snapshot); + assert!(tagged_v4(switch, &tag).await.is_empty()); + assert_eq!(tagged_v4(switch, &other).await, other_request); + + // A conflicting entry does not block the valid entries alongside it. + let request = + vec![v4_nat(ext, 2000, 2500, &tgt), v4_nat(ext, 5000, 5999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &request).await?.into_inner(); + assert_eq!(result.added, vec![v4_nat(ext, 5000, 5999, &tgt)]); + assert_eq!(result.add_failures.len(), 1); + assert_eq!(result.add_failures[0].entry, v4_nat(ext, 2000, 2500, &tgt)); + assert!(result.remove_failures.is_empty()); + assert_eq!( + tagged_v4(switch, &tag).await, + vec![v4_nat(ext, 5000, 5999, &tgt)] + ); + + // Dropping the conflicting entry from the next apply converges: the + // added entry is unchanged and nothing is removed. + let request = vec![v4_nat(ext, 5000, 5999, &tgt)]; + let result = + client.nat_tagged_ipv4_apply(&tag, &request).await?.into_inner(); + assert_eq!(result.unchanged.len(), 1); + assert!(result.removed.is_empty()); + assert!(result.add_failures.is_empty()); + + Ok(()) +} diff --git a/dpd-types/versions/src/impls/mod.rs b/dpd-types/versions/src/impls/mod.rs index 0793200a..9dc54703 100644 --- a/dpd-types/versions/src/impls/mod.rs +++ b/dpd-types/versions/src/impls/mod.rs @@ -8,6 +8,7 @@ mod link; pub(crate) mod mcast; +pub(crate) mod nat; mod port_map; mod route; mod serdes; diff --git a/dpd-types/versions/src/impls/nat.rs b/dpd-types/versions/src/impls/nat.rs new file mode 100644 index 00000000..2c292ae7 --- /dev/null +++ b/dpd-types/versions/src/impls/nat.rs @@ -0,0 +1,97 @@ +// 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 + +//! Functional code for the latest versions of NAT types. + +use std::fmt; +use std::str::FromStr; + +use crate::latest::nat::NatTag; + +/// Maximum length for NAT tags. +pub const MAX_NAT_TAG_LENGTH: usize = 80; + +/// Error parsing a NAT tag from a string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NatTagParseError(String); + +impl fmt::Display for NatTagParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for NatTagParseError {} + +impl FromStr for NatTag { + type Err = NatTagParseError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(NatTagParseError("tag cannot be empty".to_string())); + } + if s.len() > MAX_NAT_TAG_LENGTH { + return Err(NatTagParseError(format!( + "tag cannot exceed {MAX_NAT_TAG_LENGTH} bytes" + ))); + } + if !s.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') + }) { + return Err(NatTagParseError( + "tag must contain only ASCII alphanumeric characters, \ + hyphens, underscores, colons, or periods" + .to_string(), + )); + } + Ok(NatTag(s.to_string())) + } +} + +impl TryFrom for NatTag { + type Error = NatTagParseError; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +impl From for String { + fn from(tag: NatTag) -> Self { + tag.0 + } +} + +impl AsRef for NatTag { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for NatTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nat_tag_parsing() { + assert!("omicron-service-nat".parse::().is_ok()); + assert!("a".parse::().is_ok()); + assert!("A-Z_0.9:x".parse::().is_ok()); + assert!("a".repeat(MAX_NAT_TAG_LENGTH).parse::().is_ok()); + + assert!("".parse::().is_err()); + assert!("a".repeat(MAX_NAT_TAG_LENGTH + 1).parse::().is_err()); + assert!("has space".parse::().is_err()); + assert!("slash/y".parse::().is_err()); + assert!("uniçode".parse::().is_err()); + } +} diff --git a/dpd-types/versions/src/latest.rs b/dpd-types/versions/src/latest.rs index 3350c606..5d6503a4 100644 --- a/dpd-types/versions/src/latest.rs +++ b/dpd-types/versions/src/latest.rs @@ -96,6 +96,17 @@ pub mod nat { pub use crate::v1::nat::NatIpv6PortPath; pub use crate::v1::nat::NatIpv6RangePath; pub use crate::v1::nat::NatToken; + + pub use crate::v13::nat::Ipv4NatFailure; + pub use crate::v13::nat::Ipv6NatFailure; + pub use crate::v13::nat::NatTag; + pub use crate::v13::nat::NatTagPath; + pub use crate::v13::nat::NatTaggedApplyResultV4; + pub use crate::v13::nat::NatTaggedApplyResultV6; + pub use crate::v13::nat::NatTaggedV4Token; + pub use crate::v13::nat::NatTaggedV6Token; + + pub use crate::impls::nat::NatTagParseError; } pub mod port { diff --git a/dpd-types/versions/src/lib.rs b/dpd-types/versions/src/lib.rs index 5f99f707..b3f684d2 100644 --- a/dpd-types/versions/src/lib.rs +++ b/dpd-types/versions/src/lib.rs @@ -41,6 +41,8 @@ pub mod v10; pub mod v11; #[path = "prbs_error_tracking/mod.rs"] pub mod v12; +#[path = "nat_tagged_apply/mod.rs"] +pub mod v13; #[path = "attached_subnets/mod.rs"] pub mod v3; #[path = "v4_over_v6_routes/mod.rs"] diff --git a/dpd-types/versions/src/nat_tagged_apply/mod.rs b/dpd-types/versions/src/nat_tagged_apply/mod.rs new file mode 100644 index 00000000..b54fbc1b --- /dev/null +++ b/dpd-types/versions/src/nat_tagged_apply/mod.rs @@ -0,0 +1,13 @@ +// 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 `NAT_TAGGED_APPLY` of the DPD API. +//! +//! Adds a tag on NAT entries, an endpoint for declaratively applying the +//! complete set of NAT entries for a tag, and endpoints for listing the +//! entries carrying a tag. + +pub mod nat; diff --git a/dpd-types/versions/src/nat_tagged_apply/nat.rs b/dpd-types/versions/src/nat_tagged_apply/nat.rs new file mode 100644 index 00000000..35c59a1e --- /dev/null +++ b/dpd-types/versions/src/nat_tagged_apply/nat.rs @@ -0,0 +1,97 @@ +// 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 + +//! Public types for tagged NAT entry management introduced in the +//! `NAT_TAGGED_APPLY` version. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use common::nat::{Ipv4Nat, Ipv6Nat}; + +/// A tag identifying a set of NAT entries. +/// +/// Tag format: 1 to 80 ASCII bytes containing alphanumeric characters, +/// hyphens, underscores, colons, or periods. +#[derive( + Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, +)] +#[serde(try_from = "String", into = "String")] +pub struct NatTag( + #[schemars( + length(min = 1, max = 80), + regex(pattern = r"^[a-zA-Z0-9_.:-]+$") + )] + pub(crate) String, +); + +/// Path parameter for tagged NAT operations. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct NatTagPath { + pub tag: NatTag, +} + +/// An IPv4 NAT entry that could not be applied, along with the reason. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Ipv4NatFailure { + pub entry: Ipv4Nat, + pub error: String, +} + +/// An IPv6 NAT entry that could not be applied, along with the reason. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Ipv6NatFailure { + pub entry: Ipv6Nat, + pub error: String, +} + +/// The result of applying a tagged set of IPv4 NAT entries. +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct NatTaggedApplyResultV4 { + /// Entries already present under this tag and identical to the request. + pub unchanged: Vec, + /// Entries created. + pub added: Vec, + /// Tagged entries removed because they were absent from the request. + pub removed: Vec, + /// Entries that could not be created, either because they conflict with + /// mappings not carrying this tag or because the update failed. + pub add_failures: Vec, + /// Entries that could not be removed; non-empty only on partial failure. + pub remove_failures: Vec, +} + +/// The result of applying a tagged set of IPv6 NAT entries. +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct NatTaggedApplyResultV6 { + /// Entries already present under this tag and identical to the request. + pub unchanged: Vec, + /// Entries created. + pub added: Vec, + /// Tagged entries removed because they were absent from the request. + pub removed: Vec, + /// Entries that could not be created, either because they conflict with + /// mappings not carrying this tag or because the update failed. + pub add_failures: Vec, + /// Entries that could not be removed; non-empty only on partial failure. + pub remove_failures: Vec, +} + +/// A cursor into a paginated request for the IPv4 NAT entries carrying a tag. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct NatTaggedV4Token { + pub ip: Ipv4Addr, + pub port: u16, +} + +/// A cursor into a paginated request for the IPv6 NAT entries carrying a tag. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct NatTaggedV6Token { + pub ip: Ipv6Addr, + pub port: u16, +} diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index ea111235..bc5bcba4 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -39,7 +39,8 @@ use dpd_types::mcast::{ use dpd_types::misc::{BuildInfo, TagPath}; use dpd_types::nat::{ NatIpv4Path, NatIpv4PortPath, NatIpv4RangePath, NatIpv6Path, - NatIpv6PortPath, NatIpv6RangePath, NatToken, + NatIpv6PortPath, NatIpv6RangePath, NatTagPath, NatTaggedApplyResultV4, + NatTaggedApplyResultV6, NatTaggedV4Token, NatTaggedV6Token, NatToken, }; use dpd_types::oxstats::OximeterMetadata; use dpd_types::port::{ @@ -1625,6 +1626,94 @@ impl DpdApi for DpdApiImpl { } } + async fn nat_tagged_ipv4_apply( + rqctx: RequestContext>, + path: Path, + body: TypedBody>, + ) -> Result, HttpError> { + let switch: &Switch = rqctx.context(); + let tag = path.into_inner().tag; + let requested = body.into_inner(); + nat::apply_tagged_mappings_v4(switch, &tag, &requested) + .map(HttpResponseOk) + .map_err(HttpError::from) + } + + async fn nat_tagged_ipv6_apply( + rqctx: RequestContext>, + path: Path, + body: TypedBody>, + ) -> Result, HttpError> { + let switch: &Switch = rqctx.context(); + let tag = path.into_inner().tag; + let requested = body.into_inner(); + nat::apply_tagged_mappings_v6(switch, &tag, &requested) + .map(HttpResponseOk) + .map_err(HttpError::from) + } + + async fn nat_tagged_ipv4_list( + rqctx: RequestContext>, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let tag = path.into_inner().tag; + let pag_params = query.into_inner(); + let max = rqctx.page_limit(&pag_params)?.get(); + + let last = match &pag_params.page { + WhichPage::First(..) => None, + WhichPage::Next(NatTaggedV4Token { ip, port }) => { + Some((*ip, *port)) + } + }; + + let entries = nat::get_mappings_by_tag_range( + switch, + &tag, + last, + usize::try_from(max).expect("invalid usize"), + ); + + Ok(HttpResponseOk(ResultsPage::new( + entries, + &EmptyScanParams {}, + |e: &Ipv4Nat, _| NatTaggedV4Token { ip: e.external, port: e.low }, + )?)) + } + + async fn nat_tagged_ipv6_list( + rqctx: RequestContext>, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let tag = path.into_inner().tag; + let pag_params = query.into_inner(); + let max = rqctx.page_limit(&pag_params)?.get(); + + let last = match &pag_params.page { + WhichPage::First(..) => None, + WhichPage::Next(NatTaggedV6Token { ip, port }) => { + Some((*ip, *port)) + } + }; + + let entries = nat::get_mappings_by_tag_range( + switch, + &tag, + last, + usize::try_from(max).expect("invalid usize"), + ); + + Ok(HttpResponseOk(ResultsPage::new( + entries, + &EmptyScanParams {}, + |e: &Ipv6Nat, _| NatTaggedV6Token { ip: e.external, port: e.low }, + )?)) + } + async fn attached_subnet_list( rqctx: RequestContext>, query: Query>, diff --git a/dpd/src/nat.rs b/dpd/src/nat.rs index c6031132..e7d47880 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -5,7 +5,7 @@ // Copyright 2026 Oxide Computer Company use slog::{debug, error, trace}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::Bound; @@ -15,11 +15,16 @@ use crate::Switch; use crate::table; use crate::table::nat::{NatAddress, add_entry, delete_entry}; use crate::types::{DpdError, DpdResult}; +use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::NatTarget; +use dpd_types::nat::{ + Ipv4NatFailure, Ipv6NatFailure, NatTag, NatTaggedApplyResultV4, + NatTaggedApplyResultV6, +}; -/// An inclusive range of ports, guaranteed by construction to have +/// An inclusive range of l4_ports, guaranteed by construction to have /// `low <= high`. -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct PortRange { low: u16, high: u16, @@ -62,10 +67,21 @@ impl fmt::Display for PortRange { } } -#[derive(Clone, PartialEq)] +#[derive(Clone, Debug)] pub(crate) struct NatEntry { pub l4_ports: PortRange, pub tgt: NatTarget, + /// Set when the entry was created via the tagged apply API. + pub tag: Option, +} + +// The tag does not participate in entry identity: the classic per-entry +// API is tag-oblivious, so creating an entry identical to a tagged one +// remains an idempotent no-op. +impl PartialEq for NatEntry { + fn eq(&self, other: &Self) -> bool { + self.l4_ports == other.l4_ports && self.tgt == other.tgt + } } impl fmt::Display for NatEntry { @@ -178,6 +194,337 @@ impl NatFamily for Ipv6Addr { } } +/// One NAT mapping in validated, family-neutral form. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Mapping { + external: A, + l4_ports: PortRange, + target: NatTarget, +} + +impl Mapping { + fn new( + external: A, + low: u16, + high: u16, + target: NatTarget, + ) -> DpdResult { + let l4_ports = PortRange::new(low, high).map_err(|_| { + DpdError::Invalid(format!( + "invalid port range {low}-{high} for {external}" + )) + })?; + Ok(Mapping { external, l4_ports, target }) + } +} + +/// The classification of a tagged apply request against current state. +struct Plan { + unchanged: Vec>, + to_add: Vec>, + to_remove: Vec>, + /// Requested mappings that conflict with entries not carrying this tag, + /// with the reason; these are reported as failures without being applied. + conflicts: Vec<(Mapping, String)>, +} + +impl Plan { + fn new() -> Self { + Self { + unchanged: Vec::new(), + to_add: Vec::new(), + to_remove: Vec::new(), + conflicts: Vec::new(), + } + } +} + +/// The per-entry results of executing a plan, in family-neutral form; +/// converted into the API result types by the public entry points. +struct ApplyOutcome { + unchanged: Vec>, + added: Vec>, + removed: Vec>, + add_failures: Vec<(Mapping, String)>, + remove_failures: Vec<(Mapping, String)>, +} + +/// Classify a complete requested set of NAT mappings for `tag` against +/// the current state, without modifying anything. +/// +/// Mapping identity is the full (external, l4_ports, target) triple. Each +/// requested mapping is classified as `unchanged` (identical entry +/// carrying this tag) or `to_add`. A requested mapping that overlaps an +/// entry carrying this tag replaces it (the existing entry lands in +/// `to_remove`). Any overlap with an entry *not* carrying this tag +/// (untagged entries included) lands the requested mapping in +/// `conflicts` without affecting the rest of the request. An internally +/// overlapping request fails wholesale. +fn make_plan( + mappings: &NatMappings, + tag: &NatTag, + requested: &[Mapping], +) -> DpdResult> { + // Sorted by (address, low port), two requested ranges on the same + // address overlap iff an adjacent pair does. + let mut ranges: Vec<(A, PortRange)> = + requested.iter().map(|m| (m.external, m.l4_ports)).collect(); + ranges + .sort_unstable_by_key(|&(external, l4_ports)| (external, l4_ports.low)); + for w in ranges.windows(2) { + let (ext_a, a) = w[0]; + let (ext_b, b) = w[1]; + if ext_a == ext_b && a.overlaps(b) { + return Err(DpdError::Invalid(format!( + "requested entries overlap on {ext_a}: {a} and {b}" + ))); + } + } + + let mut plan = Plan::new(); + let mut keep = BTreeSet::new(); + + for &req in requested { + let Some(entries) = mappings.get(&req.external) else { + plan.to_add.push(req); + continue; + }; + let overlapping = + find_mappings(entries.iter().map(|e| e.l4_ports), req.l4_ports); + let foreign = overlapping + .iter() + .copied() + .find(|&i| entries[i].tag.as_ref() != Some(tag)); + if let Some(i) = foreign { + plan.conflicts.push(( + req, + format!( + "requested entry {}/{} conflicts with existing \ + entry {}/{} not carrying tag {}", + req.external, + req.l4_ports, + req.external, + entries[i].l4_ports, + tag + ), + )); + continue; + } + // Every overlap carries this tag. Current entries never + // overlap one another, so an entry identical to the request is + // necessarily the only overlap. + let identical = overlapping.iter().copied().find(|&i| { + entries[i].l4_ports == req.l4_ports && entries[i].tgt == req.target + }); + if let Some(i) = identical { + keep.insert((req.external, i)); + plan.unchanged.push(req); + continue; + } + // Any remaining overlaps carry this tag but are not identical: + // those entries are replaced by the requested one (removed in + // the sweep below). + plan.to_add.push(req); + } + + // Every entry carrying this tag that was not matched above is + // removed. + for (external, entries) in mappings { + for (i, e) in entries.iter().enumerate() { + if e.tag.as_ref() == Some(tag) && !keep.contains(&(*external, i)) { + plan.to_remove.push(Mapping { + external: *external, + l4_ports: e.l4_ports, + target: e.tgt, + }); + } + } + } + + Ok(plan) +} + +/// Execute the removals and additions from a plan, updating the +/// in-memory mappings entry-by-entry as each ASIC operation succeeds. +/// Removals precede additions: entries are keyed by (address, port +/// range), so retargeting is delete-then-create. Every scheduled +/// operation is attempted; per-entry failures are reported rather than +/// short-circuiting. +fn apply_plan( + switch: &Switch, + mappings: &mut NatMappings, + tag: &NatTag, + plan: Plan, +) -> ApplyOutcome { + let mut outcome = ApplyOutcome { + unchanged: plan.unchanged, + added: Vec::new(), + removed: Vec::new(), + add_failures: plan.conflicts, + remove_failures: Vec::new(), + }; + + for req in plan.to_remove { + let Mapping { external, l4_ports, .. } = req; + match delete_entry(switch, external, l4_ports) { + Ok(()) => { + let entries = mappings.get_mut(&external).unwrap(); + entries.retain(|e| e.l4_ports != l4_ports); + if entries.is_empty() { + mappings.remove(&external); + } + debug!( + switch.log, + "removed tagged nat entry {}/{}", external, l4_ports + ); + outcome.removed.push(req); + } + Err(e) => { + error!( + switch.log, + "failed to remove tagged nat entry {}/{}: {:?}", + external, + l4_ports, + e + ); + outcome.remove_failures.push((req, e.to_string())); + } + } + } + + for req in plan.to_add { + let Mapping { external, l4_ports, target } = req; + let entries = mappings.entry(external).or_default(); + // Re-check for space at apply time: a failed removal may still + // occupy the requested range. + let Some(idx) = + find_space(entries.iter().map(|e| e.l4_ports), l4_ports) + else { + // No space implies an overlapping entry exists. + let i = find_first_mapping( + entries.iter().map(|e| e.l4_ports), + l4_ports, + ) + .unwrap(); + outcome.add_failures.push(( + req, + format!( + "requested entry {}/{} conflicts with entry {}/{} \ + still present after a failed removal", + external, l4_ports, external, entries[i].l4_ports + ), + )); + continue; + }; + match add_entry(switch, external, l4_ports, target) { + Ok(()) => { + entries.insert( + idx, + NatEntry { l4_ports, tgt: target, tag: Some(tag.clone()) }, + ); + debug!( + switch.log, + "added tagged nat entry {}/{}", external, l4_ports + ); + outcome.added.push(req); + } + Err(e) => { + error!( + switch.log, + "failed to add tagged nat entry {}/{}: {:?}", + external, + l4_ports, + e + ); + if entries.is_empty() { + mappings.remove(&external); + } + outcome.add_failures.push((req, e.to_string())); + } + } + } + + outcome +} + +impl From> for Ipv4Nat { + fn from(m: Mapping) -> Self { + Ipv4Nat { + external: m.external, + low: m.l4_ports.low, + high: m.l4_ports.high, + target: m.target, + } + } +} + +impl From> for Ipv6Nat { + fn from(m: Mapping) -> Self { + Ipv6Nat { + external: m.external, + low: m.l4_ports.low, + high: m.l4_ports.high, + target: m.target, + } + } +} + +impl From> for NatTaggedApplyResultV4 { + fn from(outcome: ApplyOutcome) -> Self { + let failure = |(req, error): (Mapping<_>, _)| Ipv4NatFailure { + entry: req.into(), + error, + }; + NatTaggedApplyResultV4 { + unchanged: outcome + .unchanged + .into_iter() + .map(Ipv4Nat::from) + .collect(), + added: outcome.added.into_iter().map(Ipv4Nat::from).collect(), + removed: outcome.removed.into_iter().map(Ipv4Nat::from).collect(), + add_failures: outcome + .add_failures + .into_iter() + .map(failure) + .collect(), + remove_failures: outcome + .remove_failures + .into_iter() + .map(failure) + .collect(), + } + } +} + +impl From> for NatTaggedApplyResultV6 { + fn from(outcome: ApplyOutcome) -> Self { + let failure = |(req, error): (Mapping<_>, _)| Ipv6NatFailure { + entry: req.into(), + error, + }; + NatTaggedApplyResultV6 { + unchanged: outcome + .unchanged + .into_iter() + .map(Ipv6Nat::from) + .collect(), + added: outcome.added.into_iter().map(Ipv6Nat::from).collect(), + removed: outcome.removed.into_iter().map(Ipv6Nat::from).collect(), + add_failures: outcome + .add_failures + .into_iter() + .map(failure) + .collect(), + remove_failures: outcome + .remove_failures + .into_iter() + .map(failure) + .collect(), + } + } +} + pub struct Nat(Mutex); impl Nat { @@ -251,6 +598,48 @@ pub(crate) fn get_mappings_range( .unwrap_or_default() } +/// Paginates through the entries carrying `tag`, using the +/// `(address, low port)` of the last entry returned as the starting +/// offset. +/// +/// The walk crosses external addresses, scanning past entries not +/// carrying `tag` until the page is full or the map is exhausted. +pub(crate) fn get_mappings_by_tag_range( + switch: &Switch, + tag: &NatTag, + last: Option<(A, u16)>, + max: usize, +) -> Vec { + let max = max.min(64); + + let start = match last { + Some((ip, _)) => Bound::Included(ip), + None => Bound::Unbounded, + }; + + let mut data = switch.nat.lock(); + let mut results = Vec::new(); + for (external, entries) in + A::mappings(&mut data).range((start, Bound::Unbounded)) + { + for e in entries { + if let Some(last) = last + && (*external, e.l4_ports.low) <= last + { + continue; + } + if e.tag.as_ref() != Some(tag) { + continue; + } + results.push(external.reservation(e.l4_ports, e.tgt)); + if results.len() >= max { + return results; + } + } + } + results +} + /// Find the first `NatTarget` where its `NatEntry` overlaps with the /// provided port range pub(crate) fn get_mapping( @@ -278,7 +667,7 @@ pub(crate) fn add_mapping( tgt: NatTarget, ) -> DpdResult<()> { let l4_ports = PortRange::new(low, high)?; - let new_entry = NatEntry { l4_ports, tgt }; + let new_entry = NatEntry { l4_ports, tgt, tag: None }; let full = format!("{nat_ip}/{new_entry}"); trace!(switch.log, "adding nat entry {}", full); @@ -429,3 +818,304 @@ pub(crate) fn clear_overlapping_mappings( IpAddr::V6(ip) => remove_overlapping_mappings(switch, ip, l4_ports), } } + +/// Apply `requested` as the complete desired set of NAT entries for +/// `tag` in one address family, diffing it against current state and +/// converging. +/// +/// The whole operation runs under a single acquisition of the NAT lock, +/// so it is atomic with respect to the individual create/delete +/// operations. Validation failures fail the request with nothing +/// applied; conflicts with entries not carrying this tag and ASIC +/// failures while converging are reported per-entry in the result. An +/// apply that matches current state performs zero ASIC operations. +pub(crate) fn apply_tagged_mappings_v4( + switch: &Switch, + tag: &NatTag, + requested: &[Ipv4Nat], +) -> DpdResult { + let req = requested + .iter() + .map(|e| Mapping::new(e.external, e.low, e.high, e.target)) + .collect::>>()?; + + let mut data = switch.nat.lock(); + let plan = make_plan(&data.ipv4, tag, &req)?; + Ok(apply_plan(switch, &mut data.ipv4, tag, plan).into()) +} + +/// IPv6 flavor of [`apply_tagged_mappings_v4`]. +pub(crate) fn apply_tagged_mappings_v6( + switch: &Switch, + tag: &NatTag, + requested: &[Ipv6Nat], +) -> DpdResult { + let req = requested + .iter() + .map(|e| Mapping::new(e.external, e.low, e.high, e.target)) + .collect::>>()?; + + let mut data = switch.nat.lock(); + let plan = make_plan(&data.ipv6, tag, &req)?; + Ok(apply_plan(switch, &mut data.ipv6, tag, plan).into()) +} + +#[cfg(test)] +mod tagged_tests { + use super::*; + use common::network::{MacAddr, Vni}; + + const TAG: &str = "test-tag"; + + fn tgt(vni: u32) -> NatTarget { + NatTarget { + internal_ip: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1), + inner_mac: MacAddr::new(2, 4, 6, 8, 10, 12), + vni: Vni::new(vni).unwrap(), + } + } + + fn ip(octet: u8) -> Ipv4Addr { + Ipv4Addr::new(10, 0, 0, octet) + } + + fn pr(low: u16, high: u16) -> PortRange { + PortRange::new(low, high).unwrap() + } + + fn entry( + low: u16, + high: u16, + tgt: NatTarget, + tag: Option<&str>, + ) -> NatEntry { + NatEntry { + l4_ports: pr(low, high), + tgt, + tag: tag.map(|t| t.parse().unwrap()), + } + } + + fn mapping( + external: A, + low: u16, + high: u16, + target: NatTarget, + ) -> Mapping { + Mapping::new(external, low, high, target).unwrap() + } + + fn plan( + map: &NatMappings, + requested: &[Mapping], + ) -> DpdResult> { + make_plan(map, &TAG.parse().unwrap(), requested) + } + + #[test] + fn test_tag_excluded_from_entry_equality() { + // Entry identity must remain (ports, tgt): an untagged create + // identical to a tagged entry must stay a no-op via `contains`. + assert_eq!(entry(1, 2, tgt(1), None), entry(1, 2, tgt(1), Some(TAG))); + assert_ne!(entry(1, 2, tgt(1), None), entry(1, 2, tgt(2), None)); + assert_ne!(entry(1, 2, tgt(1), None), entry(1, 3, tgt(1), None)); + assert!([entry(1, 2, tgt(1), Some(TAG))].contains(&entry( + 1, + 2, + tgt(1), + None + ))); + } + + #[test] + fn test_plan_empty_to_n() { + let map = NatMappings::new(); + let requested = vec![ + mapping(ip(1), 100, 200, tgt(1)), + mapping(ip(2), 100, 200, tgt(1)), + ]; + let p = plan(&map, &requested).unwrap(); + assert_eq!(p.to_add, requested); + assert!(p.unchanged.is_empty()); + assert!(p.to_remove.is_empty()); + } + + #[test] + fn test_plan_identical_is_all_unchanged() { + let map = NatMappings::from([ + (ip(1), vec![entry(100, 200, tgt(1), Some(TAG))]), + (ip(2), vec![entry(300, 400, tgt(2), Some(TAG))]), + ]); + let requested = vec![ + mapping(ip(1), 100, 200, tgt(1)), + mapping(ip(2), 300, 400, tgt(2)), + ]; + let p = plan(&map, &requested).unwrap(); + assert_eq!(p.unchanged, requested); + // Zero table operations: nothing to add or remove. + assert!(p.to_add.is_empty()); + assert!(p.to_remove.is_empty()); + } + + #[test] + fn test_plan_add_remove_retarget() { + let map = NatMappings::from([( + ip(1), + vec![ + entry(100, 200, tgt(1), Some(TAG)), + entry(300, 400, tgt(1), Some(TAG)), + entry(500, 600, tgt(1), Some(TAG)), + ], + )]); + let requested = vec![ + // unchanged + mapping(ip(1), 100, 200, tgt(1)), + // retarget: same range, new target + mapping(ip(1), 300, 400, tgt(2)), + // new entry; 500-600 is absent and should be removed + mapping(ip(1), 700, 800, tgt(1)), + ]; + let p = plan(&map, &requested).unwrap(); + assert_eq!(p.unchanged, vec![mapping(ip(1), 100, 200, tgt(1))]); + assert_eq!( + p.to_add, + vec![ + mapping(ip(1), 300, 400, tgt(2)), + mapping(ip(1), 700, 800, tgt(1)) + ] + ); + assert_eq!( + p.to_remove, + vec![ + mapping(ip(1), 300, 400, tgt(1)), + mapping(ip(1), 500, 600, tgt(1)) + ] + ); + } + + #[test] + fn test_plan_conflicts() { + // Identical entry carrying a different tag. + let map = NatMappings::from([( + ip(1), + vec![entry(100, 200, tgt(1), Some("other-tag"))], + )]); + let p = plan(&map, &[mapping(ip(1), 100, 200, tgt(1))]).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert_eq!(p.conflicts[0].0, mapping(ip(1), 100, 200, tgt(1))); + assert!(p.to_add.is_empty()); + + // Identical untagged entry: without adoption, any entry not + // carrying this tag is a conflict. + let map = + NatMappings::from([(ip(1), vec![entry(100, 200, tgt(1), None)])]); + let p = plan(&map, &[mapping(ip(1), 100, 200, tgt(1))]).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert!(p.to_add.is_empty()); + assert!(p.unchanged.is_empty()); + + // Overlapping range against an untagged entry. + let p = plan(&map, &[mapping(ip(1), 150, 250, tgt(1))]).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert!(p.to_add.is_empty()); + + // Same key, different target, against an untagged entry. + let p = plan(&map, &[mapping(ip(1), 100, 200, tgt(2))]).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert!(p.to_add.is_empty()); + + // Overlapping range against an entry carrying a foreign tag. + let map = NatMappings::from([( + ip(1), + vec![entry(100, 200, tgt(1), Some("other-tag"))], + )]); + let p = plan(&map, &[mapping(ip(1), 150, 250, tgt(1))]).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert!(p.to_add.is_empty()); + + // Intra-request overlap fails wholesale. + assert!(matches!( + plan( + &NatMappings::new(), + &[ + mapping(ip(1), 100, 200, tgt(1)), + mapping(ip(1), 200, 300, tgt(1)) + ] + ), + Err(DpdError::Invalid(_)) + )); + } + + #[test] + fn test_plan_conflict_does_not_block_others() { + // One conflicting entry must not affect the classification of the + // rest of the request or the removal sweep. + let map = NatMappings::from([( + ip(1), + vec![ + entry(100, 200, tgt(1), Some("other-tag")), + entry(300, 400, tgt(1), Some(TAG)), + ], + )]); + let requested = vec![ + // conflict: carries other-tag + mapping(ip(1), 100, 200, tgt(1)), + // new entry + mapping(ip(2), 100, 200, tgt(1)), + ]; + let p = plan(&map, &requested).unwrap(); + assert_eq!(p.conflicts.len(), 1); + assert_eq!(p.conflicts[0].0, mapping(ip(1), 100, 200, tgt(1))); + assert_eq!(p.to_add, vec![mapping(ip(2), 100, 200, tgt(1))]); + // The tagged entry absent from the request is still removed. + assert_eq!(p.to_remove, vec![mapping(ip(1), 300, 400, tgt(1))]); + } + + #[test] + fn test_plan_two_tags_coexist() { + let map = NatMappings::from([( + ip(1), + vec![ + entry(100, 200, tgt(1), Some(TAG)), + entry(300, 400, tgt(1), Some("other-tag")), + entry(500, 600, tgt(1), None), + ], + )]); + + // An empty apply for TAG removes only TAG's entry, leaving the + // foreign-tagged and untagged entries alone. + let p = plan(&map, &[]).unwrap(); + assert_eq!(p.to_remove, vec![mapping(ip(1), 100, 200, tgt(1))]); + assert!(p.to_add.is_empty()); + + // An identical apply for TAG touches nothing. + let p = plan(&map, &[mapping(ip(1), 100, 200, tgt(1))]).unwrap(); + assert_eq!(p.unchanged, vec![mapping(ip(1), 100, 200, tgt(1))]); + assert!(p.to_remove.is_empty()); + assert!(p.to_add.is_empty()); + } + + #[test] + fn test_plan_ipv6() { + let ip6 = |o: u16| Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, o); + let entry6 = |low, high, tgt, tag: Option<&str>| NatEntry { + l4_ports: pr(low, high), + tgt, + tag: tag.map(|t| t.parse().unwrap()), + }; + let map = NatMappings::from([ + (ip6(1), vec![entry6(100, 200, tgt(1), Some(TAG))]), + (ip6(2), vec![entry6(100, 200, tgt(1), None)]), + ]); + let requested = vec![ + mapping(ip6(1), 100, 200, tgt(1)), + mapping(ip6(3), 100, 200, tgt(1)), + ]; + let p = make_plan(&map, &TAG.parse().unwrap(), &requested).unwrap(); + assert_eq!(p.unchanged, vec![mapping(ip6(1), 100, 200, tgt(1))]); + assert_eq!(p.to_add, vec![mapping(ip6(3), 100, 200, tgt(1))]); + assert!(p.to_remove.is_empty()); + // The untagged entry on ip6(2) is not touched. + assert!(p.conflicts.is_empty()); + } +} diff --git a/openapi/dpd/dpd-12.0.0-a135ff.json.gitstub b/openapi/dpd/dpd-12.0.0-a135ff.json.gitstub new file mode 100644 index 00000000..ac977a6a --- /dev/null +++ b/openapi/dpd/dpd-12.0.0-a135ff.json.gitstub @@ -0,0 +1 @@ +ef7978f916c17d5851935b8e7c2c12db48f72097:openapi/dpd/dpd-12.0.0-a135ff.json diff --git a/openapi/dpd/dpd-12.0.0-a135ff.json b/openapi/dpd/dpd-13.0.0-ebcb20.json similarity index 96% rename from openapi/dpd/dpd-12.0.0-a135ff.json rename to openapi/dpd/dpd-13.0.0-ebcb20.json index 8e02fbd4..64982054 100644 --- a/openapi/dpd/dpd-12.0.0-a135ff.json +++ b/openapi/dpd/dpd-13.0.0-ebcb20.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "12.0.0" + "version": "13.0.0" }, "paths": { "/all-settings": { @@ -2218,6 +2218,214 @@ } } }, + "/nat/tagged/{tag}/ipv4": { + "get": { + "summary": "Get all of the IPv4 NAT entries carrying a tag.", + "operationId": "nat_tagged_ipv4_list", + "parameters": [ + { + "in": "path", + "name": "tag", + "required": true, + "schema": { + "$ref": "#/components/schemas/NatTag" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv4NatResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, + "put": { + "summary": "Apply the complete set of IPv4 NAT entries for a tag.", + "description": "The request body is the full desired set of IPv4 NAT entries for this tag; dpd diffs it against current state and converges, creating missing entries and removing tagged entries absent from the request.\n\nAn invalid request (a malformed port range or entries that overlap within the request) is rejected wholesale. Otherwise every entry is attempted: entries that conflict with mappings not carrying this tag and entries whose dataplane update fails are reported per-entry in `add_failures`/`remove_failures` rather than failing the request.\n\nRe-applying the same set is idempotent and performs no dataplane operations.", + "operationId": "nat_tagged_ipv4_apply", + "parameters": [ + { + "in": "path", + "name": "tag", + "required": true, + "schema": { + "$ref": "#/components/schemas/NatTag" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Array_of_Ipv4Nat", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Nat" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NatTaggedApplyResultV4" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/nat/tagged/{tag}/ipv6": { + "get": { + "summary": "Get all of the IPv6 NAT entries carrying a tag.", + "operationId": "nat_tagged_ipv6_list", + "parameters": [ + { + "in": "path", + "name": "tag", + "required": true, + "schema": { + "$ref": "#/components/schemas/NatTag" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv6NatResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, + "put": { + "summary": "Apply the complete set of IPv6 NAT entries for a tag.", + "description": "The request body is the full desired set of IPv6 NAT entries for this tag; dpd diffs it against current state and converges, creating missing entries and removing tagged entries absent from the request.\n\nAn invalid request (a malformed port range or entries that overlap within the request) is rejected wholesale. Otherwise every entry is attempted: entries that conflict with mappings not carrying this tag and entries whose dataplane update fails are reported per-entry in `add_failures`/`remove_failures` rather than failing the request.\n\nRe-applying the same set is idempotent and performs no dataplane operations.", + "operationId": "nat_tagged_ipv6_apply", + "parameters": [ + { + "in": "path", + "name": "tag", + "required": true, + "schema": { + "$ref": "#/components/schemas/NatTag" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Array_of_Ipv6Nat", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Nat" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NatTaggedApplyResultV6" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/ndp": { "get": { "summary": "Fetch the IPv6 NDP table entries.", @@ -6965,6 +7173,22 @@ "target" ] }, + "Ipv4NatFailure": { + "description": "An IPv4 NAT entry that could not be applied, along with the reason.", + "type": "object", + "properties": { + "entry": { + "$ref": "#/components/schemas/Ipv4Nat" + }, + "error": { + "type": "string" + } + }, + "required": [ + "entry", + "error" + ] + }, "Ipv4NatResultsPage": { "description": "A single page of results", "type": "object", @@ -7174,6 +7398,22 @@ "target" ] }, + "Ipv6NatFailure": { + "description": "An IPv6 NAT entry that could not be applied, along with the reason.", + "type": "object", + "properties": { + "entry": { + "$ref": "#/components/schemas/Ipv6Nat" + }, + "error": { + "type": "string" + } + }, + "required": [ + "entry", + "error" + ] + }, "Ipv6NatResultsPage": { "description": "A single page of results", "type": "object", @@ -8693,6 +8933,102 @@ "members" ] }, + "NatTaggedApplyResultV4": { + "description": "The result of applying a tagged set of IPv4 NAT entries.", + "type": "object", + "properties": { + "add_failures": { + "description": "Entries that could not be created, either because they conflict with mappings not carrying this tag or because the update failed.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4NatFailure" + } + }, + "added": { + "description": "Entries created.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Nat" + } + }, + "remove_failures": { + "description": "Entries that could not be removed; non-empty only on partial failure.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4NatFailure" + } + }, + "removed": { + "description": "Tagged entries removed because they were absent from the request.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Nat" + } + }, + "unchanged": { + "description": "Entries already present under this tag and identical to the request.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Nat" + } + } + }, + "required": [ + "add_failures", + "added", + "remove_failures", + "removed", + "unchanged" + ] + }, + "NatTaggedApplyResultV6": { + "description": "The result of applying a tagged set of IPv6 NAT entries.", + "type": "object", + "properties": { + "add_failures": { + "description": "Entries that could not be created, either because they conflict with mappings not carrying this tag or because the update failed.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6NatFailure" + } + }, + "added": { + "description": "Entries created.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Nat" + } + }, + "remove_failures": { + "description": "Entries that could not be removed; non-empty only on partial failure.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6NatFailure" + } + }, + "removed": { + "description": "Tagged entries removed because they were absent from the request.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Nat" + } + }, + "unchanged": { + "description": "Entries already present under this tag and identical to the request.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Nat" + } + } + }, + "required": [ + "add_failures", + "added", + "remove_failures", + "removed", + "unchanged" + ] + }, "NatTarget": { "description": "represents an internal NAT target", "type": "object", @@ -10716,6 +11052,13 @@ "pattern": "^[a-zA-Z0-9_.:-]+$", "minLength": 1, "maxLength": 80 + }, + "NatTag": { + "description": "A tag identifying a set of NAT entries.\n\nTag format: 1 to 80 ASCII bytes containing alphanumeric characters, hyphens, underscores, colons, or periods.", + "type": "string", + "pattern": "^[a-zA-Z0-9_.:-]+$", + "minLength": 1, + "maxLength": 80 } }, "responses": { diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index bab102da..4b21f1e7 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-12.0.0-a135ff.json \ No newline at end of file +dpd-13.0.0-ebcb20.json \ No newline at end of file diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 2c0bc18b..f142b240 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -21,6 +21,7 @@ oxide-tokio-rt.workspace = true oxnet.workspace = true regex.workspace = true reqwest.workspace = true +serde_json.workspace = true slog.workspace = true tabwriter.workspace = true tokio.workspace = true diff --git a/swadm/src/nat.rs b/swadm/src/nat.rs index 5beec07e..242d98ed 100644 --- a/swadm/src/nat.rs +++ b/swadm/src/nat.rs @@ -5,8 +5,9 @@ // Copyright 2026 Oxide Computer Company use std::convert::TryFrom; -use std::io::{Write, stdout}; +use std::io::{Read, Write, stdout}; use std::net::{IpAddr, Ipv6Addr}; +use std::path::PathBuf; use anyhow::Context; use clap::Subcommand; @@ -25,8 +26,35 @@ pub enum Nat { #[clap(visible_alias = "ls")] List { /// limit to the given external IP address", - #[clap(short = 'e')] + #[clap(short = 'e', conflicts_with = "tag")] external: Option, + /// limit to the entries carrying the given tag + #[clap(short = 't', long)] + tag: Option, + }, + /// apply the complete set of IPv4 NAT entries for a tag + /// + /// The request is a JSON array of NAT entries; dpd diffs it against + /// current state and converges. Entries carrying the tag but absent + /// from the request are removed, so an empty array removes them all. + ApplyIpv4 { + /// tag identifying the set of entries + #[clap(short = 't', long)] + tag: String, + /// file containing the request body (defaults to stdin) + file: Option, + }, + /// apply the complete set of IPv6 NAT entries for a tag + /// + /// The request is a JSON array of NAT entries; dpd diffs it against + /// current state and converges. Entries carrying the tag but absent + /// from the request are removed, so an empty array removes them all. + ApplyIpv6 { + /// tag identifying the set of entries + #[clap(short = 't', long)] + tag: String, + /// file containing the request body (defaults to stdin) + file: Option, }, /// get a single NAT reservation Get { @@ -143,6 +171,167 @@ async fn nat_list( Ok(()) } +async fn nat_list_tagged(client: &Client, tag: &str) -> anyhow::Result<()> { + let tag = tag + .parse::() + .map_err(|e| anyhow::anyhow!("invalid tag: {e}"))?; + + let mut tw = TabWriter::new(stdout()); + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}\t{}", + "External IP".underline(), + "Port low".underline(), + "Port high".underline(), + "Internal IP".underline(), + "Inner MAC".underline(), + "VNI".underline() + )?; + + let mut v4 = client.nat_tagged_ipv4_list_stream(&tag, None); + while let Some(entry) = + v4.try_next().await.context("failed to list tagged IPv4 NAT entries")? + { + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}\t{}", + entry.external, + entry.low, + entry.high, + entry.target.internal_ip, + MacAddr::from(entry.target.inner_mac), + entry.target.vni.0, + )?; + } + + let mut v6 = client.nat_tagged_ipv6_list_stream(&tag, None); + while let Some(entry) = + v6.try_next().await.context("failed to list tagged IPv6 NAT entries")? + { + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}\t{}", + entry.external, + entry.low, + entry.high, + entry.target.internal_ip, + MacAddr::from(entry.target.inner_mac), + entry.target.vni.0, + )?; + } + tw.flush()?; + + Ok(()) +} + +fn read_request(file: Option) -> anyhow::Result { + match file { + Some(path) => std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display())), + None => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("failed to read request from stdin")?; + Ok(buf) + } + } +} + +async fn nat_apply_ipv4( + client: &Client, + tag: &str, + file: Option, +) -> anyhow::Result<()> { + let tag = tag + .parse::() + .map_err(|e| anyhow::anyhow!("invalid tag: {e}"))?; + + let entries: Vec = + serde_json::from_str(&read_request(file)?) + .context("failed to parse request")?; + + let result = client + .nat_tagged_ipv4_apply(&tag, &entries) + .await + .context("failed to apply tagged IPv4 NAT entries")? + .into_inner(); + + println!( + "{} unchanged, {} added, {} removed, \ + {} add failures, {} remove failures", + result.unchanged.len(), + result.added.len(), + result.removed.len(), + result.add_failures.len(), + result.remove_failures.len(), + ); + for f in &result.add_failures { + eprintln!( + "failed to add {}/{}-{}: {}", + f.entry.external, f.entry.low, f.entry.high, f.error + ); + } + for f in &result.remove_failures { + eprintln!( + "failed to remove {}/{}-{}: {}", + f.entry.external, f.entry.low, f.entry.high, f.error + ); + } + if !result.add_failures.is_empty() || !result.remove_failures.is_empty() { + anyhow::bail!("apply completed with failures"); + } + + Ok(()) +} + +async fn nat_apply_ipv6( + client: &Client, + tag: &str, + file: Option, +) -> anyhow::Result<()> { + let tag = tag + .parse::() + .map_err(|e| anyhow::anyhow!("invalid tag: {e}"))?; + + let entries: Vec = + serde_json::from_str(&read_request(file)?) + .context("failed to parse request")?; + + let result = client + .nat_tagged_ipv6_apply(&tag, &entries) + .await + .context("failed to apply tagged IPv6 NAT entries")? + .into_inner(); + + println!( + "{} unchanged, {} added, {} removed, \ + {} add failures, {} remove failures", + result.unchanged.len(), + result.added.len(), + result.removed.len(), + result.add_failures.len(), + result.remove_failures.len(), + ); + for f in &result.add_failures { + eprintln!( + "failed to add {}/{}-{}: {}", + f.entry.external, f.entry.low, f.entry.high, f.error + ); + } + for f in &result.remove_failures { + eprintln!( + "failed to remove {}/{}-{}: {}", + f.entry.external, f.entry.low, f.entry.high, f.error + ); + } + if !result.add_failures.is_empty() || !result.remove_failures.is_empty() { + anyhow::bail!("apply completed with failures"); + } + + Ok(()) +} + async fn nat_get( client: &Client, external: IpAddr, @@ -224,7 +413,15 @@ async fn nat_del( pub async fn nat_cmd(client: &Client, n: Nat) -> anyhow::Result<()> { match n { - Nat::List { external } => nat_list(client, external).await, + // clap rejects combining `--tag` with `-e`. + Nat::List { tag: Some(tag), .. } => nat_list_tagged(client, &tag).await, + Nat::List { external, tag: None } => nat_list(client, external).await, + Nat::ApplyIpv4 { tag, file } => { + nat_apply_ipv4(client, &tag, file).await + } + Nat::ApplyIpv6 { tag, file } => { + nat_apply_ipv6(client, &tag, file).await + } Nat::Get { external, port } => nat_get(client, external, port).await, Nat::Add { external, low, high, internal, inner, vni } => { nat_add(client, external, low, high, internal, inner, vni).await