diff --git a/acl-filter/src/fuzz.rs b/acl-filter/src/fuzz.rs index 156fbe82c9..ddd052191d 100644 --- a/acl-filter/src/fuzz.rs +++ b/acl-filter/src/fuzz.rs @@ -127,6 +127,16 @@ fn resolved_action(rule: Option, default: Option) -> A rule.map_or_else(|| default.unwrap_or(AclAction::Allow), |v| v.action) } +pub(crate) fn oracle_resolved_action( + overlay: &ValidatedOverlay, + packet: &PacketSummary, +) -> AclAction { + resolved_action( + oracle_lookup(overlay, packet), + oracle_default_action(overlay, packet.src_vni, packet.dst_vni), + ) +} + // ------------------------------------------------------------------------------------------------- // Properties. diff --git a/acl-filter/src/lib.rs b/acl-filter/src/lib.rs index 3e17f71453..d4fe2c6f40 100644 --- a/acl-filter/src/lib.rs +++ b/acl-filter/src/lib.rs @@ -26,6 +26,8 @@ mod fuzz; #[cfg(test)] mod fuzz_gen; #[cfg(test)] +mod nf_fuzz; +#[cfg(test)] mod tests; pub use access::{ diff --git a/acl-filter/src/nf_fuzz.rs b/acl-filter/src/nf_fuzz.rs new file mode 100644 index 0000000000..2ab77ecd3b --- /dev/null +++ b/acl-filter/src/nf_fuzz.rs @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![cfg(test)] + +use crate::fuzz::oracle_resolved_action; +use crate::fuzz_gen::{OverlaySpec, ProbeSpec}; +use crate::{AclFilter, AclFilterContext, AclFilterContextWriter, PacketSummary}; +use concurrency::sync::atomic::{AtomicUsize, Ordering}; +use config::external::overlay::acl::AclAction; +use net::buffer::TestBuffer; +use net::ip::{NextHeader, UnicastIpAddr}; +use net::packet::test_utils::{ + build_test_ipv4_packet_with_transport, build_test_ipv6_packet_with_transport, +}; +use net::packet::{DoneReason, Packet, VpcDiscriminant}; +use net::tcp::port::TcpPort; +use net::udp::UdpPort; +use pipeline::NetworkFunction; +use std::net::IpAddr; + +const PROBES: usize = 8; + +fn packet_for(summary: &PacketSummary) -> Option> { + let (sport, dport) = summary.ports?; + let tcp = match summary.proto { + NextHeader::TCP => true, + NextHeader::UDP => false, + _ => return None, + }; + + let mut packet = match (summary.src_ip, summary.dst_ip) { + (IpAddr::V4(_), IpAddr::V4(_)) => { + build_test_ipv4_packet_with_transport(64, Some(summary.proto)).ok()? + } + (IpAddr::V6(_), IpAddr::V6(_)) => { + build_test_ipv6_packet_with_transport(64, Some(summary.proto)).ok()? + } + _ => return None, + }; + + packet + .set_ip_source(UnicastIpAddr::try_from(summary.src_ip).ok()?) + .ok()?; + packet.set_ip_destination(summary.dst_ip).ok()?; + if tcp { + packet + .set_tcp_source_port(TcpPort::new_checked(sport.max(1)).ok()?) + .ok()?; + packet + .set_tcp_destination_port(TcpPort::new_checked(dport.max(1)).ok()?) + .ok()?; + } else { + packet + .set_udp_source_port(UdpPort::new_checked(sport.max(1)).ok()?) + .ok()?; + packet + .set_udp_destination_port(UdpPort::new_checked(dport.max(1)).ok()?) + .ok()?; + } + + let meta = packet.meta_mut(); + meta.src_vpcd = Some(VpcDiscriminant::from_vni(summary.src_vni)); + meta.dst_vpcd = Some(VpcDiscriminant::from_vni(summary.dst_vni)); + meta.set_overlay(true); + meta.set_keep(true); + Some(packet) +} + +fn expected_summary(summary: &PacketSummary) -> PacketSummary { + let mut expected = summary.clone(); + expected.ports = summary.ports.map(|(s, d)| (s.max(1), d.max(1))); + expected +} + +fn filter(built: &crate::fuzz_gen::BuiltOverlay) -> AclFilter { + let writer = AclFilterContextWriter::new(); + writer.store(AclFilterContext::for_test(&built.overlay)); + AclFilter::new("nf-fuzz-acl-filter", writer.get_reader()) +} + +#[derive(Default)] +struct Tally { + drawn: AtomicUsize, + reached: AtomicUsize, + denied: AtomicUsize, +} + +impl Tally { + fn report_arrivals_only(&self, what: &str) { + let (drawn, reached) = ( + self.drawn.load(Ordering::Relaxed), + self.reached.load(Ordering::Relaxed), + ); + if drawn == 0 { + return; + } + println!("{what}: {reached}/{drawn} probes became packets"); + assert!( + reached > 0 && reached * 4 >= drawn, + "only {reached} of {drawn} probes became packets, so the {what} assertion is barely \ + running" + ); + } + + fn report(&self, what: &str) { + let (drawn, reached, denied) = ( + self.drawn.load(Ordering::Relaxed), + self.reached.load(Ordering::Relaxed), + self.denied.load(Ordering::Relaxed), + ); + if drawn == 0 { + return; + } + println!("{what}: {reached}/{drawn} probes became packets, {denied} of them denied"); + assert!( + reached > 0 && reached * 4 >= drawn, + "only {reached} of {drawn} probes became packets, so the {what} assertion is barely \ + running" + ); + assert!( + denied * 20 >= reached, + "only {denied} of {reached} probes were denied, so the drop path is barely exercised \ + and this property is mostly checking that nothing happens" + ); + } +} + +#[test] +fn the_stage_agrees_with_the_configuration() { + let tally = Tally::default(); + + bolero::check!() + .with_type::<(OverlaySpec, [ProbeSpec; PROBES])>() + .for_each(|(overlay_spec, probe_specs)| { + let built = overlay_spec.build(); + let mut acl = filter(&built); + + for probe_spec in probe_specs { + tally.drawn.fetch_add(1, Ordering::Relaxed); + let summary = probe_spec.resolve(&built); + let Some(packet) = packet_for(&summary) else { + continue; + }; + + let judged = expected_summary(&summary); + let want = oracle_resolved_action(&built.overlay, &judged); + let out: Vec<_> = acl.process(std::iter::once(packet)).collect(); + let got = out[0].get_done(); + + match want { + AclAction::Deny => { + assert_eq!( + got, + Some(DoneReason::AclDropped), + "the configuration denies {summary:?} and the stage let it through \ + with {got:?}\nspec: {overlay_spec:?}" + ); + tally.denied.fetch_add(1, Ordering::Relaxed); + } + AclAction::Allow => { + assert_eq!( + got, None, + "the configuration allows {summary:?} and the stage dropped it for \ + {got:?}\nspec: {overlay_spec:?}" + ); + } + } + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report("stage verdict"); +} + +#[test] +fn the_summary_survives_the_round_trip_through_a_packet() { + let tally = Tally::default(); + + bolero::check!() + .with_type::<(OverlaySpec, [ProbeSpec; PROBES])>() + .for_each(|(overlay_spec, probe_specs)| { + let built = overlay_spec.build(); + + for probe_spec in probe_specs { + tally.drawn.fetch_add(1, Ordering::Relaxed); + let summary = probe_spec.resolve(&built); + let Some(packet) = packet_for(&summary) else { + continue; + }; + + let read = PacketSummary::try_from(&packet) + .unwrap_or_else(|e| panic!("a built packet did not yield a summary: {e:?}")); + let expected = expected_summary(&summary); + + assert_eq!( + (read.src_vni, read.dst_vni), + (expected.src_vni, expected.dst_vni), + "discriminants came back swapped or wrong\nspec: {overlay_spec:?}" + ); + assert_eq!( + (read.src_ip, read.dst_ip), + (expected.src_ip, expected.dst_ip), + "addresses came back swapped or wrong\nspec: {overlay_spec:?}" + ); + assert_eq!( + read.proto, expected.proto, + "protocol came back wrong\nspec: {overlay_spec:?}" + ); + assert_eq!( + read.ports, expected.ports, + "ports came back swapped or wrong\nspec: {overlay_spec:?}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report_arrivals_only("summary round trip"); +} + +#[test] +fn a_packet_with_no_discriminants_is_dropped() { + let tally = Tally::default(); + + bolero::check!() + .with_type::<(OverlaySpec, [ProbeSpec; PROBES])>() + .for_each(|(overlay_spec, probe_specs)| { + let built = overlay_spec.build(); + let mut acl = filter(&built); + + for probe_spec in probe_specs { + tally.drawn.fetch_add(1, Ordering::Relaxed); + let summary = probe_spec.resolve(&built); + let Some(mut packet) = packet_for(&summary) else { + continue; + }; + packet.meta_mut().dst_vpcd = None; + + let out: Vec<_> = acl.process(std::iter::once(packet)).collect(); + assert_eq!( + out[0].get_done(), + Some(DoneReason::Unroutable), + "a packet with no destination vpc was not refused\nspec: {overlay_spec:?}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report_arrivals_only("missing discriminant"); +} + +#[test] +fn underlay_traffic_is_not_judged() { + let tally = Tally::default(); + + bolero::check!() + .with_type::<(OverlaySpec, [ProbeSpec; PROBES])>() + .for_each(|(overlay_spec, probe_specs)| { + let built = overlay_spec.build(); + let mut acl = filter(&built); + + for probe_spec in probe_specs { + tally.drawn.fetch_add(1, Ordering::Relaxed); + let summary = probe_spec.resolve(&built); + let Some(mut packet) = packet_for(&summary) else { + continue; + }; + packet.meta_mut().set_overlay(false); + + let out: Vec<_> = acl.process(std::iter::once(packet)).collect(); + assert_eq!( + out[0].get_done(), + None, + "a packet that is not overlay traffic was judged by an overlay acl\nspec: \ + {overlay_spec:?}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report_arrivals_only("underlay gate"); +} diff --git a/config/src/external/overlay/vpcpeering.rs b/config/src/external/overlay/vpcpeering.rs index c64919a5c7..da855cd2f7 100644 --- a/config/src/external/overlay/vpcpeering.rs +++ b/config/src/external/overlay/vpcpeering.rs @@ -1081,37 +1081,64 @@ pub mod contract { #[derive(Debug, Clone, Copy, Default)] pub struct MasqueradeExpose; + #[derive(Debug, Clone, Copy)] + pub struct MasqueradeExposes(pub u8); + + impl Default for MasqueradeExposes { + fn default() -> Self { + Self(3) + } + } + + const MASQUERADE_SLOT: u8 = 4; + + impl ValueGenerator for MasqueradeExposes { + type Output = Vec; + + fn generate(&self, driver: &mut D) -> Option> { + let v4 = driver.produce::()?; + let count = driver.gen_u8(Included(&1), Included(&self.0.max(1)))?; + (0..count) + .map(|slot| masquerade_expose(driver, v4, slot.wrapping_mul(MASQUERADE_SLOT))) + .collect() + } + } + impl ValueGenerator for MasqueradeExpose { type Output = VpcExpose; fn generate(&self, driver: &mut D) -> Option { let v4 = driver.produce::()?; - let privates = driver.gen_u8(Included(&1), Included(&3))?; - let publics = driver.gen_u8(Included(&1), Included(&2))?; let base = driver.produce::()?; - let idle_timeout = match driver.gen_u8(Included(&0), Included(&2))? { - 0 => None, - 1 => Some(Duration::from_secs(30)), - _ => Some(Duration::from_mins(2)), - }; + masquerade_expose(driver, v4, base) + } + } - let mut expose = VpcExpose::empty().make_masquerade(idle_timeout).ok()?; - for index in 0..privates { - expose = expose.ip(PrefixWithOptionalPorts::new( - block(v4, Side::Private, base.wrapping_add(index))?, + fn masquerade_expose(driver: &mut D, v4: bool, base: u8) -> Option { + let privates = driver.gen_u8(Included(&1), Included(&3))?; + let publics = driver.gen_u8(Included(&1), Included(&2))?; + let idle_timeout = match driver.gen_u8(Included(&0), Included(&2))? { + 0 => None, + 1 => Some(Duration::from_secs(30)), + _ => Some(Duration::from_mins(2)), + }; + + let mut expose = VpcExpose::empty().make_masquerade(idle_timeout).ok()?; + for index in 0..privates { + expose = expose.ip(PrefixWithOptionalPorts::new( + block(v4, Side::Private, base.wrapping_add(index))?, + None, + )); + } + for index in 0..publics { + expose = expose + .as_range(PrefixWithOptionalPorts::new( + block(v4, Side::Public, base.wrapping_add(index))?, None, - )); - } - for index in 0..publics { - expose = expose - .as_range(PrefixWithOptionalPorts::new( - block(v4, Side::Public, base.wrapping_add(index))?, - None, - )) - .ok()?; - } - Some(expose) + )) + .ok()?; } + Some(expose) } #[derive(Clone, Copy)] @@ -1140,29 +1167,99 @@ pub mod contract { #[derive(Debug, Clone, Copy, Default)] pub struct StaticNatExpose; + #[derive(Debug, Clone, Copy)] + pub struct StaticNatExposes { + pub max: u8, + pub ports: bool, + } + + impl Default for StaticNatExposes { + fn default() -> Self { + Self::addresses_only(3) + } + } + + impl StaticNatExposes { + #[must_use] + pub fn addresses_only(max: u8) -> Self { + Self { max, ports: false } + } + + #[must_use] + pub fn with_ports(max: u8) -> Self { + Self { max, ports: true } + } + } + const MAX_TOTAL_LOG: u8 = 6; + const BLOCK_STRIDE: u128 = 4 << MAX_TOTAL_LOG; + impl ValueGenerator for StaticNatExpose { type Output = VpcExpose; fn generate(&self, driver: &mut D) -> Option { let v4 = driver.produce::()?; - let total_log = driver.gen_u8(Included(&0), Included(&MAX_TOTAL_LOG))?; + static_nat_expose(driver, v4, 0) + } + } - let privates = place(v4, Side::Private, &split(driver, total_log)?)?; - let publics = place(v4, Side::Public, &split(driver, total_log)?)?; + impl ValueGenerator for StaticNatExposes { + type Output = Vec; - let mut expose = VpcExpose::empty().make_static_nat().ok()?; - for prefix in privates { - expose = expose.ip(PrefixWithOptionalPorts::new(prefix, None)); - } - for prefix in publics { - expose = expose - .as_range(PrefixWithOptionalPorts::new(prefix, None)) - .ok()?; - } - Some(expose) + fn generate(&self, driver: &mut D) -> Option> { + let v4 = driver.produce::()?; + let count = driver.gen_u8(Included(&1), Included(&self.max.max(1)))?; + (0..count) + .map(|block| { + if self.ports { + static_nat_pat_expose(driver, v4, block) + } else { + static_nat_expose(driver, v4, block) + } + }) + .collect() + } + } + + fn static_nat_expose(driver: &mut D, v4: bool, block: u8) -> Option { + let total_log = driver.gen_u8(Included(&0), Included(&MAX_TOTAL_LOG))?; + + let privates = place(v4, Side::Private, block, &split(driver, total_log)?)?; + let publics = place(v4, Side::Public, block, &split(driver, total_log)?)?; + + let mut expose = VpcExpose::empty().make_static_nat().ok()?; + for prefix in privates { + expose = expose.ip(PrefixWithOptionalPorts::new(prefix, None)); + } + for prefix in publics { + expose = expose + .as_range(PrefixWithOptionalPorts::new(prefix, None)) + .ok()?; } + Some(expose) + } + + fn static_nat_pat_expose(driver: &mut D, v4: bool, block: u8) -> Option { + let total_log = driver.gen_u8(Included(&0), Included(&MAX_TOTAL_LOG))?; + + let mut side = |which| -> Option { + let port_log = driver.gen_u8(Included(&0), Included(&total_log))?; + let addr_log = total_log - port_log; + let prefix = *place(v4, which, block, &[addr_log])?.first()?; + let ports = port_range(driver, 1u16 << port_log)?; + Some(PrefixWithOptionalPorts::new(prefix, Some(ports))) + }; + + let private = side(Side::Private)?; + let public = side(Side::Public)?; + + VpcExpose::empty() + .make_static_nat() + .ok()? + .ip(private) + .as_range(public) + .ok() } fn split(driver: &mut D, total_log: u8) -> Option> { @@ -1189,19 +1286,21 @@ pub mod contract { Some(parts) } - fn place(v4: bool, side: Side, parts: &[u8]) -> Option> { - let mut cursor = if v4 { - u128::from(match side { - Side::Private => 0x0A00_0000u32, - Side::Public => 0xAC10_0000, - }) - } else { - let selector = match side { - Side::Private => 0u128, - Side::Public => 1, + fn place(v4: bool, side: Side, block: u8, parts: &[u8]) -> Option> { + let offset = u128::from(block) * BLOCK_STRIDE; + let mut cursor = offset + + if v4 { + u128::from(match side { + Side::Private => 0x0A00_0000u32, + Side::Public => 0xAC10_0000, + }) + } else { + let selector = match side { + Side::Private => 0u128, + Side::Public => 1, + }; + (0x2001_0db8u128 << 96) | (selector << 80) }; - (0x2001_0db8u128 << 96) | (selector << 80) - }; let mut out = Vec::with_capacity(parts.len()); for &log in parts { diff --git a/k8s-intf/src/bolero/crd.rs b/k8s-intf/src/bolero/crd.rs index 532f646502..8a51e79c2d 100644 --- a/k8s-intf/src/bolero/crd.rs +++ b/k8s-intf/src/bolero/crd.rs @@ -24,6 +24,7 @@ fn simple_hostname(d: &mut D) -> Option { ) } +/// fn join_own_groups(d: &mut D, name: &str, spec: &mut GatewayAgentSpec) -> Option<()> { for group in spec.groups.iter_mut().flatten().map(|(_, group)| group) { if !d.produce::()? { @@ -99,7 +100,6 @@ impl GatewayAgentBuilder { } /// Generate a random legal `GatewayAgent` value -/// /// Is not exhaustive due to hostname generation /// Coverage of values is subject to limitations of the `GatewayAgentSpec` `TypeGenerator` as well impl TypeGenerator for LegalValue { diff --git a/nat/src/masquerade/fuzz.rs b/nat/src/masquerade/fuzz.rs new file mode 100644 index 0000000000..ca7d004bc9 --- /dev/null +++ b/nat/src/masquerade/fuzz.rs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![cfg(test)] + +use crate::masquerade::probe::{Arrival, Fabric, ProbeSpec, Stray, run}; +use bolero::{Driver, TypeGenerator, ValueGenerator}; +use concurrency::sync::atomic::{AtomicUsize, Ordering}; +use config::external::overlay::vpcpeering::VpcExpose; +use config::external::overlay::vpcpeering::contract::MasqueradeExposes; +use net::buffer::TestBuffer; +use net::packet::Packet; +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::num::NonZero; + +const MAX_EXPOSES: u8 = 3; + +const PROBES: usize = 8; + +#[derive(Debug, Clone, Copy)] +struct Scenario { + strays: bool, +} + +impl ValueGenerator for Scenario { + type Output = (Vec, Vec); + + fn generate(&self, driver: &mut D) -> Option { + let exposes = MasqueradeExposes(MAX_EXPOSES).generate(driver)?; + + let mut probes = Vec::with_capacity(PROBES); + for _ in 0..PROBES { + let mut probe = ProbeSpec::generate(driver)?; + if !self.strays { + probe.clear_stray(); + } + probes.push(probe); + } + Some((exposes, probes)) + } +} + +fn with_runtime(body: impl FnOnce()) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap_or_else(|e| unreachable!("{e}")); + let _guard = runtime.enter(); + body(); +} + +fn fabric(exposes: &[VpcExpose]) -> Option { + let fabric = Fabric::build(exposes)?; + fabric.is_probeable().then_some(fabric) +} + +fn source_of(packet: &Packet) -> (IpAddr, u16) { + ( + packet + .ip_source() + .unwrap_or_else(|| unreachable!("a probe is always an ip packet")), + packet.transport_src_port().map_or(0, NonZero::get), + ) +} + +fn destination_of(packet: &Packet) -> (IpAddr, u16) { + ( + packet + .ip_destination() + .unwrap_or_else(|| unreachable!("a probe is always an ip packet")), + packet.transport_dst_port().map_or(0, NonZero::get), + ) +} + +#[derive(Default)] +struct Tally { + seen: AtomicUsize, + built: AtomicUsize, + reached: AtomicUsize, +} + +impl Tally { + fn report(&self, what: &str) { + let (seen, built, reached) = ( + self.seen.load(Ordering::Relaxed), + self.built.load(Ordering::Relaxed), + self.reached.load(Ordering::Relaxed), + ); + if seen == 0 { + return; + } + println!("{what}: {built}/{seen} configurations built, {reached} flows reached it"); + assert!( + built * 2 >= seen, + "only {built} of {seen} configurations built, so this checked much less than it looks \ + like it did" + ); + assert!( + reached > 0 && reached * 2 >= built, + "{reached} flows reached the {what} assertion across {built} configurations; \ + this property has gone vacuous" + ); + } +} + +#[test] +fn a_masqueraded_flow_comes_back() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: false }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + for spec in &probes { + let probe = (*spec).resolve(&fabric); + let before = (probe.source, probe.sport); + let out = run( + &mut lookup, + &mut masq, + vec![probe.packet()], + probe.arrival.dst_vpcd, + ); + let after = source_of(&out[0]); + if after == before || out[0].is_done() { + continue; + } + + let back = run( + &mut lookup, + &mut masq, + vec![probe.reply(after.0, after.1)], + Arrival::inbound().dst_vpcd, + ); + assert_eq!( + destination_of(&back[0]), + before, + "{:?} was masqueraded to {after:?}, and the reply came back to {:?}", + before, + destination_of(&back[0]) + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("reversibility"); +} + +#[test] +fn a_flow_keeps_its_translation() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: false }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + for spec in &probes { + let probe = (*spec).resolve(&fabric); + let before = (probe.source, probe.sport); + let first = run(&mut lookup, &mut masq, vec![probe.packet()], probe.arrival.dst_vpcd); + if out_unchanged(&first, before) { + continue; + } + let second = run(&mut lookup, &mut masq, vec![probe.packet()], probe.arrival.dst_vpcd); + + assert_eq!( + source_of(&second[0]), + source_of(&first[0]), + "the same flow from {before:?} was given {:?} and then {:?}, so its reply can \ + only reach one of them", + source_of(&first[0]), + source_of(&second[0]) + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("stability"); +} + +fn out_unchanged(out: &[Packet], before: (IpAddr, u16)) -> bool { + out[0].is_done() || source_of(&out[0]) == before +} + +#[test] +fn distinct_flows_do_not_share_a_translation() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: false }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + let mut taken: BTreeMap<(IpAddr, u16), (IpAddr, u16)> = BTreeMap::new(); + for (index, spec) in probes.iter().enumerate() { + let mut probe = (*spec).resolve(&fabric); + probe.sport = u16::try_from(1024 + index).unwrap_or(1024); + let before = (probe.source, probe.sport); + let out = run(&mut lookup, &mut masq, vec![probe.packet()], probe.arrival.dst_vpcd); + if out_unchanged(&out, before) { + continue; + } + let after = source_of(&out[0]); + + if let Some(previous) = taken.insert(after, before) { + assert_eq!( + previous, before, + "flows from {previous:?} and {before:?} were both masqueraded to {after:?}, \ + so a reply can only reach one of them" + ); + } + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("exclusivity"); +} + +#[test] +fn a_translation_stays_inside_the_public_range() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: false }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + for spec in &probes { + let probe = (*spec).resolve(&fabric); + let before = (probe.source, probe.sport); + let out = run( + &mut lookup, + &mut masq, + vec![probe.packet()], + probe.arrival.dst_vpcd, + ); + if out_unchanged(&out, before) { + continue; + } + let (addr, port) = source_of(&out[0]); + + assert!( + fabric.is_public(addr), + "{before:?} was masqueraded to {addr}:{port}, which no expose offers; the \ + fabric has no route back to it" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("containment"); +} + +#[test] +fn nothing_is_masqueraded_without_permission() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: true }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + for spec in &probes { + let probe = (*spec).resolve(&fabric); + if probe.asks_for_translation() && probe.exposed { + continue; + } + let before = (probe.source, probe.sport); + let (stray, arrival) = (probe.stray, probe.arrival); + let out = run(&mut lookup, &mut masq, vec![probe.packet()], probe.arrival.dst_vpcd); + + if out[0].is_done() { + tally.reached.fetch_add(1, Ordering::Relaxed); + continue; + } + assert_eq!( + source_of(&out[0]), + before, + "masquerade translated {before:?} although {stray:?} forbade it; the packet \ + arrived as {arrival:?}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("permission"); +} + +#[test] +fn a_flow_that_cannot_be_masqueraded_says_so() { + let tally = Tally::default(); + + with_runtime(|| { + bolero::check!() + .with_generator(Scenario { strays: true }) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let (mut lookup, mut masq) = fabric.stages(); + + for spec in &probes { + let probe = (*spec).resolve(&fabric); + let unplaceable = matches!( + probe.stray, + Some(Stray::SourceNotExposed | Stray::UnknownSourceVni | Stray::UnknownDestVni) + ); + if !unplaceable { + continue; + } + let before = (probe.source, probe.sport); + let stray = probe.stray; + let out = run(&mut lookup, &mut masq, vec![probe.packet()], probe.arrival.dst_vpcd); + let packet = &out[0]; + + assert!( + packet.is_done(), + "a flow from {before:?} with {stray:?} passed masquerade with no verdict, so \ + a private address reaches the fabric untranslated" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + }); + + tally.report("attribution"); +} diff --git a/nat/src/masquerade/mod.rs b/nat/src/masquerade/mod.rs index d7b2861dba..5e149bb06f 100644 --- a/nat/src/masquerade/mod.rs +++ b/nat/src/masquerade/mod.rs @@ -5,10 +5,12 @@ pub(crate) mod allocation; mod allocator_writer; pub mod apalloc; pub(crate) mod flows; +mod fuzz; pub(crate) mod icmp_handling; mod natip; mod nf; mod packet; +mod probe; mod protocol; mod state; mod test; diff --git a/nat/src/masquerade/probe.rs b/nat/src/masquerade/probe.rs new file mode 100644 index 0000000000..b689e831df --- /dev/null +++ b/nat/src/masquerade/probe.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![cfg(test)] + +use crate::masquerade::{MasqueradeConfig, NatAllocatorWriter}; +use bolero::TypeGenerator; +use concurrency::sync::Arc; +use config::external::overlay::vpcpeering::VpcExpose; +use config::external::overlay::vpcpeering::contract::{ + LOCAL_VNI, REMOTE_VNI, overlay_with_exposes, +}; +use flow_entry::flow_table::{FlowLookup, FlowTable}; +use lpm::prefix::Prefix; +use net::buffer::TestBuffer; +use net::packet::{Packet, VpcDiscriminant}; +use net::vxlan::Vni; +use pipeline::NetworkFunction; +use std::net::IpAddr; + +use crate::Masquerade; +use crate::static_nat::probe::{build, vni}; + +const FLOW_CAPACITY: usize = 4096; + +const ABSENT_VNI: u32 = 4_000; + +pub(crate) struct Fabric { + flow_table: Arc, + allocator: NatAllocatorWriter, + pub(crate) private: Vec, + pub(crate) public: Vec, + pub(crate) peer: Vec, +} + +impl Fabric { + pub(crate) fn build(exposes: &[VpcExpose]) -> Option { + let overlay = overlay_with_exposes(exposes.to_vec()).ok()?; + let validated = overlay.validate().ok()?; + + let private: Vec = exposes + .iter() + .flat_map(|e| e.ips.iter().map(|p| p.prefix().as_address())) + .collect(); + let public: Vec = validated + .vpc_table() + .values() + .filter(|vpc| vpc.vni() == vni(LOCAL_VNI)) + .flat_map(config::external::overlay::vpc::ValidatedVpc::peerings) + .flat_map(|peering| peering.local().valexp()) + .flat_map(|expose| expose.as_range_or_empty().iter()) + .map(lpm::prefix::PrefixWithOptionalPorts::prefix) + .collect(); + + let peer = match private.first() { + Some(IpAddr::V6(_)) => vec![ + "2001:db8:ffff::1" + .parse() + .unwrap_or_else(|_| unreachable!()), + "2001:db8:ffff::2" + .parse() + .unwrap_or_else(|_| unreachable!()), + ], + _ => vec![ + "3.3.3.1".parse().unwrap_or_else(|_| unreachable!()), + "3.3.3.2".parse().unwrap_or_else(|_| unreachable!()), + ], + }; + + let flow_table = Arc::new(FlowTable::new(FLOW_CAPACITY)); + let mut allocator = NatAllocatorWriter::new(); + let config = MasqueradeConfig::new(validated.vpc_table()).set_randomize(false); + allocator.update_nat_allocator(config, 1, &flow_table); + + Some(Self { + flow_table, + allocator, + private, + public, + peer, + }) + } + + pub(crate) fn stages(&self) -> (FlowLookup, Masquerade) { + ( + FlowLookup::new("flow-lookup", self.flow_table.clone()), + Masquerade::new( + "masquerade", + self.flow_table.clone(), + self.allocator.get_reader(), + ), + ) + } + + pub(crate) fn is_probeable(&self) -> bool { + !self.private.is_empty() && !self.public.is_empty() + } + + pub(crate) fn is_public(&self, addr: IpAddr) -> bool { + self.public.iter().any(|p| p.covers_addr(&addr)) + } +} + +pub(crate) fn run( + lookup: &mut FlowLookup, + masq: &mut Masquerade, + packets: Vec>, + dst_vpcd: Option, +) -> Vec> { + let mut looked: Vec<_> = lookup.process(packets.into_iter()).collect(); + for packet in &mut looked { + packet.meta_mut().dst_vpcd = dst_vpcd.map(VpcDiscriminant::from_vni); + } + masq.process(looked.into_iter()).collect() +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Arrival { + pub(crate) src_vpcd: Option, + pub(crate) dst_vpcd: Option, + pub(crate) wants_masquerade: bool, +} + +impl Arrival { + pub(crate) fn outbound() -> Self { + Self { + src_vpcd: Some(vni(LOCAL_VNI)), + dst_vpcd: Some(vni(REMOTE_VNI)), + wants_masquerade: true, + } + } + + pub(crate) fn inbound() -> Self { + Self { + src_vpcd: Some(vni(REMOTE_VNI)), + dst_vpcd: Some(vni(LOCAL_VNI)), + wants_masquerade: true, + } + } + + pub(crate) fn stamp(self, packet: &mut Packet) { + let meta = packet.meta_mut(); + meta.src_vpcd = self.src_vpcd.map(VpcDiscriminant::from_vni); + meta.set_overlay(true); + meta.set_keep(true); + meta.set_masquerade(self.wants_masquerade); + } +} + +#[derive(Debug, Clone, Copy, TypeGenerator)] +pub(crate) enum Stray { + SourceNotExposed, + UnknownSourceVni, + UnknownDestVni, + NotAskedFor, +} + +#[derive(Debug, Clone, Copy, TypeGenerator)] +pub(crate) struct ProbeSpec { + source: u8, + peer: u8, + sport: u16, + dport: u16, + stray: Option, +} + +pub(crate) struct Probe { + pub(crate) source: IpAddr, + pub(crate) destination: IpAddr, + pub(crate) sport: u16, + pub(crate) dport: u16, + pub(crate) exposed: bool, + pub(crate) arrival: Arrival, + pub(crate) stray: Option, +} + +impl Probe { + pub(crate) fn asks_for_translation(&self) -> bool { + self.arrival.wants_masquerade + && self.arrival.src_vpcd == Some(vni(LOCAL_VNI)) + && self.arrival.dst_vpcd == Some(vni(REMOTE_VNI)) + } + + pub(crate) fn packet(&self) -> Packet { + let mut packet = build(self.source, self.destination, false, self.sport, self.dport); + self.arrival.stamp(&mut packet); + packet + } + + pub(crate) fn reply(&self, translated: IpAddr, translated_port: u16) -> Packet { + let mut packet = build( + self.destination, + translated, + false, + self.dport, + translated_port, + ); + Arrival::inbound().stamp(&mut packet); + packet + } +} + +impl ProbeSpec { + pub(crate) fn clear_stray(&mut self) { + self.stray = None; + } + + pub(crate) fn resolve(self, fabric: &Fabric) -> Probe { + let mut arrival = Arrival::outbound(); + let mut source = fabric.private[self.source as usize % fabric.private.len()]; + let destination = fabric.peer[self.peer as usize % fabric.peer.len()]; + let mut exposed = true; + + match self.stray { + None => {} + Some(Stray::SourceNotExposed) => { + source = destination; + exposed = false; + } + Some(Stray::UnknownSourceVni) => arrival.src_vpcd = Some(vni(ABSENT_VNI)), + Some(Stray::UnknownDestVni) => arrival.dst_vpcd = Some(vni(ABSENT_VNI)), + Some(Stray::NotAskedFor) => arrival.wants_masquerade = false, + } + + Probe { + source, + destination, + sport: self.sport.max(1), + dport: self.dport.max(1), + exposed, + arrival, + stray: self.stray, + } + } +} diff --git a/nat/src/static_nat/fuzz.rs b/nat/src/static_nat/fuzz.rs new file mode 100644 index 0000000000..7987784ea3 --- /dev/null +++ b/nat/src/static_nat/fuzz.rs @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![cfg(test)] + +use crate::static_nat::nf::StaticNat; +use crate::static_nat::probe::{Fabric, ProbeSpec, Stray}; +use bolero::{Driver, TypeGenerator, ValueGenerator}; +use concurrency::sync::atomic::{AtomicUsize, Ordering}; +use config::external::overlay::vpcpeering::VpcExpose; +use config::external::overlay::vpcpeering::contract::StaticNatExposes; +use net::buffer::TestBuffer; +use net::ip::NextHeader; +use net::packet::{DoneReason, Packet}; +use pipeline::NetworkFunction; +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::num::NonZero; + +const MAX_EXPOSES: u8 = 3; + +const PROBES: usize = 8; + +#[derive(Debug, Clone, Copy)] +struct Scenario { + strays: bool, + exposes: StaticNatExposes, +} + +impl Scenario { + fn addresses(strays: bool) -> Self { + Self { + strays, + exposes: StaticNatExposes::addresses_only(MAX_EXPOSES), + } + } + + fn ports(strays: bool) -> Self { + Self { + strays, + exposes: StaticNatExposes::with_ports(MAX_EXPOSES), + } + } +} + +impl ValueGenerator for Scenario { + type Output = (Vec, Vec); + + fn generate(&self, driver: &mut D) -> Option { + let exposes = self.exposes.generate(driver)?; + + let mut probes = Vec::with_capacity(PROBES); + for _ in 0..PROBES { + let mut probe = ProbeSpec::generate(driver)?; + if !self.strays { + probe.clear_stray(); + } + probes.push(probe); + } + Some((exposes, probes)) + } +} + +fn run(nf: &mut StaticNat, packets: Vec>) -> Vec> { + nf.process(packets.into_iter()).collect() +} + +fn fabric(exposes: &[VpcExpose]) -> Option { + let fabric = Fabric::build(exposes)?; + fabric.is_probeable().then_some(fabric) +} + +fn five_tuple_source(packet: &Packet) -> (IpAddr, u16) { + ( + packet + .ip_source() + .unwrap_or_else(|| unreachable!("a probe is always an ip packet")), + packet.transport_src_port().map_or(0, NonZero::get), + ) +} + +fn five_tuple_destination(packet: &Packet) -> (IpAddr, u16) { + ( + packet + .ip_destination() + .unwrap_or_else(|| unreachable!("a probe is always an ip packet")), + packet.transport_dst_port().map_or(0, NonZero::get), + ) +} + +#[derive(Default)] +struct Tally { + seen: AtomicUsize, + built: AtomicUsize, + reached: AtomicUsize, +} + +impl Tally { + fn report(&self, what: &str) { + let (seen, built, reached) = ( + self.seen.load(Ordering::Relaxed), + self.built.load(Ordering::Relaxed), + self.reached.load(Ordering::Relaxed), + ); + if seen == 0 { + return; + } + println!("{what}: {built}/{seen} configurations built, {reached} probes reached it"); + assert!( + built * 2 >= seen, + "only {built} of {seen} configurations built, so this checked much less than it looks \ + like it did" + ); + assert!( + reached > 0 && reached * 2 >= built, + "{reached} probes reached the {what} assertion across {built} configurations; \ + this property has gone vacuous" + ); + } +} + +macro_rules! drive_round_trip { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!().with_generator($scenario).cloned().for_each( + |(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + for spec in &probes { + let mut probe = (*spec).resolve(&fabric); + let (source, sport) = (probe.source, probe.sport); + let out = run(&mut nf, vec![probe.take()]); + let (translated, translated_port) = five_tuple_source(&out[0]); + + if (translated, translated_port) == (source, sport) { + continue; + } + + let back = run(&mut nf, vec![probe.reply(translated, translated_port)]); + let (returned, returned_port) = five_tuple_destination(&back[0]); + + assert_eq!( + (returned, returned_port), + (source, sport), + "{source}:{sport} translated to {translated}:{translated_port} on the way out, \ + and the reply came back to {returned}:{returned_port}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }, + ); + + tally.report("round trip"); +}}; +} + +#[test] +fn a_translated_source_comes_back() { + drive_round_trip!(Scenario::addresses(false)); +} + +#[test] +fn a_translated_source_and_port_come_back() { + drive_round_trip!(Scenario::ports(false)); +} + +macro_rules! drive_injectivity { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!().with_generator($scenario).cloned().for_each( + |(exposes, _probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + let sources = fabric.every_source(); + let batch: Vec> = sources + .iter() + .map(|(endpoint, port)| fabric.outbound_to_peer(*endpoint, *port)) + .collect(); + let out = run(&mut nf, batch); + + let mut taken: BTreeMap<(IpAddr, u16), (IpAddr, u16)> = BTreeMap::new(); + for ((endpoint, port), packet) in sources.iter().zip(out.iter()) { + let before = (endpoint.addr, *port); + let after = five_tuple_source(packet); + if after == before { + continue; + } + if let Some(previous) = taken.insert(after, before) { + let (addr, port) = after; + let (pa, pp) = previous; + let (ba, bp) = before; + panic!( + "{ba}:{bp} and {pa}:{pp} both translated to {addr}:{port}, so static NAT \ + is not one to one for {exposes:#?}" + ); + } + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }, + ); + + tally.report("injectivity"); +}}; +} + +#[test] +fn distinct_sources_stay_distinct() { + drive_injectivity!(Scenario::addresses(false)); +} + +#[test] +fn distinct_sources_and_ports_stay_distinct() { + drive_injectivity!(Scenario::ports(false)); +} + +macro_rules! drive_frame { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!() + .with_generator($scenario) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + for spec in &probes { + let mut probe = (*spec).resolve(&fabric); + let (destination, sport, dport) = (probe.destination, probe.sport, probe.dport); + let proto = if probe.tcp { + NextHeader::TCP + } else { + NextHeader::UDP + }; + let out = run(&mut nf, vec![probe.take()]); + let packet = &out[0]; + + assert_eq!( + packet.ip_destination(), + Some(destination), + "source translation rewrote the destination" + ); + assert_eq!( + packet.transport_dst_port().map(NonZero::get), + Some(dport), + "source translation rewrote the destination port" + ); + assert_eq!( + packet.ip_proto(), + Some(proto), + "source translation changed the transport protocol" + ); + if !fabric.uses_ports { + assert_eq!( + packet.transport_src_port().map(NonZero::get), + Some(sport), + "source translation rewrote the source port, which no expose asked for" + ); + } + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report("frame"); + }}; +} + +#[test] +fn translation_touches_only_the_source() { + drive_frame!(Scenario::addresses(false)); +} + +#[test] +fn port_translation_touches_only_the_source() { + drive_frame!(Scenario::ports(false)); +} + +macro_rules! drive_permission { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!() + .with_generator($scenario) + .cloned() + .for_each(|(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + for spec in &probes { + let mut probe = (*spec).resolve(&fabric); + if probe.asks_for_translation() && probe.exposed { + continue; + } + + let (source, sport) = (probe.source, probe.sport); + let (stray, arrival) = (probe.stray, probe.arrival); + let out = run(&mut nf, vec![probe.take()]); + + assert_eq!( + five_tuple_source(&out[0]), + (source, sport), + "static NAT translated {source}:{sport} although {stray:?} forbade it; the \ + packet arrived as {arrival:?}" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }); + + tally.report("permission"); + }}; +} + +#[test] +fn nothing_is_translated_without_permission() { + drive_permission!(Scenario::addresses(true)); +} + +#[test] +fn no_port_is_translated_without_permission() { + drive_permission!(Scenario::ports(true)); +} + +macro_rules! drive_attribution { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!().with_generator($scenario).cloned().for_each( + |(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + for spec in &probes { + let mut probe = (*spec).resolve(&fabric); + let unroutable = matches!( + probe.stray, + Some(Stray::NoSourceVni | Stray::UnknownSourceVni) + ); + if !unroutable { + continue; + } + + let (source, stray) = (probe.source, probe.stray); + let out = run(&mut nf, vec![probe.take()]); + + let reason = out[0].get_done().unwrap_or_else(|| { + panic!( + "a packet with {stray:?} passed static NAT with no verdict at all, so \ + {source} would be forwarded untranslated" + ) + }); + assert_eq!( + reason, + DoneReason::Unroutable, + "a packet with {stray:?} was dropped for {reason:?}, which does not describe \ + what happened to it" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }, + ); + + tally.report("attribution"); +}}; +} + +#[test] +fn a_packet_that_cannot_be_looked_up_says_so() { + drive_attribution!(Scenario::addresses(true)); +} + +macro_rules! drive_marking { + ($scenario:expr) => {{ + let tally = Tally::default(); + + bolero::check!().with_generator($scenario).cloned().for_each( + |(exposes, probes): (Vec, Vec)| { + tally.seen.fetch_add(1, Ordering::Relaxed); + let Some(fabric) = fabric(&exposes) else { + return; + }; + tally.built.fetch_add(1, Ordering::Relaxed); + let mut nf = fabric.nf(); + + for spec in &probes { + let mut probe = (*spec).resolve(&fabric); + let before = (probe.source, probe.sport); + let out = run(&mut nf, vec![probe.take()]); + let packet = &out[0]; + + if five_tuple_source(packet) == before { + continue; + } + let (source, sport) = before; + + assert!( + packet.meta().is_src_natted(), + "{source}:{sport} was translated without the source-natted mark, so a later \ + stage would translate it again" + ); + assert!( + packet.meta().checksum_refresh(), + "{source}:{sport} was translated without asking for a checksum refresh, so the \ + packet goes out with a checksum for headers it no longer carries" + ); + tally.reached.fetch_add(1, Ordering::Relaxed); + } + }, + ); + + tally.report("marking"); +}}; +} + +#[test] +fn a_modified_packet_is_always_marked() { + drive_marking!(Scenario::addresses(true)); +} + +#[test] +fn a_port_modified_packet_is_always_marked() { + drive_marking!(Scenario::ports(true)); +} diff --git a/nat/src/static_nat/mod.rs b/nat/src/static_nat/mod.rs index 844cefa19a..a2bf321741 100644 --- a/nat/src/static_nat/mod.rs +++ b/nat/src/static_nat/mod.rs @@ -3,8 +3,10 @@ //! Static NAT implementation +pub(crate) mod fuzz; pub mod natrw; pub mod nf; +pub(crate) mod probe; pub mod setup; pub(crate) mod test; diff --git a/nat/src/static_nat/probe.rs b/nat/src/static_nat/probe.rs new file mode 100644 index 0000000000..1272192830 --- /dev/null +++ b/nat/src/static_nat/probe.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![cfg(test)] + +use crate::static_nat::nf::StaticNat; +use crate::static_nat::setup::build_nat_configuration; +use bolero::TypeGenerator; +use config::external::overlay::vpcpeering::VpcExpose; +use config::external::overlay::vpcpeering::contract::{ + LOCAL_VNI, REMOTE_VNI, overlay_with_exposes, +}; +use lpm::prefix::{PortRange, PrefixWithOptionalPorts}; +use net::buffer::TestBuffer; +use net::ip::{NextHeader, UnicastIpAddr}; +use net::packet::test_utils::{ + build_test_ipv4_packet_with_transport, build_test_ipv6_packet_with_transport, +}; +use net::packet::{Packet, VpcDiscriminant}; +use net::tcp::port::TcpPort; +use net::udp::UdpPort; +use net::vxlan::Vni; +use std::net::IpAddr; + +pub(crate) const PROBE_TTL: u8 = 64; + +const ABSENT_VNI: u32 = 4_000; + +pub(crate) fn vni(raw: u32) -> Vni { + Vni::new_checked(raw).unwrap_or_else(|_| unreachable!("{raw} is a legal vni")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Endpoint { + pub(crate) addr: IpAddr, + pub(crate) ports: Option, +} + +impl Endpoint { + pub(crate) fn port(&self, index: u16) -> u16 { + match self.ports { + None => index.max(1), + Some(range) => { + let len = u32::try_from(range.len()).unwrap_or(u32::from(u16::MAX)); + let offset = u32::from(index) % len.max(1); + u16::try_from(u32::from(range.start()) + offset).unwrap_or(range.end()) + } + } + } +} + +pub(crate) fn endpoints<'a>( + prefixes: impl IntoIterator, +) -> Vec { + let mut out = Vec::new(); + for prefix_with_ports in prefixes { + let ports = prefix_with_ports.ports(); + let prefix = prefix_with_ports.prefix(); + let (start, end) = (prefix.as_address(), prefix.last_address()); + let (mut bits, last) = match (start, end) { + (IpAddr::V4(a), IpAddr::V4(b)) => (u128::from(a.to_bits()), u128::from(b.to_bits())), + (IpAddr::V6(a), IpAddr::V6(b)) => (a.to_bits(), b.to_bits()), + _ => unreachable!("a prefix does not change address family"), + }; + while bits <= last { + out.push(Endpoint { + addr: match start { + IpAddr::V4(_) => IpAddr::V4( + u32::try_from(bits) + .unwrap_or_else(|_| unreachable!()) + .into(), + ), + IpAddr::V6(_) => IpAddr::V6(bits.into()), + }, + ports, + }); + bits += 1; + } + } + out +} + +pub(crate) struct Fabric { + writer: crate::static_nat::natrw::NatTablesWriter, + pub(crate) private: Vec, + pub(crate) public: Vec, + pub(crate) peer: Vec, + pub(crate) uses_ports: bool, +} + +impl Fabric { + pub(crate) fn build(exposes: &[VpcExpose]) -> Option { + let overlay = overlay_with_exposes(exposes.to_vec()).ok()?; + let validated = overlay.validate().ok()?; + let tables = build_nat_configuration(validated.vpc_table()).ok()?; + + let local: Vec<&config::external::overlay::vpcpeering::ValidatedExpose> = validated + .vpc_table() + .values() + .filter(|vpc| vpc.vni() == vni(LOCAL_VNI)) + .flat_map(config::external::overlay::vpc::ValidatedVpc::peerings) + .flat_map(|peering| peering.local().valexp()) + .collect(); + let private: Vec = local + .iter() + .flat_map(|expose| endpoints(expose.ips().iter())) + .collect(); + let public: Vec = local + .iter() + .flat_map(|expose| endpoints(expose.as_range_or_empty().iter())) + .collect(); + let uses_ports = private.iter().chain(&public).any(|e| e.ports.is_some()); + + let peer = match private.first().map(|e| e.addr) { + Some(IpAddr::V6(_)) => vec![ + "2001:db8:ffff::1" + .parse() + .unwrap_or_else(|_| unreachable!()), + "2001:db8:ffff::2" + .parse() + .unwrap_or_else(|_| unreachable!()), + ], + _ => vec![ + "3.3.3.1".parse().unwrap_or_else(|_| unreachable!()), + "3.3.3.2".parse().unwrap_or_else(|_| unreachable!()), + ], + }; + + let mut writer = crate::static_nat::natrw::NatTablesWriter::new(); + writer.update_nat_tables(tables); + Some(Self { + writer, + private, + public, + peer, + uses_ports, + }) + } + + pub(crate) fn nf(&self) -> StaticNat { + StaticNat::with_reader("probe", self.writer.get_reader()) + } + + pub(crate) fn outbound_to_peer(&self, source: Endpoint, port: u16) -> Packet { + let mut packet = build(source.addr, self.peer[0], false, port, 80); + Arrival::outbound().stamp(&mut packet); + packet + } + + pub(crate) fn every_source(&self) -> Vec<(Endpoint, u16)> { + self.private + .iter() + .flat_map(|endpoint| match endpoint.ports { + None => vec![(*endpoint, 1024)], + Some(range) => (range.start()..=range.end()) + .map(|port| (*endpoint, port)) + .collect(), + }) + .collect() + } + + pub(crate) fn is_probeable(&self) -> bool { + !self.private.is_empty() && !self.public.is_empty() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Arrival { + pub(crate) src_vpcd: Option, + pub(crate) dst_vpcd: Option, + pub(crate) wants_src_nat: bool, + pub(crate) wants_dst_nat: bool, + pub(crate) already_src_natted: bool, +} + +impl Arrival { + pub(crate) fn outbound() -> Self { + Self { + src_vpcd: Some(vni(LOCAL_VNI)), + dst_vpcd: Some(vni(REMOTE_VNI)), + wants_src_nat: true, + wants_dst_nat: false, + already_src_natted: false, + } + } + + pub(crate) fn inbound() -> Self { + Self { + src_vpcd: Some(vni(REMOTE_VNI)), + dst_vpcd: Some(vni(LOCAL_VNI)), + wants_src_nat: false, + wants_dst_nat: true, + already_src_natted: false, + } + } + + pub(crate) fn stamp(self, packet: &mut Packet) { + let meta = packet.meta_mut(); + meta.src_vpcd = self.src_vpcd.map(VpcDiscriminant::from_vni); + meta.dst_vpcd = self.dst_vpcd.map(VpcDiscriminant::from_vni); + meta.set_overlay(true); + meta.set_keep(true); + meta.set_static_nat_src(self.wants_src_nat); + meta.set_static_nat_dst(self.wants_dst_nat); + meta.src_natted(self.already_src_natted); + } +} + +#[derive(Debug, Clone, Copy, TypeGenerator)] +pub(crate) enum Stray { + SourceNotExposed, + NoSourceVni, + UnknownSourceVni, + UnknownDestVni, + AlreadySourceNatted, + NotAskedFor, +} + +#[derive(Debug, Clone, Copy, TypeGenerator)] +pub(crate) struct ProbeSpec { + source: u8, + peer: u8, + tcp: bool, + sport: u16, + dport: u16, + stray: Option, +} + +pub(crate) struct Probe { + packet: Option>, + pub(crate) source: IpAddr, + pub(crate) destination: IpAddr, + pub(crate) sport: u16, + pub(crate) dport: u16, + pub(crate) tcp: bool, + pub(crate) exposed: bool, + pub(crate) arrival: Arrival, + pub(crate) stray: Option, +} + +impl Probe { + pub(crate) fn take(&mut self) -> Packet { + self.packet + .take() + .unwrap_or_else(|| unreachable!("a probe's packet is taken once")) + } + + pub(crate) fn asks_for_translation(&self) -> bool { + self.arrival.wants_src_nat + && !self.arrival.already_src_natted + && self.arrival.src_vpcd == Some(vni(LOCAL_VNI)) + && self.arrival.dst_vpcd == Some(vni(REMOTE_VNI)) + } + + pub(crate) fn reply(&self, translated: IpAddr, translated_port: u16) -> Packet { + let mut packet = build( + self.destination, + translated, + self.tcp, + self.dport, + translated_port, + ); + Arrival::inbound().stamp(&mut packet); + packet + } +} + +impl ProbeSpec { + pub(crate) fn clear_stray(&mut self) { + self.stray = None; + } + + pub(crate) fn resolve(self, fabric: &Fabric) -> Probe { + let mut arrival = Arrival::outbound(); + let endpoint = fabric.private[self.source as usize % fabric.private.len()]; + + let destination = fabric.peer[self.peer as usize % fabric.peer.len()]; + let mut source = endpoint.addr; + let mut sport = endpoint.port(self.sport); + let mut exposed = true; + + match self.stray { + None => {} + Some(Stray::SourceNotExposed) => { + source = destination; + sport = self.sport.max(1); + exposed = false; + } + Some(Stray::NoSourceVni) => arrival.src_vpcd = None, + Some(Stray::UnknownSourceVni) => arrival.src_vpcd = Some(vni(ABSENT_VNI)), + Some(Stray::UnknownDestVni) => arrival.dst_vpcd = Some(vni(ABSENT_VNI)), + Some(Stray::AlreadySourceNatted) => arrival.already_src_natted = true, + Some(Stray::NotAskedFor) => { + arrival.wants_src_nat = false; + arrival.wants_dst_nat = false; + } + } + + let dport = self.dport.max(1); + let mut packet = build(source, destination, self.tcp, sport, dport); + arrival.stamp(&mut packet); + + Probe { + packet: Some(packet), + source, + destination, + sport, + dport, + tcp: self.tcp, + exposed, + arrival, + stray: self.stray, + } + } +} + +pub(crate) fn build( + source: IpAddr, + destination: IpAddr, + tcp: bool, + sport: u16, + dport: u16, +) -> Packet { + let next_header = if tcp { + NextHeader::TCP + } else { + NextHeader::UDP + }; + let mut packet = match (source, destination) { + (IpAddr::V4(_), IpAddr::V4(_)) => { + build_test_ipv4_packet_with_transport(PROBE_TTL, Some(next_header)) + .unwrap_or_else(|e| unreachable!("{e:?}")) + } + (IpAddr::V6(_), IpAddr::V6(_)) => { + build_test_ipv6_packet_with_transport(PROBE_TTL, Some(next_header)) + .unwrap_or_else(|e| unreachable!("{e:?}")) + } + _ => unreachable!("a probe never mixes address families"), + }; + + packet + .set_ip_source(UnicastIpAddr::try_from(source).unwrap_or_else(|_| { + unreachable!("{source} is drawn from a prefix an expose offers, so it is unicast") + })) + .unwrap_or_else(|e| unreachable!("{e:?}")); + packet + .set_ip_destination(destination) + .unwrap_or_else(|e| unreachable!("{e:?}")); + + if tcp { + packet + .set_tcp_source_port(TcpPort::new_checked(sport).unwrap_or_else(|_| unreachable!())) + .unwrap_or_else(|e| unreachable!("{e:?}")); + packet + .set_tcp_destination_port( + TcpPort::new_checked(dport).unwrap_or_else(|_| unreachable!()), + ) + .unwrap_or_else(|e| unreachable!("{e:?}")); + } else { + packet + .set_udp_source_port(UdpPort::new_checked(sport).unwrap_or_else(|_| unreachable!())) + .unwrap_or_else(|e| unreachable!("{e:?}")); + packet + .set_udp_destination_port( + UdpPort::new_checked(dport).unwrap_or_else(|_| unreachable!()), + ) + .unwrap_or_else(|e| unreachable!("{e:?}")); + } + + packet +}