Skip to content
Open
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
3ffcc35
refactor(nat): Move static NAT network function to dedicated file
qmonnet May 15, 2026
c8e1076
refactor(nat): Move unicast check closer to source IP mapping lookup
qmonnet May 15, 2026
925c390
refactor(nat): Handle zero-port check at port mapping lookup
qmonnet May 15, 2026
f3fb6d4
feat(net): Add wrappers for transport with types (TCP/UDP)
qmonnet May 15, 2026
fe450af
refactor(nat): Use TcpUdp view in static NAT to simplify port update
qmonnet May 15, 2026
659c96b
refactor(net): Simplify metadata flag toggling
qmonnet May 19, 2026
92719fa
chore(flow-filter): Split requires_stateless_nat() into source/dest
qmonnet May 19, 2026
4df3964
feat(flow-filter): Tag packets for src/dst static NAT
qmonnet May 19, 2026
6af7039
feat(nat): Mark packets as NAT-ed for source or destination
qmonnet May 19, 2026
bb61bf2
feat(nat): For static NAT, only apply NAT for relevant direction(s)
qmonnet May 19, 2026
5a42178
feat(dataplane): Move static NAT stage before port forwarding
qmonnet May 20, 2026
65dc8f1
feat(flow-filter,nat): Store static NAT requirements in flow info
qmonnet May 20, 2026
59c6a31
feat(net): Embed flow key in packet metadata
qmonnet May 20, 2026
3530547
feat(nat): Use initial IP addresses for forward flow table entry
qmonnet May 20, 2026
a7c7bdf
fix(nat): Remove NoMappingFound error
qmonnet May 30, 2026
ebf7a27
refactor(nat): Move NAT tables reader entering to process_packet()
qmonnet May 30, 2026
e1cd599
feat(nat): Pass a NatTablesReaderFactory to the IcmpErrorHandler NF
qmonnet May 30, 2026
1523214
feat(nat): Add static NAT support in IcmpErrorHandler
qmonnet May 30, 2026
2267593
feat(config): Allow static + stateful NAT, on opposite ends
qmonnet May 20, 2026
a36a1c4
test(nat): Add tests for static NAT + {masquerade, port forwarding}
qmonnet May 20, 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions config/src/external/overlay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,11 +420,7 @@ pub mod test {

// Build overlay object and validate it
let overlay = Overlay::new(vpc_table, peering_table);
assert!(
overlay
.validate()
.is_err_and(|e| e == ConfigError::IncompatibleNatModes("Peering-1".to_owned()))
);
assert!(overlay.validate().is_ok());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super tiny nit: this should work with assert_matches! as of rust 1.96.0 (very new). That will give you better error messages than the true / false this returns.

}

#[test]
Expand Down
18 changes: 4 additions & 14 deletions config/src/external/overlay/validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,7 @@ mod test {
assert!(validate_overlay_with_peering(peering).is_ok());
}

// Stateless + stateful rejected
// Stateless + stateful passes
#[test]
fn test_stateless_plus_stateful_rejected() {
let peering = VpcPeering::with_default_group(
Expand Down Expand Up @@ -1237,15 +1237,10 @@ mod test {
],
),
);
let result = validate_overlay_with_peering(peering);
assert_eq!(
result,
Err(ConfigError::IncompatibleNatModes("Peering-1".to_owned())),
"{result:?}",
);
assert!(validate_overlay_with_peering(peering).is_ok());
}

// Stateless + port forwarding rejected
// Stateless + port forwarding passes
#[test]
fn test_stateless_plus_port_forwarding_rejected() {
let peering = VpcPeering::with_default_group(
Expand Down Expand Up @@ -1273,12 +1268,7 @@ mod test {
],
),
);
let result = validate_overlay_with_peering(peering);
assert_eq!(
result,
Err(ConfigError::IncompatibleNatModes("Peering-1".to_owned())),
"{result:?}",
);
assert!(validate_overlay_with_peering(peering).is_ok());
}

