Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6d11109
feat(config): Extend VpcExposeNatConfig for port forwarding
qmonnet Feb 10, 2026
5cd452f
refactor(flow-filter): Remove Copy trait derivation for VpcdLookupResult
qmonnet Feb 11, 2026
94b4bd5
feat(flow-filter): Add RemoteData set to MultipleMatches lookup result
qmonnet Feb 11, 2026
90cd99a
test(flow-filter): Add vni()/vpcd() helpers to simplify tests
qmonnet Feb 12, 2026
3575189
feat(flow-filter): Add support for masquerade/port forwarding overlap
qmonnet Feb 12, 2026
96c3462
feat(flow-filter): Update logic for port forwarding + masquerade overlap
qmonnet Feb 12, 2026
de0020a
feat(config): Port forwarding validation: symmetrical IP/port numbers
qmonnet Feb 10, 2026
834a908
chore(config): Adjust comments in VpcExpose validation
qmonnet Feb 23, 2026
26efaeb
feat(config): Port forwarding validation: check ranges size
qmonnet Feb 23, 2026
78eb874
feat(config): Port forwarding validation: reject incompatible NAT modes
qmonnet Feb 10, 2026
89ed1ab
feat(config): Port forwarding validation: allow prefixes overlap
qmonnet Feb 10, 2026
58ed92c
feat(config): Port forwarding validation: overlap => inclusion
qmonnet Feb 11, 2026
41cbf3c
refactor(config): Move expose overlap-related code to utils submodule
qmonnet Feb 11, 2026
192eb78
test(flow-filter): Extend tests, for port forwarding
qmonnet Feb 14, 2026
c13eafb
refactor(nat): Move IP/port range structs to the top of the crate
qmonnet Feb 19, 2026
a13fbca
feat(nat): Compute and pass port ranges for stateful ports reservation
qmonnet Feb 19, 2026
d9ab12d
feat(nat): Reserve ports to prevent masquerade/port forwarding conflicts
qmonnet Feb 20, 2026
14b1872
feat(config): Potentially produce multiple VpcExpose objects from config
qmonnet Feb 20, 2026
ab66c5d
feat(config): Convert port forwarding configuration from API
qmonnet Feb 20, 2026
43d8c32
[no ci]
qmonnet Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 134 additions & 19 deletions config/src/converters/k8s/config/expose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::convert::TryFrom;
use k8s_intf::gateway_agent_crd::{
GatewayAgentPeeringsPeeringExpose, GatewayAgentPeeringsPeeringExposeAs,
GatewayAgentPeeringsPeeringExposeIps, GatewayAgentPeeringsPeeringExposeNat,
GatewayAgentPeeringsPeeringExposeNatPortForwardPortsProto,
};
use lpm::prefix::{PortRange, Prefix, PrefixString, PrefixWithOptionalPorts, PrefixWithPorts};

