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 f8393f93..46cdf970 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::{ @@ -1624,6 +1625,96 @@ 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(); + switch + .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(); + switch + .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 = switch.nat.get_ipv4_mappings_by_tag_range( + &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 = switch.nat.get_ipv6_mappings_by_tag_range( + &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 f5b4c5a1..aa953981 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -5,7 +5,7 @@ // Copyright 2026 Oxide Computer Company use slog::{Logger, 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 { @@ -101,7 +117,7 @@ impl NatMap { external: A, last_port: Option, max: usize, - ) -> Vec { + ) -> Vec> { let max = max.min(64); let port = match last_port { @@ -116,7 +132,11 @@ impl NatMap { .iter() .filter(|e| e.l4_ports.low >= port) .take(max) - .map(|e| external.reservation(e.l4_ports, e.tgt)) + .map(|e| Mapping { + external, + l4_ports: e.l4_ports, + target: e.tgt, + }) .collect() }) .unwrap_or_default() @@ -143,13 +163,10 @@ impl NatMap { fn add_mapping( &mut self, switch: &Switch, - nat_ip: A, - low: u16, - high: u16, - tgt: NatTarget, + mapping: Mapping, ) -> DpdResult<()> { - let l4_ports = PortRange::new(low, high)?; - let new_entry = NatEntry { l4_ports, tgt }; + let Mapping { external: nat_ip, l4_ports, target: tgt } = mapping; + let new_entry = NatEntry { l4_ports, tgt, tag: None }; let full = format!("{nat_ip}/{new_entry}"); trace!(switch.log, "adding nat entry {}", full); @@ -259,6 +276,392 @@ impl NatMap { } } +/// 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)>, +} + +impl NatMap { + /// 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( + &self, + 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) = self.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 &self.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( + &mut self, + switch: &Switch, + 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 external.delete_entry(switch, l4_ports) { + Ok(()) => { + let entries = self.mappings.get_mut(&external).unwrap(); + entries.retain(|e| e.l4_ports != l4_ports); + if entries.is_empty() { + self.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 = self.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 external.add_entry(switch, 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() { + self.mappings.remove(&external); + } + outcome.add_failures.push((req, e.to_string())); + } + } + } + + outcome + } + + /// 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. + fn get_mappings_by_tag_range( + &self, + 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 entries = Vec::new(); + for (external, mappings) in + self.mappings.range((start, Bound::Unbounded)) + { + for m in mappings { + if let Some(last) = last + && (*external, m.l4_ports.low) <= last + { + continue; + } + if m.tag.as_ref() != Some(tag) { + continue; + } + entries.push(Mapping { + external: *external, + l4_ports: m.l4_ports, + target: m.tgt, + }); + if entries.len() >= max { + return entries; + } + } + } + entries + } +} + +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 { @@ -297,7 +700,12 @@ impl Nat { last_port: Option, max: usize, ) -> Vec { - self.with_family(|t| t.get_mappings_range(external, last_port, max)) + self.with_family(|t: &mut NatMap| { + t.get_mappings_range(external, last_port, max) + }) + .into_iter() + .map(|m| m.external.reservation(m.l4_ports, m.target)) + .collect() } pub(crate) fn get_mapping( @@ -317,7 +725,8 @@ impl Nat { high: u16, tgt: NatTarget, ) -> DpdResult<()> { - self.with_family(|t| t.add_mapping(switch, nat_ip, low, high, tgt)) + let mapping = Mapping::new(nat_ip, low, high, tgt)?; + self.with_family(|t| t.add_mapping(switch, mapping)) } pub(crate) fn remove_mapping( @@ -390,6 +799,81 @@ impl Nat { } } + /// 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( + &self, + 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 = self.lock(); + let plan = data.ipv4.make_plan(tag, &req)?; + Ok(data.ipv4.apply_plan(switch, tag, plan).into()) + } + + /// IPv6 flavor of [`Nat::apply_tagged_mappings_v4`]. + pub(crate) fn apply_tagged_mappings_v6( + &self, + 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 = self.lock(); + let plan = data.ipv6.make_plan(tag, &req)?; + Ok(data.ipv6.apply_plan(switch, tag, plan).into()) + } + + /// Paginates through the NAT entries carrying `tag` in one address + /// family, using the `(address, low port)` of the last entry returned + /// as the starting offset. + pub(crate) fn get_ipv4_mappings_by_tag_range( + &self, + tag: &NatTag, + last: Option<(Ipv4Addr, u16)>, + max: usize, + ) -> Vec { + self.lock() + .ipv4 + .get_mappings_by_tag_range(tag, last, max) + .into_iter() + .map(Ipv4Nat::from) + .collect() + } + + /// IPv6 flavor of [`Nat::get_ipv4_mappings_by_tag_range`]. + pub(crate) fn get_ipv6_mappings_by_tag_range( + &self, + tag: &NatTag, + last: Option<(Ipv6Addr, u16)>, + max: usize, + ) -> Vec { + self.lock() + .ipv6 + .get_mappings_by_tag_range(tag, last, max) + .into_iter() + .map(Ipv6Nat::from) + .collect() + } + pub(crate) fn generation(&self) -> i64 { let data = self.lock(); debug!(data.log, "fetching nat generation"); @@ -512,3 +996,281 @@ fn test_mapping() { assert_eq!(space(3, 5), None); assert_eq!(space(3, 8), None); } + +#[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: &NatMap, + requested: &[Mapping], + ) -> DpdResult> { + map.make_plan(&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 = NatMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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( + &NatMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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 = NatMap { + mappings: BTreeMap::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 = map.make_plan(&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