// Stateful + stateful rejected (across peering sides)
Expand Down
36 changes: 10 additions & 26 deletions config/src/external/overlay/vpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,55 +122,39 @@ impl ValidatedPeering {
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_stateless_nat = false;
let mut local_has_stateful_nat = false;
let mut local_has_masquerading = false;
let mut local_has_port_forwarding = false;
for expose in self.local.valexp() {
match expose.nat_config() {
Some(VpcExposeNatConfig::Stateful { .. }) => {
local_has_stateful_nat = true;
}
Some(VpcExposeNatConfig::Stateless { .. }) => {
local_has_stateless_nat = true;
local_has_masquerading = true;
}
Some(VpcExposeNatConfig::PortForwarding { .. }) => {
local_has_port_forwarding = true;
}
None => {}
Some(VpcExposeNatConfig::Stateless { .. }) | None => {}
}
}
let local_has_nat =
local_has_stateless_nat || local_has_stateful_nat || local_has_port_forwarding;

if !local_has_nat {
// No NAT or static NAT only is compatible with all other modes on the other side
if !(local_has_masquerading || local_has_port_forwarding) {
return Ok(());
}

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
// - stateless NAT ----- *
//
// Disallowed (some of them may be supported in the future):
//
// - stateful NAT ------ stateless NAT
// - stateful NAT ------ stateful NAT
// - stateful NAT ------ port forwarding
// - masquerading ------ masquerading
// - masquerading ------ port forwarding
// - port forwarding --- port forwarding
// - port forwarding --- stateless NAT

for remote_expose in self.remote.valexp() {
if !remote_expose.has_nat() {
continue;
}
if local_has_stateless_nat_only && remote_expose.has_stateless_nat() {
continue;
if remote_expose.has_stateful_nat() || remote_expose.has_port_forwarding() {
return Err(ConfigError::IncompatibleNatModes(self.name.clone()));
}
// Other combinations are rejected
return Err(ConfigError::IncompatibleNatModes(self.name.clone()));
}
Ok(())
}
Expand Down
7 changes: 0 additions & 7 deletions config/src/external/overlay/vpcpeering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,13 +535,6 @@ impl ValidatedExpose {
)
}

#[must_use]
pub(crate) fn has_nat(&self) -> bool {
self.nat
.as_ref()
.is_some_and(|nat| !nat.as_range.is_empty())
}

#[must_use]
pub fn has_stateful_nat(&self) -> bool {
self.nat.as_ref().is_some_and(VpcExposeNat::is_stateful)
Expand Down
7 changes: 4 additions & 3 deletions dataplane/src/packet_processor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ pub(crate) fn start_router<Buf: PacketBufferMut>(
let stage_egress = Egress::new("Egress", iftr_factory.handle(), atabler_factory.handle());
let iprouter1 = IpForwarder::new("IP-Forward-1", fibtr_factory.handle());
let iprouter2 = IpForwarder::new("IP-Forward-2", fibtr_factory.handle());
let stateless_nat = StatelessNat::with_reader("stateless-NAT", nattabler_factory.handle());
let static_nat = StatelessNat::with_reader("static-NAT-1", nattabler_factory.handle());
let stateful_nat = StatefulNat::new(
"stateful-NAT",
flow_table_clone.clone(),
Expand All @@ -107,7 +107,8 @@ pub(crate) fn start_router<Buf: PacketBufferMut>(
let pktdump = PacketDumper::new("pipeline-end", true, None);
let stats_stage = Stats::new("stats", stats_w.clone());
let flow_filter = FlowFilter::new("flow-filter", flowfiltertablesr_factory.handle());
let icmp_error_handler = IcmpErrorHandler::new(flow_table_clone.clone());
let icmp_error_handler =
IcmpErrorHandler::new(flow_table_clone.clone(), Some(nattabler_factory.clone()));
let flow_lookup = FlowLookup::new("flow-lookup", flow_table_clone.clone());
let portfw = PortForwarder::new(
"port-forwarder",
Expand All @@ -125,8 +126,8 @@ pub(crate) fn start_router<Buf: PacketBufferMut>(
.add_stage(icmp_error_handler)
.add_stage(flow_lookup)
.add_stage(flow_filter)
.add_stage(static_nat)
.add_stage(portfw)
.add_stage(stateless_nat)
.add_stage(stateful_nat)
.add_stage(iprouter2)
.add_stage(stage_egress)
Expand Down
10 changes: 8 additions & 2 deletions flow-entry/src/flow_table/nf_lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ mod test {
use net::FlowKey;
use net::buffer::PacketBufferMut;
use net::buffer::TestBuffer;
use net::flows::FlowInfo;
use net::flows::{FlowInfo, FlowInfoFlags};
use net::ip::NextHeader;
use net::ip::UnicastIpAddr;
use net::packet::Packet;
Expand Down Expand Up @@ -198,7 +198,13 @@ mod test {

// create a pair of related flow entries; flow_2 will get a longer timeout
let expires_at = tokio::time::Instant::now().into_std() + Duration::from_secs(2);
let (flow_1, flow_2) = FlowInfo::related_pair(expires_at, key_1, key_2);
let (flow_1, flow_2) = FlowInfo::related_pair(
expires_at,
key_1,
FlowInfoFlags::default(),
key_2,
FlowInfoFlags::default(),
);
assert_eq!(Arc::weak_count(&flow_1), 1);
assert_eq!(Arc::weak_count(&flow_2), 1);
assert_eq!(Arc::strong_count(&flow_1), 1);
Expand Down
1 change: 1 addition & 0 deletions flow-filter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ publish.workspace = true
version.workspace = true

[dependencies]
bitflags = { workspace = true }
common = { workspace = true }
concurrency = { workspace = true }
config = { workspace = true }
Expand Down
50 changes: 44 additions & 6 deletions flow-filter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use crate::tables::{NatRequirement, RemoteData, VpcdLookupResult};
use lpm::prefix::L4Protocol;
use net::FlowKey;
use net::buffer::PacketBufferMut;
use net::headers::{Transport, TryIp, TryTransport};
use net::packet::{DoneReason, Packet, VpcDiscriminant};
Expand Down Expand Up @@ -307,8 +308,11 @@ impl FlowFilter {
if data.requires_stateful_nat() {
packet.meta_mut().set_stateful_nat(true);
}
if data.requires_stateless_nat() {
packet.meta_mut().set_stateless_nat(true);
if data.requires_static_nat_src() {
packet.meta_mut().set_static_nat_src(true);
}
if data.requires_static_nat_dst() {
packet.meta_mut().set_static_nat_dst(true);
}
if data.requires_port_forwarding(get_l4_proto(packet)) {
packet.meta_mut().set_port_forwarding(true);
Expand All @@ -319,22 +323,51 @@ impl FlowFilter {
fn set_nat_requirements_from_flow_info<Buf: PacketBufferMut>(
packet: &mut Packet<Buf>,
) -> Result<(), ()> {
let locked_info = packet.meta().flow_info.as_ref().ok_or(())?.locked.read();
let flow_info = packet.meta().flow_info.as_ref().ok_or(())?;
let needs_static_nat_src = flow_info.get_flags().requires_static_nat_src();
let needs_static_nat_dst = flow_info.get_flags().requires_static_nat_dst();

let locked_info = flow_info.locked.read();
let needs_stateful_nat = locked_info.nat_state.is_some();
let needs_port_forwarding = locked_info.port_fw_state.is_some();
drop(locked_info);

match (needs_stateful_nat, needs_port_forwarding) {
(true, false) => {
packet.meta_mut().set_stateful_nat(true);
Ok(())
}
(false, true) => {
packet.meta_mut().set_port_forwarding(true);
Ok(())
}
_ => Err(()),
_ => return Err(()),
}
if needs_static_nat_src {
packet.meta_mut().set_static_nat_src(true);
}
if needs_static_nat_dst {
packet.meta_mut().set_static_nat_dst(true);
}
Ok(())
}

fn maybe_tag_with_flow_key<Buf: PacketBufferMut>(packet: &mut Packet<Buf>) {
// No need to allocate for the flow key if we already have flow info
if packet.meta().flow_info.is_some() {
return;
}

// Only attach the flow key when using {port forwarding, masquerading} + static NAT
if !((packet.meta().requires_port_forwarding() || packet.meta().requires_stateful_nat())
&& packet.meta().requires_static_nat())
{
return;
}

let Ok(flow_key) = FlowKey::try_from(net::flow_key::Uni(&*packet)) else {
return;
};

packet.meta_mut().flow_key = Some(Box::new(flow_key));
}

/// Process a packet.
Expand Down Expand Up @@ -435,6 +468,11 @@ impl FlowFilter {
debug!("{nfi}: Flow {tuple} is allowed. Dst VPC is {dst_vpcd}");
packet.meta_mut().dst_vpcd = Some(dst_vpcd);

// Port forwarding or masquerading used in combination with static NAT need to keep track of
// the initial IP addresses for creating the right flow table entries, so we may have to
// attach the flow key to packet's metadata.
Self::maybe_tag_with_flow_key(packet);

// The packet is ALLOWED. However, if it refers to a flow, the flow may no longer be valid and a new one
// be needed. The flow-filter cannot always tell if a flow is valid or not, as it lacks the context and state
// to do so. Therefore, it should not upgrade flow to newer gen ids. However, it can (and must) invalidate
Expand Down
7 changes: 5 additions & 2 deletions flow-filter/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,12 @@ impl RemoteData {
|| self.dst_nat_req == Some(NatRequirement::Stateful)
}

pub(crate) fn requires_stateless_nat(&self) -> bool {
pub(crate) fn requires_static_nat_src(&self) -> bool {
self.src_nat_req == Some(NatRequirement::Stateless)
|| self.dst_nat_req == Some(NatRequirement::Stateless)
}

pub(crate) fn requires_static_nat_dst(&self) -> bool {
self.dst_nat_req == Some(NatRequirement::Stateless)
}

pub(crate) fn requires_port_forwarding(&self, packet_proto: L4Protocol) -> bool {
Expand Down
8 changes: 4 additions & 4 deletions flow-filter/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,25 @@ fn vpcd(id: u32) -> VpcDiscriminant {

fn needs_masquerade<Buf: PacketBufferMut>(packet: &Packet<Buf>) -> bool {
packet.meta().requires_stateful_nat()
&& !packet.meta().requires_stateless_nat()
&& !packet.meta().requires_static_nat()
&& !packet.meta().requires_port_forwarding()
}

fn needs_static_nat<Buf: PacketBufferMut>(packet: &Packet<Buf>) -> bool {
packet.meta().requires_stateless_nat()
packet.meta().requires_static_nat()
&& !packet.meta().requires_stateful_nat()
&& !packet.meta().requires_port_forwarding()
}

fn needs_port_forwarding<Buf: PacketBufferMut>(packet: &Packet<Buf>) -> bool {
packet.meta().requires_port_forwarding()
&& !packet.meta().requires_stateful_nat()
&& !packet.meta().requires_stateless_nat()
&& !packet.meta().requires_static_nat()
}

fn needs_no_nat<Buf: PacketBufferMut>(packet: &Packet<Buf>) -> bool {
!packet.meta().requires_stateful_nat()
&& !packet.meta().requires_stateless_nat()
&& !packet.meta().requires_static_nat()
&& !packet.meta().requires_port_forwarding()
}

Expand Down
27 changes: 25 additions & 2 deletions nat/src/icmp_handler/nf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,30 @@ use strum::EnumMessage;
use tracectl::trace_target;
use tracing::{debug, warn};

use crate::StatelessNat;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude spotted this on this commit:

safety rationale is inaccurate — fix the comment. nat/src/icmp_handler/nf.rs sets both set_static_nat_src(true) and set_static_nat_dst(true) then runs process_packet, justified by: "we do mark packets as source-NAT-ed or dest-NAT-ed when we process them for masquerade or port forwarding, and the static NAT stage does not re-apply translation… so there's no risk to incur double translations."* But handle_icmp_error_masquerading and handle_icmp_error_port_forwarding do not set src_natted/dst_natted (only the non-ICMP snat/dnat paths in stateful/packet.rs and portfw/packet.rs do — confirmed by grep). So in the ICMP path, both source_nat and destination_nat actually run. The reason it's still safe is the commit-15 change: a missing mapping now returns Ok(false) (no-op) instead of NoMappingFound, and the opposite-end address ranges don't match the wrong direction. The tests pass because of that, not because of the natted flags. Please correct the comment to state the real invariant — relying on a documented-but-false guarantee is a maintenance trap for the next person who touches this.

Looking over this, that seems to be true, but I'm not done reviewing yet

use crate::common::NatFlowStatus;
use crate::portfw::icmp_handling::handle_icmp_error_port_forwarding;
use crate::stateful::icmp_handling::handle_icmp_error_masquerading;
use crate::stateless::natrw::NatTablesReaderFactory;

trace_target!("icmp-errors", LevelFilter::INFO, &["nat", "pipeline"]);

pub struct IcmpErrorHandler {
flow_table: Arc<FlowTable>,
tables_factory: Option<NatTablesReaderFactory>,
}

impl IcmpErrorHandler {
/// Creates a new `IcmpErrorHandler`
#[must_use]
pub fn new(flow_table: Arc<FlowTable>) -> Self {
Self { flow_table }
pub fn new(
flow_table: Arc<FlowTable>,
static_nat_tables_factory: Option<NatTablesReaderFactory>,
) -> Self {
Self {
flow_table,
tables_factory: static_nat_tables_factory,
}
}
}

Expand Down Expand Up @@ -205,6 +214,20 @@ impl IcmpErrorHandler {
} else {
debug!("Will not invalidate flows (reason={reason} flow-status={status})");
}

// We may also need to apply static NAT to the packet, if static NAT is used on the other
// end of the peering.
// We first need to update the checksums after the previous NAT changes, or the static NAT
// processor will fail to validate checksums and won't proceed.
let Some(tables_factory) = &self.tables_factory else {
return;
};
packet.update_checksums();
packet.meta_mut().set_static_nat_src(true);
packet.meta_mut().set_static_nat_dst(true);
let nat_processor =
StatelessNat::with_reader("icmp_error_static_nat", tables_factory.handle());
nat_processor.process_packet(packet);
}
}

Expand Down
1 change: 1 addition & 0 deletions nat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod portfw;
mod ranges;
pub mod stateful;
pub mod stateless;
mod test;

pub use icmp_handler::nf::IcmpErrorHandler;
pub use port::NatPort;
Expand Down
Loading
Loading