Expand Down Expand Up @@ -135,9 +136,9 @@ fn process_as_block(
fn process_nat_block(
vpc_expose: VpcExpose,
nat: Option<&GatewayAgentPeeringsPeeringExposeNat>,
) -> Result<VpcExpose, FromK8sConversionError> {
) -> Result<Vec<VpcExpose>, FromK8sConversionError> {
let Some(nat) = nat else {
return Ok(vpc_expose);
return Ok(vec![vpc_expose]);
};
match (&nat.masquerade, &nat.port_forward, &nat.r#static) {
(Some(_), Some(_), _) | (Some(_), _, Some(_)) | (_, Some(_), Some(_)) => {
Expand All @@ -147,26 +148,137 @@ fn process_nat_block(
}
(Some(masquerade), None, None) => {
let idle_timeout = masquerade.idle_timeout.map(std::time::Duration::from);
vpc_expose
.make_stateful_nat(idle_timeout)
Ok(vec![vpc_expose.make_stateful_nat(idle_timeout).map_err(
|e| FromK8sConversionError::NotAllowed(e.to_string()),
)?])
}
(None, Some(port_forward), None) => {
let idle_timeout = port_forward.idle_timeout.map(std::time::Duration::from);
let orig_port_ranges = if let Some(vec) = port_forward.ports.as_ref() {
Some(vec.iter().try_fold(Vec::new(), |mut acc, ports| {
let Some(port_string) = ports.port.as_ref() else {
return Err(FromK8sConversionError::NotAllowed(
"Port forward must specify a port".to_string(),
));
};
let Some(as_string) = ports.r#as.as_ref() else {
return Err(FromK8sConversionError::NotAllowed(
"Port forward must specify a port to map to".to_string(),
));
};
acc.push((
parse_port_ranges(port_string)?,
parse_port_ranges(as_string)?,
ports.proto.as_ref(),
));
Ok(acc)
})?)
} else {
None
};
set_port_ranges(vpc_expose, orig_port_ranges)?
.into_iter()
.map(|vpc_expose| vpc_expose.make_port_forwarding(idle_timeout))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| FromK8sConversionError::NotAllowed(e.to_string()))
}
(None, Some(_port_forward), None) => {
// TODO: port forwarding support
// Don't forget to also update the TypeGenerator in k8s-intf/src/bolero/expose.rs
Err(FromK8sConversionError::NotAllowed(
"Port forwarding is not yet supported".to_string(),
))
(None, None, Some(_)) => {
Ok(vec![vpc_expose.make_stateless_nat().map_err(|e| {
FromK8sConversionError::NotAllowed(e.to_string())
})?])
}
(None, None, Some(_)) => vpc_expose
.make_stateless_nat()
.map_err(|e| FromK8sConversionError::NotAllowed(e.to_string())),
(None, None, None) => Err(FromK8sConversionError::NotAllowed(
"NAT config block must specify one NAT mode".to_string(),
)),
}
}
impl TryFrom<(&SubnetMap, &GatewayAgentPeeringsPeeringExpose)> for VpcExpose {

#[allow(clippy::type_complexity)]
fn set_port_ranges(
vpc_expose: VpcExpose,
orig_port_ranges: Option<
Vec<(
Vec<PortRange>,
Vec<PortRange>,
Option<&GatewayAgentPeeringsPeeringExposeNatPortForwardPortsProto>,
)>,
>,
) -> Result<Vec<VpcExpose>, FromK8sConversionError> {
let Some(port_ranges_vec) = orig_port_ranges else {
return Ok(vec![vpc_expose]);
};
if port_ranges_vec.is_empty() {
return Err(FromK8sConversionError::NotAllowed(
"Port forward must specify at least one ports block".to_string(),
));
}

let mut vpc_exposes = Vec::new();
for (orig_ranges, target_ranges, _proto) in port_ranges_vec {
let mut vpc_expose_clone = vpc_expose.clone();

let nat = vpc_expose_clone
.nat
.as_mut()
.unwrap_or_else(|| unreachable!());

let orig_prefixes = vpc_expose_clone.ips.clone();
let target_prefixes = nat.as_range.clone();

for orig_prefix in &orig_prefixes {
debug_assert!(orig_prefix.ports().is_none());
for range in &orig_ranges {
vpc_expose_clone.ips.insert(PrefixWithOptionalPorts::new(
orig_prefix.prefix(),
Some(*range),
));
}
vpc_expose_clone.ips.remove(orig_prefix);
}

for target_prefix in &target_prefixes {
debug_assert!(target_prefix.ports().is_none());
for range in &target_ranges {
nat.as_range.insert(PrefixWithOptionalPorts::new(
target_prefix.prefix(),
Some(*range),
));
}
nat.as_range.remove(target_prefix);
}
vpc_exposes.push(vpc_expose_clone);

// TODO: L4 protocol
}

Ok(vpc_exposes)
}

#[derive(Debug, Clone)]
pub(crate) struct VpcExposes(Vec<VpcExpose>);

impl VpcExposes {
#[cfg(test)]
fn get_single(self) -> Result<VpcExpose, FromK8sConversionError> {
if self.0.len() != 1 {
return Err(FromK8sConversionError::NotAllowed(
"Expected exactly one VPC expose".to_owned(),
));
}
Ok(self.0.into_iter().next().unwrap())
}
}

impl IntoIterator for VpcExposes {
type Item = VpcExpose;
type IntoIter = std::vec::IntoIter<Self::Item>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl TryFrom<(&SubnetMap, &GatewayAgentPeeringsPeeringExpose)> for VpcExposes {
type Error = FromK8sConversionError;

fn try_from(
Expand All @@ -187,7 +299,7 @@ impl TryFrom<(&SubnetMap, &GatewayAgentPeeringsPeeringExpose)> for VpcExpose {
"A Default expose can't contain 'as' prefixes".to_string(),
));
}
return Ok(vpc_expose);
return Ok(VpcExposes(vec![vpc_expose]));
}

// Process PeeringIP rules
Expand All @@ -206,15 +318,15 @@ impl TryFrom<(&SubnetMap, &GatewayAgentPeeringsPeeringExpose)> for VpcExpose {
));
}

vpc_expose = process_nat_block(vpc_expose, expose.nat.as_ref())?;

if let Some(ases) = expose.r#as.as_ref() {
for r#as in ases {
vpc_expose = process_as_block(vpc_expose, r#as)?;
}
}

Ok(vpc_expose)
let vpc_exposes = process_nat_block(vpc_expose, expose.nat.as_ref())?;

Ok(VpcExposes(vpc_exposes))
}
}

Expand Down Expand Up @@ -391,7 +503,7 @@ mod test {
bolero::check!()
.with_generator(expose_gen)
.for_each(|k8s_expose| {
let expose = VpcExpose::try_from((&subnets, k8s_expose)).unwrap();
let expose = VpcExposes::try_from((&subnets, k8s_expose)).unwrap().get_single().unwrap();
let mut ips = expose
.ips
.iter()
Expand Down Expand Up @@ -507,6 +619,9 @@ mod test {
assert_eq!(config.idle_timeout, std::time::Duration::new(2 * 60, 0));
}
},
(Some(VpcExposeNatConfig::PortForwarding(_)), Some(_)) => {
todo!()
}
(Some(VpcExposeNatConfig::Stateless(_)), Some(k8s_nat)) => {
assert!(k8s_nat.r#static.is_some(), "Stateless NAT configured but not by K8s: {expose:#?}\nk8s: {k8s_nat:#?}");
},
Expand Down
5 changes: 3 additions & 2 deletions config/src/converters/k8s/config/peering.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Open Network Fabric Authors

use super::expose::VpcExposes;
use k8s_intf::gateway_agent_crd::{GatewayAgentPeerings, GatewayAgentPeeringsPeering};

use crate::converters::k8s::FromK8sConversionError;
use crate::converters::k8s::config::{SubnetMap, VpcSubnetMap};
use crate::external::overlay::vpcpeering::{VpcExpose, VpcManifest, VpcPeering};
use crate::external::overlay::vpcpeering::{VpcManifest, VpcPeering};

impl TryFrom<(&SubnetMap, &str, &GatewayAgentPeeringsPeering)> for VpcManifest {
type Error = FromK8sConversionError;
Expand All @@ -15,7 +16,7 @@ impl TryFrom<(&SubnetMap, &str, &GatewayAgentPeeringsPeering)> for VpcManifest {
) -> Result<Self, Self::Error> {
let mut manifest = VpcManifest::new(vpc_name);
for expose in peering.expose.as_ref().unwrap_or(&vec![]) {
manifest.add_expose(VpcExpose::try_from((subnets, expose))?);
manifest.add_exposes(VpcExposes::try_from((subnets, expose))?);
}
Ok(manifest)
}
Expand Down
10 changes: 2 additions & 8 deletions config/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,8 @@ pub enum ConfigError {
// NAT-specific
#[error("Mismatched prefixes sizes for static NAT: {0:?} and {1:?}")]
MismatchedPrefixSizes(PrefixWithPortsSize, PrefixWithPortsSize),
#[error(
"Stateful NAT is only supported on one side of a peering, but peering {0} has stateful NAT enabled on both sides"
)]
StatefulNatOnBothSides(String),
#[error(
"Stateful NAT is not compatible with stateless NAT at the other side of a peering, but peering {0} has stateful and stateless NAT enabled on different sides"
)]
StatefulPlusStatelessNat(String),
#[error("Peering {0} has manifests using incompatible NAT modes")]
IncompatibleNatModes(String),

// Interface addresses
#[error("Invalid interface address format: {0}")]
Expand Down
6 changes: 2 additions & 4 deletions config/src/external/overlay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ pub mod test {
let mut overlay = Overlay::new(vpc_table, peering_table);
assert_eq!(
overlay.validate(),
Err(ConfigError::StatefulNatOnBothSides("Peering-1".to_owned()))
Err(ConfigError::IncompatibleNatModes("Peering-1".to_owned()))
);
}

Expand Down Expand Up @@ -373,9 +373,7 @@ pub mod test {
let mut overlay = Overlay::new(vpc_table, peering_table);
assert_eq!(
overlay.validate(),
Err(ConfigError::StatefulPlusStatelessNat(
"Peering-1".to_owned()
))
Err(ConfigError::IncompatibleNatModes("Peering-1".to_owned()))
);
}

Expand Down
51 changes: 39 additions & 12 deletions config/src/external/overlay/vpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use tracing::{debug, error, warn};
use crate::converters::k8s::config::peering;
use crate::external::overlay::VpcManifest;
use crate::external::overlay::VpcPeeringTable;
use crate::external::overlay::vpcpeering::VpcExposeNatConfig;
use crate::internal::interfaces::interface::{InterfaceConfig, InterfaceConfigTable};
use crate::{ConfigError, ConfigResult};

Expand Down Expand Up @@ -51,29 +52,55 @@ impl Peering {
fn validate_nat_combinations(&self) -> ConfigResult {
// If stateful NAT is set up on one side of the peering, we don't support NAT (stateless or
// stateful) on the other side.
let mut local_has_nat = false;
let mut local_has_stateless_nat = false;
let mut local_has_stateful_nat = false;
let mut local_has_port_forwarding = false;
for expose in &self.local.exposes {
if expose.has_stateful_nat() {
local_has_stateful_nat = true;
local_has_nat = true;
break;
} else if expose.has_stateless_nat() {
local_has_nat = true;
match expose.nat_config() {
Some(VpcExposeNatConfig::Stateful { .. }) => {
local_has_stateful_nat = true;
}
Some(VpcExposeNatConfig::Stateless { .. }) => {
local_has_stateless_nat = true;
}
Some(VpcExposeNatConfig::PortForwarding { .. }) => {
local_has_port_forwarding = true;
}
None => {}
}
}
let local_has_nat =
local_has_stateless_nat || local_has_stateful_nat || local_has_port_forwarding;

if !local_has_nat {
return Ok(());
}

for expose in &self.remote.exposes {
if expose.has_stateful_nat() {
return Err(ConfigError::StatefulNatOnBothSides(self.name.clone()));
let local_has_stateless_nat_only =
local_has_stateless_nat && !local_has_stateful_nat && !local_has_port_forwarding;

// Allowed:
//
// - no NAT ------------ *
// - stateless NAT ----- stateless NAT
//
// Disallowed (some of them may be supported in the future):
//
// - stateful NAT ------ stateless NAT
// - stateful NAT ------ stateful NAT
// - stateful NAT ------ port forwarding
// - port forwarding --- port forwarding
// - port forwarding --- stateless NAT

for remote_expose in &self.remote.exposes {
if !remote_expose.has_nat() {
continue;
}
if expose.has_stateless_nat() && local_has_stateful_nat {
return Err(ConfigError::StatefulPlusStatelessNat(self.name.clone()));
if local_has_stateless_nat_only && remote_expose.has_stateless_nat() {
continue;
}
// Other combinations are rejected
return Err(ConfigError::IncompatibleNatModes(self.name.clone()));
}
Ok(())
}
Expand Down
Loading