diff --git a/config/src/external/overlay/acl.rs b/config/src/external/overlay/acl.rs index ffbd037bfa..6ea3e3b389 100644 --- a/config/src/external/overlay/acl.rs +++ b/config/src/external/overlay/acl.rs @@ -424,8 +424,8 @@ impl ValidatedAclRule { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Acl { - default: AclAction, - rules: Vec, + pub(crate) default: AclAction, + pub(crate) rules: Vec, } impl Acl { diff --git a/config/src/external/overlay/algebra.rs b/config/src/external/overlay/algebra.rs index 5fcf22bc4a..2812286c40 100644 --- a/config/src/external/overlay/algebra.rs +++ b/config/src/external/overlay/algebra.rs @@ -2,14 +2,19 @@ // Copyright Open Network Fabric Authors use std::collections::{BTreeMap, BTreeSet}; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; use std::ops::Bound::Included; +use std::time::Duration; use bolero::{Driver, ValueGenerator}; -use lpm::prefix::{IpPrefix, Ipv4Prefix, Prefix}; +use lpm::prefix::with_ports::L4Protocol; +use lpm::prefix::{ + IpPrefix, Ipv4Prefix, PortRange, Prefix, PrefixPortsSet, PrefixWithOptionalPorts, +}; use crate::ConfigError; use crate::external::overlay::Overlay; +use crate::external::overlay::acl::{Acl, AclAction, AclPattern, AclProtoMatch, AclRule, AclScope}; use crate::external::overlay::vpc::{Vpc, VpcTable}; use crate::external::overlay::vpcpeering::{VpcExpose, VpcManifest, VpcPeering, VpcPeeringTable}; @@ -46,6 +51,131 @@ impl Side { pub enum Flavour { Forward, Masquerade, + StaticNat, + PortForward, + Everything, +} + +impl Flavour { + #[must_use] + pub const fn is_directional(self) -> bool { + matches!(self, Self::Masquerade | Self::PortForward) + } + + const fn has_prefixes(self) -> bool { + !matches!(self, Self::Everything) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)] +pub enum Guard { + #[default] + Open, + Permit, + PermitFlow, + PermitExcept, + PermitByProtocol, + Deny, +} + +impl Guard { + fn acl(self, peering: PeeringHandle, spec: &PeeringSpec) -> Option { + let (default, action, scope) = match self { + Guard::Open => return None, + Guard::Permit | Guard::PermitExcept | Guard::PermitByProtocol => { + (AclAction::Deny, AclAction::Allow, AclScope::Packet) + } + Guard::PermitFlow => (AclAction::Deny, AclAction::Allow, AclScope::Flow), + Guard::Deny => (AclAction::Allow, AclAction::Deny, AclScope::Packet), + }; + let (proto, any_ports) = if self == Guard::PermitByProtocol { + (AclProtoMatch::Udp, vec![port_range(EVERY_PORT)]) + } else { + (AclProtoMatch::Any, Vec::new()) + }; + let rule = |side: Side| { + let (from, to) = (spec.vpc(side).name(), spec.vpc(side.other()).name()); + AclRule { + name: format!("{from}-to-{to}"), + from, + to, + action, + pattern: AclPattern { + src: PrefixPortsSet::new(), + dst: PrefixPortsSet::new(), + src_any_ports: any_ports.clone(), + dst_any_ports: any_ports.clone(), + proto, + }, + scope, + log: action == AclAction::Deny, + } + }; + let rules = match self { + Guard::PermitFlow => { + vec![rule(self.opening_side(spec).unwrap_or_else(|| { + unreachable!("`legal_on` refused a guard with no side") + }))] + } + Guard::PermitExcept | Guard::PermitByProtocol => { + let (side, which, prefix) = spec + .exception(peering) + .unwrap_or_else(|| unreachable!("`legal_on` refused a guard with no expose")); + let denied_from = match which { + Narrowing::Source => side, + Narrowing::Destination => side.other(), + }; + let mut denial = rule(denied_from); + denial.name = format!("{}-except", denial.name); + denial.action = AclAction::Deny; + let named = PrefixPortsSet::from([PrefixWithOptionalPorts::from(prefix)]); + match which { + Narrowing::Source => denial.pattern.src = named, + Narrowing::Destination => denial.pattern.dst = named, + } + if self == Guard::PermitByProtocol { + denial.pattern.proto = AclProtoMatch::Tcp; + } + vec![denial, rule(denied_from), rule(denied_from.other())] + } + Guard::Open | Guard::Permit | Guard::Deny => { + vec![rule(Side::Left), rule(Side::Right)] + } + }; + Some(Acl::new(default, rules)) + } + + fn opening_side(self, spec: &PeeringSpec) -> Option { + match self { + Guard::PermitFlow => Some( + spec.sole_opener() + .unwrap_or_else(|| unreachable!("`legal_on` refused a guard with no side")), + ), + Guard::Open + | Guard::Permit + | Guard::PermitExcept + | Guard::PermitByProtocol + | Guard::Deny => None, + } + } + + fn legal_on(self, spec: &PeeringSpec) -> bool { + match self { + Guard::Open | Guard::Permit | Guard::Deny => true, + Guard::PermitFlow => spec.sole_opener().is_some(), + Guard::PermitExcept | Guard::PermitByProtocol => spec.exception_slot().is_some(), + } + } + + fn silences(self, spec: &PeeringSpec, side: Side, nth: usize) -> bool { + match self { + Guard::Open | Guard::Permit | Guard::PermitFlow | Guard::PermitByProtocol => false, + Guard::Deny => true, + Guard::PermitExcept => spec + .exception_slot() + .is_some_and(|(at, which, _)| (at, which) == (side, nth)), + } + } } pub const MAX_EXPOSES: u8 = 4; @@ -68,11 +198,17 @@ impl VpcHandle { } } +const LONG_IDLE_TIMEOUT: Duration = Duration::from_hours(1); + impl PeeringHandle { fn name(self) -> String { format!("PEERING-{:03}", self.0) } + fn group(self) -> String { + format!("group-{}", self.0 % 3) + } + fn block(self, side: Side, slot: u8) -> u32 { u32::from(self.0) * SLOTS_PER_PEERING + u32::try_from(side.index()).unwrap_or_else(|_| unreachable!()) @@ -81,12 +217,30 @@ impl PeeringHandle { } } +pub(crate) const FORWARDED_PRIVATE_PORTS: (u16, u16) = (1000, 1004); + +pub(crate) const FORWARDED_PUBLIC_PORTS: (u16, u16) = (2000, 2004); + +const EVERY_PORT: (u16, u16) = (1, u16::MAX); + +fn port_range((start, end): (u16, u16)) -> PortRange { + PortRange::new(start, end).unwrap_or_else(|_| unreachable!("a well-formed port range")) +} + +const PRIVATE_BASE: u32 = 0x0A00_0000; + +const PUBLIC_BASE: u32 = 0xAC10_0000; + fn private_prefix(index: u32) -> Prefix { - prefix_v4(0x0A00_0000 | (index << 8), 24) + prefix_v4(PRIVATE_BASE | (index << 8), 24) } fn public_prefix(index: u32) -> Prefix { - prefix_v4(0xAC10_0000 | (index << 8), 24) + prefix_v4(PUBLIC_BASE | (index << 8), 24) +} + +fn excluded_slice(index: u32, base: u32) -> Prefix { + prefix_v4(base | (index << 8) | 0x40, 26) } fn prefix_v4(bits: u32, len: u8) -> Prefix { @@ -108,6 +262,31 @@ impl ExposeSpec { self.flavour } + fn idle_timeout(self) -> Option { + (self.slot % 2 == 1).then_some(LONG_IDLE_TIMEOUT) + } + + fn nat_proto(self) -> Option { + match self.flavour { + Flavour::PortForward => Some(match self.slot % 3 { + 0 => L4Protocol::Any, + 1 => L4Protocol::Udp, + _ => L4Protocol::Tcp, + }), + Flavour::Forward | Flavour::Masquerade | Flavour::StaticNat | Flavour::Everything => { + None + } + } + } + + fn excludes(self) -> bool { + self.slot < 2 + && matches!( + self.flavour, + Flavour::Forward | Flavour::Masquerade | Flavour::StaticNat + ) + } + #[must_use] pub fn private(self, peering: PeeringHandle, side: Side) -> Prefix { private_prefix(peering.block(side, self.slot)) @@ -116,21 +295,82 @@ impl ExposeSpec { #[must_use] pub fn public(self, peering: PeeringHandle, side: Side) -> Prefix { match self.flavour { - Flavour::Forward => self.private(peering, side), - Flavour::Masquerade => public_prefix(peering.block(side, self.slot)), + Flavour::Forward | Flavour::Everything => self.private(peering, side), + Flavour::Masquerade | Flavour::StaticNat | Flavour::PortForward => { + public_prefix(peering.block(side, self.slot)) + } } } + fn carve(self, expose: VpcExpose, peering: PeeringHandle, side: Side) -> VpcExpose { + if !self.excludes() { + return expose; + } + let block = peering.block(side, self.slot); + let expose = expose.not(excluded_slice(block, PRIVATE_BASE).into()); + if expose.nat.is_none() { + return expose; + } + expose + .not_as(excluded_slice(block, PUBLIC_BASE).into()) + .unwrap_or_else(|_| unreachable!("a translating expose accepts an excluded range")) + } + fn expose(self, peering: PeeringHandle, side: Side) -> VpcExpose { let private = self.private(peering, side); match self.flavour { - Flavour::Forward => VpcExpose::empty().ip(private.into()), - Flavour::Masquerade => VpcExpose::empty() - .make_masquerade(None) - .unwrap_or_else(|_| unreachable!("an empty expose accepts masquerade")) - .ip(private.into()) - .as_range(self.public(peering, side).into()) - .unwrap_or_else(|_| unreachable!("a masquerade expose accepts a public range")), + Flavour::Everything => VpcExpose::empty().set_default(), + Flavour::Forward => self.carve(VpcExpose::empty().ip(private.into()), peering, side), + Flavour::Masquerade => self.carve( + VpcExpose::empty() + .make_masquerade(self.idle_timeout()) + .unwrap_or_else(|_| unreachable!("an empty expose accepts masquerade")) + .ip(private.into()) + .as_range(self.public(peering, side).into()) + .unwrap_or_else(|_| unreachable!("a masquerade expose accepts a public range")), + peering, + side, + ), + Flavour::StaticNat => self.carve( + VpcExpose::empty() + .make_static_nat() + .unwrap_or_else(|_| unreachable!("an empty expose accepts static nat")) + .ip(private.into()) + .as_range(self.public(peering, side).into()) + .unwrap_or_else(|_| unreachable!("a static nat expose accepts a public range")), + peering, + side, + ), + Flavour::PortForward => VpcExpose::empty() + .make_port_forwarding(self.idle_timeout(), self.nat_proto()) + .unwrap_or_else(|_| unreachable!("an empty expose accepts port forwarding")) + .ip(PrefixWithOptionalPorts::new( + private, + Some(port_range(FORWARDED_PRIVATE_PORTS)), + )) + .as_range(PrefixWithOptionalPorts::new( + self.public(peering, side), + Some(port_range(FORWARDED_PUBLIC_PORTS)), + )) + .unwrap_or_else(|_| { + unreachable!("a port forwarding expose accepts a public range") + }), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Narrowing { + Source, + Destination, +} + +impl Narrowing { + fn of(flavour: Flavour) -> Option { + match flavour { + Flavour::Masquerade => Some(Self::Source), + Flavour::PortForward => Some(Self::Destination), + Flavour::Forward | Flavour::StaticNat | Flavour::Everything => None, } } } @@ -140,6 +380,7 @@ pub struct PeeringSpec { left: VpcHandle, right: VpcHandle, exposes: [Vec; 2], + guard: Guard, } impl PeeringSpec { @@ -151,6 +392,11 @@ impl PeeringSpec { } } + #[must_use] + pub fn guard(&self) -> Guard { + self.guard + } + #[must_use] pub fn exposes(&self, side: Side) -> &[ExposeSpec] { &self.exposes[side.index()] @@ -174,10 +420,52 @@ impl PeeringSpec { } } - fn has_stateful(&self, side: Side) -> bool { + fn sole_opener(&self) -> Option { + [Side::Left, Side::Right].into_iter().find_map(|side| { + let exposes = self.exposes(side); + if exposes.is_empty() { + return None; + } + let all = |flavour| exposes.iter().all(|expose| expose.flavour == flavour); + if all(Flavour::Masquerade) { + Some(side) + } else if all(Flavour::PortForward) { + Some(side.other()) + } else { + None + } + }) + } + + fn exception_slot(&self) -> Option<(Side, usize, Narrowing)> { + [Side::Left, Side::Right].into_iter().find_map(|side| { + self.exposes(side) + .iter() + .enumerate() + .find_map(|(nth, expose)| Some((side, nth, Narrowing::of(expose.flavour)?))) + }) + } + + fn exception(&self, peering: PeeringHandle) -> Option<(Side, Narrowing, Prefix)> { + let (side, nth, which) = self.exception_slot()?; + let expose = self.exposes(side)[nth]; + let prefix = match which { + Narrowing::Source => expose.private(peering, side), + Narrowing::Destination => expose.public(peering, side), + }; + Some((side, which, prefix)) + } + + fn has_everything(&self, side: Side) -> bool { + self.exposes(side) + .iter() + .any(|expose| expose.flavour == Flavour::Everything) + } + + fn has_directional(&self, side: Side) -> bool { self.exposes(side) .iter() - .any(|expose| expose.flavour == Flavour::Masquerade) + .any(|expose| expose.flavour.is_directional()) } fn manifest(&self, peering: PeeringHandle, side: Side) -> VpcManifest { @@ -218,6 +506,72 @@ impl Draft { .map(|(handle, _)| *handle) } + #[must_use] + pub fn carries(&self, peering: &str, local: &str, nth: usize) -> bool { + let Some((_, spec)) = self + .peerings + .iter() + .find(|(handle, _)| handle.name() == peering) + else { + return true; + }; + let Some(side) = [Side::Left, Side::Right] + .into_iter() + .find(|side| spec.vpc(*side).name() == local) + else { + return true; + }; + !spec.guard.silences(spec, side, nth) + } + + #[must_use] + pub fn unexposed_address(&self, peering: &str, local: &str, nth: usize) -> Option { + let (handle, spec) = self + .peerings + .iter() + .find(|(handle, _)| handle.name() == peering)?; + let side = [Side::Left, Side::Right] + .into_iter() + .find(|side| spec.vpc(*side).name() == local)?; + let expose = *spec.exposes(side).get(nth)?; + if !expose.excludes() || expose.flavour == Flavour::Masquerade { + return None; + } + if spec.has_everything(side) { + return None; + } + let base = match expose.flavour { + Flavour::Forward => PRIVATE_BASE, + Flavour::StaticNat => PUBLIC_BASE, + Flavour::Masquerade | Flavour::PortForward | Flavour::Everything => return None, + }; + Some(excluded_slice(handle.block(side, expose.slot), base).as_address()) + } + + #[must_use] + pub fn guard_named(&self, name: &str) -> Option { + self.peerings + .iter() + .find(|(handle, _)| handle.name() == name) + .map(|(_, spec)| spec.guard) + } + + fn peerings_of(&self, vpc: VpcHandle) -> usize { + self.peerings + .values() + .filter(|spec| spec.touches(vpc)) + .count() + } + + fn beside_everything(&self, vpc: VpcHandle) -> bool { + self.peerings.values().any(|spec| { + spec.touches(vpc) + && [Side::Left, Side::Right] + .into_iter() + .any(|side| spec.has_everything(side)) + }) + } + #[must_use] pub fn components(&self) -> Vec> { let mut unvisited: BTreeSet = self.vpcs.clone(); @@ -253,11 +607,14 @@ impl Draft { let mut peerings = VpcPeeringTable::new(); for (handle, spec) in self.peerings() { - peerings.add(VpcPeering::with_default_group( + let mut peering = VpcPeering::new( &handle.name(), spec.manifest(handle, Side::Left), spec.manifest(handle, Side::Right), - ))?; + handle.group(), + ); + peering.acl = spec.guard.acl(handle, spec); + peerings.add(peering)?; } Ok(Overlay::new(vpc_table, peerings)) @@ -330,6 +687,10 @@ pub enum Op { slot: u8, flavour: Flavour, }, + SetGuard { + peering: PeeringHandle, + guard: Guard, + }, } #[derive(Clone, PartialEq, Eq, Debug)] @@ -358,17 +719,42 @@ pub enum Undo { slot: u8, flavour: Flavour, }, + SetGuard { + peering: PeeringHandle, + guard: Guard, + }, } impl Op { #[must_use] - pub fn reads(&self) -> Footprint { + pub fn reads(&self, draft: &Draft) -> Footprint { match self { Op::AddVpc(_) | Op::RemoveVpc(_) | Op::RemovePeering(_) => Footprint::default(), - Op::AddPeering { left, right, .. } => Footprint::of([*left, *right], []), - Op::AddExpose { peering, .. } - | Op::RemoveExpose { peering, .. } - | Op::SetFlavour { peering, .. } => Footprint::of([], [*peering]), + Op::AddPeering { left, right, .. } => Footprint::of( + [*left, *right], + draft + .peerings() + .filter(|(_, spec)| spec.touches(*left) || spec.touches(*right)) + .map(|(handle, _)| handle), + ), + Op::RemoveExpose { peering, .. } | Op::SetGuard { peering, .. } => { + Footprint::of([], [*peering]) + } + Op::AddExpose { + peering, flavour, .. + } + | Op::SetFlavour { + peering, flavour, .. + } => { + let mut footprint = Footprint::of([], [*peering]); + if *flavour == Flavour::Everything + && let Some(spec) = draft.peerings.get(peering) + { + footprint.vpcs.insert(spec.left); + footprint.vpcs.insert(spec.right); + } + footprint + } } } @@ -406,7 +792,8 @@ impl Op { } Op::AddExpose { peering, .. } | Op::RemoveExpose { peering, .. } - | Op::SetFlavour { peering, .. } => Footprint::of([], [*peering]), + | Op::SetFlavour { peering, .. } + | Op::SetGuard { peering, .. } => Footprint::of([], [*peering]), } } @@ -437,6 +824,8 @@ impl Op { || !draft.vpcs.contains(&left) || !draft.vpcs.contains(&right) || draft.peering_between(left, right).is_some() + || draft.beside_everything(left) + || draft.beside_everything(right) { return None; } @@ -450,6 +839,7 @@ impl Op { left, right, exposes: [vec![first(0)], vec![first(0)]], + guard: Guard::Open, }, ); Some(Undo::RemovePeering(handle)) @@ -478,12 +868,16 @@ impl Op { } let index = spec.exposes(side).iter().position(|e| e.slot == slot)?; let removed = spec.exposes_mut(side).remove(index); - Some(Undo::RestoreExpose { + respecting_guard( + draft, peering, - side, - index, - spec: removed, - }) + Undo::RestoreExpose { + peering, + side, + index, + spec: removed, + }, + ) } Op::SetFlavour { @@ -492,6 +886,18 @@ impl Op { slot, flavour, } => set_flavour(draft, peering, side, slot, flavour), + + Op::SetGuard { peering, guard } => { + let spec = draft.peerings.get_mut(&peering)?; + if !guard.legal_on(spec) { + return None; + } + let previous = std::mem::replace(&mut spec.guard, guard); + Some(Undo::SetGuard { + peering, + guard: previous, + }) + } } } } @@ -533,7 +939,10 @@ fn add_expose( if spec.exposes(side).iter().any(|e| e.slot == slot) { return None; } - if flavour == Flavour::Masquerade && spec.has_stateful(side.other()) { + if flavour.is_directional() && spec.has_directional(side.other()) { + return None; + } + if flavour == Flavour::Everything && !may_expose_everything(draft, peering, side) { return None; } draft @@ -542,11 +951,35 @@ fn add_expose( .unwrap_or_else(|| unreachable!("just found it")) .exposes_mut(side) .push(ExposeSpec { slot, flavour }); - Some(Undo::RemoveExpose { + respecting_guard( + draft, peering, - side, - slot, - }) + Undo::RemoveExpose { + peering, + side, + slot, + }, + ) +} + +fn respecting_guard(draft: &mut Draft, peering: PeeringHandle, undo: Undo) -> Option { + let spec = draft.peerings.get(&peering)?; + if spec.guard.legal_on(spec) { + return Some(undo); + } + undo.apply(draft); + None +} + +fn may_expose_everything(draft: &Draft, peering: PeeringHandle, side: Side) -> bool { + let Some(spec) = draft.peerings.get(&peering) else { + return false; + }; + !spec.has_everything(side) + && !spec.has_everything(side.other()) + && [Side::Left, Side::Right] + .into_iter() + .all(|which| draft.peerings_of(spec.vpc(which)) == 1) } fn set_flavour( @@ -557,10 +990,16 @@ fn set_flavour( flavour: Flavour, ) -> Option { let spec = draft.peerings.get(&peering)?; - if flavour == Flavour::Masquerade && spec.has_stateful(side.other()) { + if flavour.is_directional() && spec.has_directional(side.other()) { return None; } let index = spec.exposes(side).iter().position(|e| e.slot == slot)?; + if flavour == Flavour::Everything + && spec.exposes(side)[index].flavour != Flavour::Everything + && !may_expose_everything(draft, peering, side) + { + return None; + } let exposes = draft .peerings .get_mut(&peering) @@ -568,12 +1007,16 @@ fn set_flavour( .exposes_mut(side); let previous = exposes[index].flavour; exposes[index].flavour = flavour; - Some(Undo::SetFlavour { + respecting_guard( + draft, peering, - side, - slot, - flavour: previous, - }) + Undo::SetFlavour { + peering, + side, + slot, + flavour: previous, + }, + ) } impl Undo { @@ -649,6 +1092,13 @@ impl Undo { .unwrap_or_else(|| unreachable!("no expose in slot {slot}")); spec.exposes_mut(*side)[index].flavour = *flavour; } + Undo::SetGuard { peering, guard } => { + draft + .peerings + .get_mut(peering) + .unwrap_or_else(|| unreachable!("no peering {peering:?}")) + .guard = *guard; + } } } } @@ -710,7 +1160,7 @@ impl ValueGenerator for Sequence { } } -const MENU: [(Kind, u8); 7] = [ +const MENU: [(Kind, u8); 8] = [ (Kind::AddVpc, 4), (Kind::RemoveVpc, 1), (Kind::AddPeering, 4), @@ -718,6 +1168,7 @@ const MENU: [(Kind, u8); 7] = [ (Kind::AddExpose, 2), (Kind::RemoveExpose, 1), (Kind::SetFlavour, 2), + (Kind::SetGuard, 2), ]; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -729,6 +1180,7 @@ enum Kind { AddExpose, RemoveExpose, SetFlavour, + SetGuard, } impl Kind { @@ -745,7 +1197,7 @@ impl Kind { let vpcs = draft.vpcs.len(); next_peering < u8::MAX && vpcs >= 2 && draft.peerings.len() < vpcs * (vpcs - 1) / 2 } - Kind::RemovePeering | Kind::SetFlavour => !draft.peerings.is_empty(), + Kind::RemovePeering | Kind::SetFlavour | Kind::SetGuard => !draft.peerings.is_empty(), Kind::AddExpose => sides().any(|exposes| exposes.len() < usize::from(MAX_EXPOSES)), Kind::RemoveExpose => sides().any(|exposes| exposes.len() > 1), } @@ -775,9 +1227,10 @@ fn draw( Kind::AddExpose => draw_add_expose(driver, draft), Kind::RemoveExpose => draw_remove_expose(driver, draft), Kind::SetFlavour => draw_set_flavour(driver, draft), + Kind::SetGuard => draw_set_guard(driver, draft), }; - let Some(op) = built else { + let Some(op) = built.filter(|op| op.applicable(draft)) else { menu.retain(|other| *other != kind); continue; }; @@ -882,7 +1335,12 @@ fn draw_add_expose(driver: &mut D, draft: &Draft) -> Option { peering, side, slot, - flavour: draw_flavour(driver, spec, side)?, + flavour: draw_flavour( + driver, + spec, + side, + may_expose_everything(draft, peering, side), + )?, }) } @@ -923,20 +1381,66 @@ fn draw_set_flavour(driver: &mut D, draft: &Draft) -> Option { let (peering, side, slot) = pick(driver, &exposes)?; let spec = draft.peerings.get(&peering)?; + let already = spec + .exposes(side) + .iter() + .any(|expose| expose.slot == slot && expose.flavour == Flavour::Everything); Some(Op::SetFlavour { peering, side, slot, - flavour: draw_flavour(driver, spec, side)?, + flavour: draw_flavour( + driver, + spec, + side, + already || may_expose_everything(draft, peering, side), + )?, }) } -fn draw_flavour(driver: &mut D, spec: &PeeringSpec, side: Side) -> Option { - if driver.produce::()? && !spec.has_stateful(side.other()) { - Some(Flavour::Masquerade) +fn draw_set_guard(driver: &mut D, draft: &Draft) -> Option { + const ORDERED: [Guard; 6] = [ + Guard::Open, + Guard::Permit, + Guard::PermitExcept, + Guard::PermitByProtocol, + Guard::PermitFlow, + Guard::Deny, + ]; + + let guard = pick(driver, &ORDERED)?; + let willing: Vec = draft + .peerings() + .filter(|(_, spec)| guard.legal_on(spec)) + .map(|(handle, _)| handle) + .collect(); + + Some(Op::SetGuard { + peering: pick(driver, &willing)?, + guard, + }) +} + +fn draw_flavour( + driver: &mut D, + spec: &PeeringSpec, + side: Side, + everything: bool, +) -> Option { + const ORDERED: [Flavour; 5] = [ + Flavour::Forward, + Flavour::StaticNat, + Flavour::PortForward, + Flavour::Masquerade, + Flavour::Everything, + ]; + let end = if everything { 5 } else { 4 }; + let legal = if spec.has_directional(side.other()) { + &ORDERED[..2] } else { - Some(Flavour::Forward) - } + &ORDERED[..end] + }; + pick(driver, legal) } fn pick(driver: &mut D, items: &[T]) -> Option { @@ -1010,7 +1514,8 @@ mod tests { ); } - static DRAWN: [AtomicUsize; 7] = [ + static DRAWN: [AtomicUsize; 8] = [ + AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), @@ -1020,7 +1525,7 @@ mod tests { AtomicUsize::new(0), ]; - const KINDS: [&str; 7] = [ + const KINDS: [&str; 8] = [ "AddVpc", "RemoveVpc", "AddPeering", @@ -1028,6 +1533,7 @@ mod tests { "AddExpose", "RemoveExpose", "SetFlavour", + "SetGuard", ]; fn record(op: Op) { @@ -1039,6 +1545,7 @@ mod tests { Op::AddExpose { .. } => 4, Op::RemoveExpose { .. } => 5, Op::SetFlavour { .. } => 6, + Op::SetGuard { .. } => 7, }; DRAWN[index].fetch_add(1, Relaxed); } @@ -1054,8 +1561,8 @@ mod tests { #[test] fn every_sequence_builds_a_valid_configuration() { - let masquerading = AtomicUsize::new(0); - let forwarding = AtomicUsize::new(0); + let flavours = [const { AtomicUsize::new(0) }; 5]; + let guards = [const { AtomicUsize::new(0) }; 6]; check!() .with_generator(Sequence::default()) @@ -1076,10 +1583,13 @@ mod tests { for (_, spec) in draft.peerings() { for side in [Side::Left, Side::Right] { for expose in spec.exposes(side) { - match expose.flavour() { - Flavour::Masquerade => &masquerading, - Flavour::Forward => &forwarding, - } + flavours[match expose.flavour() { + Flavour::Forward => 0, + Flavour::Masquerade => 1, + Flavour::StaticNat => 2, + Flavour::PortForward => 3, + Flavour::Everything => 4, + }] .fetch_add(1, Relaxed); } } @@ -1088,19 +1598,94 @@ mod tests { let overlay = draft .overlay() .unwrap_or_else(|e| panic!("{ops:?} does not assemble: {e}")); + + for (handle, spec) in draft.peerings() { + let acl = overlay + .peering_table + .values() + .find(|peering| peering.name == handle.name()) + .and_then(|peering| peering.acl.as_ref()); + let observed = acl.map_or(Guard::Open, |acl| { + let inert = acl + .rules() + .first() + .is_some_and(|rule| rule.pattern.proto == AclProtoMatch::Tcp); + match (acl.default_action(), acl.rules().len()) { + (AclAction::Deny, 1) => Guard::PermitFlow, + (AclAction::Deny, 3) if inert => Guard::PermitByProtocol, + (AclAction::Deny, 3) => Guard::PermitExcept, + (AclAction::Deny, _) => Guard::Permit, + (AclAction::Allow, _) => Guard::Deny, + } + }); + assert_eq!( + observed, + spec.guard(), + "{:?} is guarded {:?} and assembled an acl reading {observed:?}", + handle, + spec.guard() + ); + guards[match observed { + Guard::Open => 0, + Guard::Permit => 1, + Guard::PermitExcept => 2, + Guard::PermitByProtocol => 3, + Guard::PermitFlow => 4, + Guard::Deny => 5, + }] + .fetch_add(1, Relaxed); + } + if let Err(e) = overlay.validate() { panic!("{ops:?} builds a configuration the validator refuses: {e}"); } }); assert_every_kind_drawn(); - assert!( - masquerading.load(Relaxed) > 0 && forwarding.load(Relaxed) > 0, - "only one flavour of expose was ever built (masquerade {}, forward {}), so the \ - combination rules were not exercised", - masquerading.load(Relaxed), - forwarding.load(Relaxed) - ); + assert_every_shape_built(&flavours, &guards); + } + + const FLAVOURS: [&str; 5] = [ + "forward", + "masquerade", + "static-nat", + "port-forward", + "everything", + ]; + const GUARDS: [&str; 6] = [ + "open", + "permit", + "permit-except-one", + "permit-by-protocol", + "permit-by-flow", + "deny", + ]; + + fn assert_every_shape_built(flavours: &[AtomicUsize; 5], guards: &[AtomicUsize; 6]) { + let show = |names: &[&str], counts: &[AtomicUsize]| { + names + .iter() + .zip(counts) + .map(|(name, count)| format!("{name}={}", count.load(Relaxed))) + .collect::>() + .join(" ") + }; + let (built, set) = (show(&FLAVOURS, flavours), show(&GUARDS, guards)); + eprintln!("exposes: {built}\nguards: {set}"); + + for (name, count) in FLAVOURS.iter().zip(flavours) { + assert!( + count.load(Relaxed) > 0, + "no expose was ever {name} ({built}), so the nat combination rules were not \ + exercised" + ); + } + for (name, count) in GUARDS.iter().zip(guards) { + assert!( + count.load(Relaxed) > 0, + "no peering was ever left {name} ({set}), so the acl vocabulary was not exercised" + ); + } } #[test] @@ -1201,8 +1786,8 @@ mod tests { } fn conflict(draft: &Draft, first: Op, second: Op) -> bool { - let (rw1, ww1) = (first.reads(), first.writes(draft)); - let (rw2, ww2) = (second.reads(), second.writes(draft)); + let (rw1, ww1) = (first.reads(draft), first.writes(draft)); + let (rw2, ww2) = (second.reads(draft), second.writes(draft)); ww1.intersects(&ww2) || ww1.intersects(&rw2) || rw1.intersects(&ww2) } diff --git a/config/src/external/overlay/completeness.rs b/config/src/external/overlay/completeness.rs index 7b78fb5d4e..c74744d0b9 100644 --- a/config/src/external/overlay/completeness.rs +++ b/config/src/external/overlay/completeness.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use lpm::prefix::with_ports::{L4Protocol, PrefixPortsSet}; use super::Overlay; +use super::acl::{Acl, AclAction, AclPattern, AclProtoMatch, AclRule, AclScope}; use super::algebra::Sequence; use super::vpc::Vpc; use super::vpcpeering::{ @@ -37,8 +38,15 @@ const REACH: &[(&str, Reach)] = &[ ( "Vpc.interfaces", Reach::Fixed( - "empty. No operation attaches an interface to a vpc, so no generated configuration \ - has one. Reaching the interface-bearing paths at all needs a new operation.", + "empty, and left that way on purpose. An operation attaching one is easy and would be \ + the wrong thing: nothing reads the field. `Vpc::validate` clones it into \ + `ValidatedVpc` without checking anything about it, `ValidatedVpc::interfaces` has no \ + callers, and the interfaces that reach the kernel and FRR come from the *internal* \ + config's vrf tables instead -- see `mgmt::vpc_manager` and \ + `converters::k8s::config::underlay`. Filling this in would move the row and cover \ + nothing, which is the one failure mode this whole record exists to prevent. The thing \ + worth doing is upstream of here: either the field has a consumer and this record \ + should follow it there, or it does not and it should go.", ), ), ( @@ -61,19 +69,41 @@ const REACH: &[(&str, Reach)] = &[ ), ( "VpcPeering.gwgroup", - Reach::Fixed( - "the default group. `with_default_group` is the only constructor the algebra calls, \ - so nothing generated ever splits vpcs across gateway groups.", + Reach::Determined("the peering handle, over three groups"), + ), + ("VpcPeering.acl", Reach::Spans(&["absent", "present"])), + ("Acl.default", Reach::Spans(&["allow", "deny"])), + ("Acl.rules", Reach::Spans(&["1", "2", "3"])), + ( + "AclRule.name", + Reach::Determined("the two vpc handles, as `-to-`, and `-except` for a denial"), + ), + ( + "AclRule.from", + Reach::Determined("the vpc handle on the rule's side"), + ), + ( + "AclRule.to", + Reach::Determined("the vpc handle on the other side"), + ), + ("AclRule.action", Reach::Spans(&["allow", "deny"])), + ("AclRule.scope", Reach::Spans(&["flow", "packet"])), + ("AclRule.log", Reach::Spans(&["false", "true"])), + ( + "AclPattern.src", + Reach::Determined( + "empty, or the excepted expose's private prefix from its peering, side and slot", ), ), ( - "VpcPeering.acl", - Reach::Fixed( - "absent. Peering-scoped ACLs are not in the vocabulary, so no generated configuration \ - carries one -- and an ACL is precisely a thing that changes a verdict, which is what \ - every property here asserts over.", + "AclPattern.dst", + Reach::Determined( + "empty, or the excepted expose's public prefix from its peering, side and slot", ), ), + ("AclPattern.src_any_ports", Reach::Spans(&["0", "1"])), + ("AclPattern.dst_any_ports", Reach::Spans(&["0", "1"])), + ("AclPattern.proto", Reach::Spans(&["any", "tcp", "udp"])), ( "VpcManifest.name", Reach::Determined("the side's vpc handle"), @@ -82,69 +112,42 @@ const REACH: &[(&str, Reach)] = &[ "VpcManifest.exposes", Reach::Determined("one per `AddExpose`, in slot order"), ), - ( - "VpcExpose.default", - Reach::Fixed("false. `VpcExpose::empty` never sets it and no operation does either."), - ), + ("VpcExpose.default", Reach::Spans(&["false", "true"])), ( "VpcExpose.ips", Reach::Determined("one prefix, from the expose's peering, side and slot"), ), - ( - "VpcExpose.ips.ports", - Reach::Fixed( - "unset. The algebra exposes whole prefixes, so a port-restricted expose is \ - unreachable, and with it every question about how ports partition an address.", - ), - ), + ("VpcExpose.ips.ports", Reach::Spans(&["set", "unset"])), ( "VpcExpose.nots", - Reach::Fixed( - "empty -- the survey renders it as no prefixes at all. An expose that carves holes out of its own range is unreachable, which is a \ - real hole rather than a canonicalisation: an exclusion is what makes a prefix set \ - non-contiguous, and non-contiguous is where a matcher goes wrong.", - ), + Reach::Determined("a `/26` in the middle of the expose's block, on the low slots"), ), ("VpcExpose.nat", Reach::Spans(&["absent", "present"])), ( "VpcExposeNat.as_range", - Reach::Determined("one prefix in the masquerade pool, from peering, side and slot"), + Reach::Determined("one prefix in the translated pool, from peering, side and slot"), ), ( "VpcExposeNat.as_range.ports", - Reach::Fixed("unset, for the same reason as `VpcExpose.ips.ports`."), + Reach::Spans(&["set", "unset"]), ), ( "VpcExposeNat.not_as", - Reach::Fixed("empty, for the same reason as `VpcExpose.nots`."), + Reach::Determined("a `/26` in the middle of the expose's translated block"), ), ( "VpcExposeNat.config", - Reach::Fixed( - "masquerade. `Flavour` has two members and only one of them makes a nat, so static \ - nat and port forwarding are both unreachable -- which the design note already names \ - as missing vocabulary.", - ), - ), - ( - "VpcExposeNat.proto", - Reach::Fixed("`Any`. No operation narrows an expose to tcp or udp."), + Reach::Spans(&["masquerade", "port-forwarding", "static"]), ), + ("VpcExposeNat.proto", Reach::Spans(&["any", "tcp", "udp"])), ( "VpcExposeMasquerade.idle_timeout", - Reach::Fixed( - "absent. `make_masquerade(None)` is the only call, so the timeout paths -- and every \ - question about a flow ageing out under a configuration that set one -- are never \ - entered.", - ), - ), - ( - "VpcExposeStaticNat", - Reach::Fixed("never constructed; see `VpcExposeNat.config`."), + Reach::Spans(&["absent", "present"]), ), + ("VpcExposeStaticNat", Reach::Spans(&["constructed"])), ( "VpcExposePortForwarding.idle_timeout", - Reach::Fixed("never constructed; see `VpcExposeNat.config`."), + Reach::Spans(&["absent", "present"]), ), ]; @@ -217,6 +220,9 @@ fn survey(overlay: &Overlay, seen: &mut Observed) { "VpcPeering.acl", if acl.is_some() { "present" } else { "absent" }, ); + if let Some(acl) = acl { + survey_acl(acl, seen); + } for (side, manifest) in [("VpcPeering.left", left), ("VpcPeering.right", right)] { seen.note(side, manifest.name.clone()); survey_manifest(manifest, seen); @@ -224,6 +230,63 @@ fn survey(overlay: &Overlay, seen: &mut Observed) { } } +fn survey_acl(acl: &Acl, seen: &mut Observed) { + let Acl { default, rules } = acl; + seen.note("Acl.default", action(*default)); + seen.count("Acl.rules", rules.len()); + for rule in rules { + let AclRule { + name, + from, + to, + action: verdict, + pattern, + scope, + log, + } = rule; + seen.note("AclRule.name", name.clone()); + seen.note("AclRule.from", from.clone()); + seen.note("AclRule.to", to.clone()); + seen.note("AclRule.action", action(*verdict)); + seen.note( + "AclRule.scope", + match scope { + AclScope::Flow => "flow", + AclScope::Packet => "packet", + }, + ); + seen.note("AclRule.log", log.to_string()); + + let AclPattern { + src, + dst, + src_any_ports, + dst_any_ports, + proto, + } = pattern; + seen.prefixes("AclPattern.src", src); + seen.prefixes("AclPattern.dst", dst); + seen.count("AclPattern.src_any_ports", src_any_ports.len()); + seen.count("AclPattern.dst_any_ports", dst_any_ports.len()); + seen.note( + "AclPattern.proto", + match proto { + AclProtoMatch::Tcp => "tcp".to_owned(), + AclProtoMatch::Udp => "udp".to_owned(), + AclProtoMatch::Other(number) => format!("other({number})"), + AclProtoMatch::Any => "any".to_owned(), + }, + ); + } +} + +fn action(action: AclAction) -> &'static str { + match action { + AclAction::Allow => "allow", + AclAction::Deny => "deny", + } +} + fn survey_manifest(manifest: &VpcManifest, seen: &mut Observed) { let VpcManifest { name, exposes } = manifest; seen.note("VpcManifest.name", name.clone()); diff --git a/config/src/external/overlay/vpcpeering.rs b/config/src/external/overlay/vpcpeering.rs index 8d363339c0..40d5edd00b 100644 --- a/config/src/external/overlay/vpcpeering.rs +++ b/config/src/external/overlay/vpcpeering.rs @@ -1502,19 +1502,30 @@ pub mod contract { _ => "3.3.3.0/24", }; + let remote = vec![ + VpcExpose::empty().ip(remote_prefix + .parse::() + .unwrap_or_else(|_| unreachable!()) + .into()), + ]; + + overlay_between(exposes, remote) + } + + pub fn overlay_between( + local: Vec, + remote: Vec, + ) -> Result { let mut vpc_table = VpcTable::new(); vpc_table.add(Vpc::new("VPC-1", "AAAAA", LOCAL_VNI)?)?; vpc_table.add(Vpc::new("VPC-2", "BBBBB", REMOTE_VNI)?)?; - let local = exposes + let local = local .into_iter() .fold(VpcManifest::new("VPC-1"), VpcManifest::exposing); - let remote = VpcManifest::new("VPC-2").exposing( - VpcExpose::empty().ip(remote_prefix - .parse::() - .unwrap_or_else(|_| unreachable!()) - .into()), - ); + let remote = remote + .into_iter() + .fold(VpcManifest::new("VPC-2"), VpcManifest::exposing); let mut peerings = VpcPeeringTable::new(); peerings.add(VpcPeering::with_default_group( "VPC-1--VPC-2", diff --git a/dataplane/src/packet_processor/fuzz.rs b/dataplane/src/packet_processor/fuzz.rs index 7b9bac5c7f..f5090b877e 100644 --- a/dataplane/src/packet_processor/fuzz.rs +++ b/dataplane/src/packet_processor/fuzz.rs @@ -400,6 +400,10 @@ impl Fabric { &mut self.worker } + pub(crate) fn fleet(&self) -> &Fleet { + &self.fleet + } + pub(crate) fn flows(&self) -> Option { self.fleet.blueprint().flow_table.len() } @@ -687,8 +691,10 @@ pub(crate) mod derive { use super::routed::{Blast, Conversation, Inbound}; use super::*; use config::external::overlay::ValidatedOverlay; + use config::external::overlay::algebra::{Draft, Guard}; use config::external::overlay::vpcpeering::ValidatedExpose; - use lpm::prefix::{Prefix, PrefixWithOptionalPorts}; + use lpm::prefix::with_ports::L4Protocol; + use lpm::prefix::{Prefix, PrefixPortsSet, PrefixWithOptionalPorts}; #[derive(Debug, Clone, Copy)] pub(crate) struct Vary { @@ -736,13 +742,30 @@ pub(crate) mod derive { peering: &config::external::overlay::vpc::ValidatedPeering, n: u8, usable: fn(&ValidatedExpose) -> bool, + ) -> Option { + peer_address(peering, n, usable, ValidatedExpose::public_ips) + } + + fn peer_source_of( + peering: &config::external::overlay::vpc::ValidatedPeering, + n: u8, + usable: fn(&ValidatedExpose) -> bool, + ) -> Option { + peer_address(peering, n, usable, ValidatedExpose::ips) + } + + fn peer_address( + peering: &config::external::overlay::vpc::ValidatedPeering, + n: u8, + usable: fn(&ValidatedExpose) -> bool, + which: for<'a> fn(&'a ValidatedExpose) -> &'a PrefixPortsSet, ) -> Option { peering .remote() .valexp() .iter() .filter(|expose| usable(expose)) - .flat_map(|expose| expose.public_ips().into_iter()) + .flat_map(|expose| which(expose).into_iter()) .find_map(|entry| host_in(entry.prefix(), n)) } @@ -750,11 +773,79 @@ pub(crate) mod derive { loads_where(overlay, vary, &|_| true) } + pub(crate) fn carried_by(draft: &Draft) -> impl Fn(Named<'_>) -> bool + '_ { + move |named| draft.carries(named.peering, named.local, named.nth) + } + + #[derive(Debug)] + pub(crate) struct Probe { + path: super::routed::Path, + from: IpAddr, + at: IpAddr, + sport: u16, + dport: u16, + } + + impl Probe { + pub(crate) fn packet(&self) -> Option> { + let inner = super::round_trip::udp(self.from, self.at, self.sport, self.dport)?; + Some(super::routed::tunnelled_from(self.path.from(), &inner)) + } + } + + pub(crate) fn probes_for( + overlay: &ValidatedOverlay, + vary: &[Vary], + draft: &Draft, + ) -> Vec { + let mut probes = Vec::new(); + let mut nth = 0usize; + for vpc in overlay.vpc_table().values() { + for peering in vpc.peerings() { + for (which, _) in peering.local().valexp().iter().enumerate() { + let Some(v) = vary.get(nth % vary.len().max(1)).copied() else { + continue; + }; + nth += 1; + let Some(at) = draft.unexposed_address(peering.name(), vpc.name(), which) + else { + continue; + }; + let Some(from) = + peer_source_of(peering, v.host, ValidatedExpose::can_init_connection) + else { + continue; + }; + if from.is_ipv4() != at.is_ipv4() { + continue; + } + probes.push(Probe { + path: super::routed::Path::new(peering.remote_vni(), vpc.vni()), + from, + at, + sport: v.sport, + dport: v.dport, + }); + } + } + } + probes + } + + pub(crate) fn loads_carried( + overlay: &ValidatedOverlay, + vary: &[Vary], + draft: &Draft, + ) -> Vec> { + loads_where(overlay, vary, &carried_by(draft)) + } + #[derive(Debug, Clone, Copy)] pub(crate) struct Named<'a> { pub(crate) local: &'a str, pub(crate) remote: &'a str, pub(crate) peering: &'a str, + pub(crate) nth: usize, } pub(crate) fn loads_where( @@ -766,23 +857,32 @@ pub(crate) mod derive { let mut nth = 0usize; for vpc in overlay.vpc_table().values() { for peering in vpc.peerings() { - if !keep(Named { - local: vpc.name(), - remote: peering.remote().name(), - peering: peering.name(), - }) { - continue; - } let path = super::routed::Path::new(vpc.vni(), peering.remote_vni()); - for expose in peering.local().valexp() { + for (which, expose) in peering.local().valexp().iter().enumerate() { let Some(v) = vary.get(nth % vary.len().max(1)).copied() else { continue; }; nth += 1; + if !keep(Named { + local: vpc.name(), + remote: peering.remote().name(), + peering: peering.name(), + nth: which, + }) { + continue; + } let outward = peer_of(peering, v.host, |expose| { expose.can_receive_connection() && !expose.has_port_forwarding() }); - let inward = peer_of(peering, v.host, ValidatedExpose::can_init_connection); + let inward = + peer_source_of(peering, v.host, ValidatedExpose::can_init_connection); + + if expose + .nat_proto() + .is_some_and(|proto| proto == L4Protocol::Tcp) + { + continue; + } if expose.has_port_forwarding() { let (Some(outside), Some(inside_entry)) = ( @@ -1605,9 +1705,11 @@ mod acl { use bolero::{Driver, TypeGenerator, ValueGenerator}; use concurrency::sync::LazyLock; use concurrency::sync::atomic::{AtomicU64, Ordering}; - use config::external::overlay::acl::{AclAction, AclProtoMatch}; + use config::external::overlay::acl::{ + Acl, AclAction, AclPattern, AclProtoMatch, AclRule, AclScope, + }; use config::external::overlay::vpcpeering::contract::{MasqueradeExposes, peering_acl}; - use lpm::prefix::{Prefix, PrefixWithOptionalPorts}; + use lpm::prefix::{PortRange, Prefix, PrefixPortsSet, PrefixWithOptionalPorts}; use net::headers::builder::ChainBase; use net::headers::{Headers, TryIpv4Mut, TryIpv6Mut}; use net::ip::NextHeader; @@ -1895,6 +1997,111 @@ mod acl { "no packet was ever sent behind an extension header, which is the shape this exists for", ); } + + fn prefix(text: &str) -> Prefix { + text.parse() + .unwrap_or_else(|_| unreachable!("a well-formed prefix")) + } + + fn ports(start: u16, end: u16) -> PortRange { + PortRange::new(start, end).unwrap_or_else(|_| unreachable!("a well-formed port range")) + } + + fn forwarding() -> Vec { + vec![ + VpcExpose::empty() + .make_port_forwarding(None, None) + .unwrap_or_else(|_| unreachable!("an empty expose accepts port forwarding")) + .ip(PrefixWithOptionalPorts::new( + prefix("10.0.0.0/24"), + Some(ports(1000, 1004)), + )) + .as_range(PrefixWithOptionalPorts::new( + prefix("172.16.0.0/24"), + Some(ports(2000, 2004)), + )) + .unwrap_or_else(|_| unreachable!("a port forwarding expose accepts a range")), + ] + } + + fn flow_scoped_permit() -> Acl { + Acl::new( + AclAction::Deny, + vec![AclRule { + name: "opened-from-outside".to_owned(), + from: "VPC-2".to_owned(), + to: "VPC-1".to_owned(), + action: AclAction::Allow, + pattern: AclPattern { + src: PrefixPortsSet::new(), + dst: PrefixPortsSet::new(), + src_any_ports: Vec::new(), + dst_any_ports: Vec::new(), + proto: AclProtoMatch::Any, + }, + scope: AclScope::Flow, + log: false, + }], + ) + } + + #[tokio::test] + #[dpdk::with_eal] + async fn a_port_forwarded_flow_loses_its_acl_permission_on_any_configuration_change() { + let acl = flow_scoped_permit(); + let overlay = overlay_with_exposes_and_acl(forwarding(), Some(&acl)) + .expect("the fixture assembles") + .validate() + .expect("a port-forwarding side accepts a flow-scoped rule"); + let mut fabric = Fabric::over(&overlay, None, Arc::new(FlowTable::default())); + + let advertised: IpAddr = "172.16.0.5".parse().unwrap_or_else(|_| unreachable!()); + let outside = peer(advertised); + + let mut request = super::round_trip::udp(outside, advertised, 40000, 2003) + .expect("a well-formed request"); + arrive(&mut request, remote()); + let arrived = fabric.send(request); + let Verdict::Forwarded { + dst: Some(inside), .. + } = verdict(&arrived) + else { + panic!( + "the request never reached the service: {:?}", + verdict(&arrived) + ); + }; + let inside_port = arrived + .transport_dst_port() + .expect("a forwarded request has a destination port"); + + let answer = |fabric: &mut Fabric| { + let mut answer = super::round_trip::udp(inside, outside, inside_port.get(), 40000) + .expect("a well-formed answer"); + arrive(&mut answer, local()); + verdict(&fabric.send(answer)) + }; + + let before = answer(&mut fabric); + assert!( + matches!(before, Verdict::Forwarded { .. }), + "the flow did not authorise the answer even before anything changed: {before:?}. \ + Either the reverse lookup in `AclFilter::lookup` has stopped working or this fixture \ + no longer opens a flow" + ); + + fabric.fleet().enact(&overlay, Enact::Everything); + + let after = answer(&mut fabric); + assert_eq!( + after, + Verdict::Dropped(DoneReason::AclDropped), + "a port-forwarded flow kept its acl permission across a configuration change. If \ + `update_nat_allocator`'s generation upgrade now covers port-forwarded flows, this \ + test has served its purpose -- delete it, and stop excluding flow-scoped peerings \ + from `a_configuration_change_leaves_traffic_outside_its_footprint_alone`" + ); + } } #[cfg(test)] @@ -2440,17 +2647,87 @@ mod offers { #[cfg(test)] mod generated { - use super::derive::{Vary, loads_for}; + use super::derive::{Named, Vary, loads_where}; use super::*; use bolero::ValueGenerator; use concurrency::sync::LazyLock; use concurrency::sync::atomic::{AtomicU64, Ordering}; - use config::external::overlay::algebra::{Op, Sequence}; + use config::external::overlay::algebra::{Draft, Guard, Op, Sequence}; + use std::cell::Cell; use std::ops::Bound::Included; const SENDERS: usize = 6; const POLLS: usize = 8; + static CHECKED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static DERIVED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static MIXED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static PEERED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static MULTI: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static INBOUND: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static PERMITTING: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static BY_FLOW: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static EXCEPTING: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + + fn report_and_assert_coverage() { + let (checked, derived, mixed) = ( + CHECKED.load(Ordering::Relaxed), + DERIVED.load(Ordering::Relaxed), + MIXED.load(Ordering::Relaxed), + ); + let (peered, multi) = ( + PEERED.load(Ordering::Relaxed), + MULTI.load(Ordering::Relaxed), + ); + eprintln!( + "checked={checked} derived={derived} inbound={} \ + permitted-loads={} (by flow {}, past an exception {}) peered-configs={peered} \ + configs-past-two-vpcs={multi} mixed-bursts={mixed}", + INBOUND.load(Ordering::Relaxed), + PERMITTING.load(Ordering::Relaxed), + BY_FLOW.load(Ordering::Relaxed), + EXCEPTING.load(Ordering::Relaxed) + ); + super::assert_covered(peered > 0, "no generated configuration ever had a peering"); + super::assert_covered( + multi > 0, + "no generated configuration ever had more than two vpcs, so this reached nothing the \ + two-vpc fixtures do not", + ); + super::assert_covered( + derived > 0, + "no generated configuration ever implied any traffic", + ); + super::assert_covered(checked > 0, "no derived sender ever completed its business"); + super::assert_covered( + INBOUND.load(Ordering::Relaxed) > 0, + "no generated configuration ever produced an inbound load, so a port-forwarding \ + expose was drawn into configurations and then carried no traffic at all", + ); + super::assert_covered( + PERMITTING.load(Ordering::Relaxed) > 0, + "no traffic was ever derived across a peering whose acl permits it, so every load here \ + ran with no acl in the way and a rule set that lowered to nothing would have gone \ + unnoticed", + ); + super::assert_covered( + EXCEPTING.load(Ordering::Relaxed) > 0, + "no traffic was ever derived across a peering whose acl excepts one expose from an \ + otherwise permitting rule, so nothing here depended on a lookup returning the \ + *first* rule that matches", + ); + super::assert_covered( + BY_FLOW.load(Ordering::Relaxed) > 0, + "no traffic was ever derived across a peering permitting only one direction, so no \ + reply here was authorised by the flow it belongs to and the reverse lookup in \ + `AclFilter::lookup` was never entered", + ); + super::assert_covered( + mixed > 0, + "no burst ever carried more than one sender's traffic, so nothing was interleaved", + ); + } + pub(super) struct Generated; impl ValueGenerator for Generated { @@ -2495,15 +2772,31 @@ mod generated { super::assert_within_budget("generated::Generated", &Generated); } + fn carried_counting<'a>( + draft: &'a Draft, + permitting: &'a Cell, + by_flow: &'a Cell, + excepting: &'a Cell, + ) -> impl Fn(Named<'_>) -> bool + 'a { + let carried = super::derive::carried_by(draft); + move |named| { + let counter = match draft.guard_named(named.peering) { + Some(Guard::Permit) => permitting, + Some(Guard::PermitFlow) => by_flow, + Some(Guard::PermitExcept | Guard::PermitByProtocol) => excepting, + Some(Guard::Open | Guard::Deny) | None => return carried(named), + }; + let kept = carried(named); + if kept { + counter.set(counter.get() + 1); + } + kept + } + } + #[tokio::test] #[dpdk::with_eal] async fn a_generated_configuration_carries_its_own_traffic() { - static CHECKED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); - static DERIVED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); - static MIXED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); - static PEERED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); - static MULTI: LazyLock = LazyLock::new(|| AtomicU64::new(0)); - bolero::check!() .with_max_len(MAX_INPUT_LEN) .with_generator(Generated) @@ -2533,8 +2826,21 @@ mod generated { let mut fabric = Fabric::routed_over_validated(&validated, topology(&vnis)); - let mut loads = loads_for(&validated, vary); + let (permitting, by_flow, excepting) = (Cell::new(0), Cell::new(0), Cell::new(0)); + let mut loads = loads_where( + &validated, + vary, + &carried_counting(&draft, &permitting, &by_flow, &excepting), + ); + PERMITTING.fetch_add(permitting.get(), Ordering::Relaxed); + BY_FLOW.fetch_add(by_flow.get(), Ordering::Relaxed); + EXCEPTING.fetch_add(excepting.get(), Ordering::Relaxed); DERIVED.fetch_add(loads.len() as u64, Ordering::Relaxed); + for load in &loads { + if load.describe().starts_with("[inbound") { + INBOUND.fetch_add(1, Ordering::Relaxed); + } + } for burst in run_schedule(fabric.worker(), &mut loads, schedule) { let mut seen = burst.clone(); @@ -2555,33 +2861,159 @@ mod generated { } }); - let (checked, derived, mixed) = ( - CHECKED.load(Ordering::Relaxed), - DERIVED.load(Ordering::Relaxed), - MIXED.load(Ordering::Relaxed), - ); - let (peered, multi) = ( - PEERED.load(Ordering::Relaxed), - MULTI.load(Ordering::Relaxed), + report_and_assert_coverage(); + } + + #[tokio::test] + #[dpdk::with_eal] + async fn a_configuration_carries_nothing_it_denies() { + static SENT: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static BY_ACL: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static NARROWED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static CONFIGS: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + + bolero::check!() + .with_max_len(MAX_INPUT_LEN) + .with_generator(Generated) + .for_each(|(ops, vary, _schedule)| { + let draft = Sequence::fold(ops); + let validated = draft + .overlay() + .unwrap_or_else(|e| panic!("{ops:?} does not assemble: {e}")) + .validate() + .unwrap_or_else(|e| panic!("{ops:?} does not validate: {e}")); + + let vnis: Vec = validated + .vpc_table() + .values() + .map(config::external::overlay::vpc::ValidatedVpc::vni) + .collect(); + if vnis.is_empty() { + return; + } + let carried = super::derive::carried_by(&draft); + let narrowed = Cell::new(0); + let mut loads = loads_where(&validated, vary, &|named| { + if carried(named) { + return false; + } + if draft.guard_named(named.peering) != Some(Guard::Deny) { + narrowed.set(narrowed.get() + 1); + } + true + }); + if loads.is_empty() { + return; + } + NARROWED.fetch_add(narrowed.get(), Ordering::Relaxed); + CONFIGS.fetch_add(1, Ordering::Relaxed); + + let mut fabric = Fabric::routed_over_validated(&validated, topology(&vnis)); + for load in &mut loads { + let Some(packet) = load.next() else { + continue; + }; + let seen = verdict(&fabric.worker().send(packet)); + SENT.fetch_add(1, Ordering::Relaxed); + assert!( + matches!(seen, Verdict::Dropped(_)), + "an acl that refuses this traffic produced {seen:?} for {}", + load.describe() + ); + if seen == Verdict::Dropped(DoneReason::AclDropped) { + BY_ACL.fetch_add(1, Ordering::Relaxed); + } + } + }); + + let (configs, sent, by_acl) = ( + CONFIGS.load(Ordering::Relaxed), + SENT.load(Ordering::Relaxed), + BY_ACL.load(Ordering::Relaxed), ); eprintln!( - "checked={checked} derived={derived} peered-configs={peered} \ - configs-past-two-vpcs={multi} mixed-bursts={mixed}" + "refusing-configs={configs} sent={sent} (dropped by the acl {by_acl}, \ + refused by a narrowed rule {})", + NARROWED.load(Ordering::Relaxed) ); - super::assert_covered(peered > 0, "no generated configuration ever had a peering"); super::assert_covered( - multi > 0, - "no generated configuration ever had more than two vpcs, so this reached nothing the \ - two-vpc fixtures do not", + sent > 0, + "no refused traffic was ever derived, so this asserted nothing about any packet", ); super::assert_covered( - derived > 0, - "no generated configuration ever implied any traffic", + NARROWED.load(Ordering::Relaxed) > 0, + "every refusal came from a peering denied outright, so nothing here was refused by a \ + rule naming part of a peering and the first-match order was not under test", ); - super::assert_covered(checked > 0, "no derived sender ever completed its business"); super::assert_covered( - mixed > 0, - "no burst ever carried more than one sender's traffic, so nothing was interleaved", + by_acl > 0, + "no packet was ever dropped by the acl: the claim is being satisfied by stages ahead \ + of it and would hold with the acl removed", + ); + } + + #[tokio::test] + #[dpdk::with_eal] + async fn an_excluded_address_is_not_reachable() { + static AIMED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + static REFUSED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + + bolero::check!() + .with_max_len(MAX_INPUT_LEN) + .with_generator(Generated) + .for_each(|(ops, vary, _schedule)| { + let draft = Sequence::fold(ops); + let validated = draft + .overlay() + .unwrap_or_else(|e| panic!("{ops:?} does not assemble: {e}")) + .validate() + .unwrap_or_else(|e| panic!("{ops:?} does not validate: {e}")); + + let vnis: Vec = validated + .vpc_table() + .values() + .map(config::external::overlay::vpc::ValidatedVpc::vni) + .collect(); + if vnis.is_empty() { + return; + } + let probes = super::derive::probes_for(&validated, vary, &draft); + if probes.is_empty() { + return; + } + + let mut fabric = Fabric::routed_over_validated(&validated, topology(&vnis)); + for probe in probes { + let Some(packet) = probe.packet() else { + continue; + }; + let seen = verdict(&fabric.worker().send(packet)); + AIMED.fetch_add(1, Ordering::Relaxed); + assert!( + matches!(seen, Verdict::Dropped(_)), + "an address the configuration excludes was reached: {seen:?} for {probe:?}" + ); + if seen == Verdict::Dropped(DoneReason::Filtered) { + REFUSED.fetch_add(1, Ordering::Relaxed); + } + } + }); + + let (aimed, refused) = ( + AIMED.load(Ordering::Relaxed), + REFUSED.load(Ordering::Relaxed), + ); + eprintln!("excluded-addresses-aimed-at={aimed} (refused as unplaceable {refused})"); + super::assert_covered( + aimed > 0, + "no packet was ever aimed at an excluded address, so this asserted nothing -- either \ + no generated expose carves a slice out of its advertised range, or none of those was \ + reachable from a peer", + ); + super::assert_covered( + refused > 0, + "every excluded address was refused for some reason other than being unplaceable, so \ + the exclusion may not be what refused any of them", ); } } @@ -2713,6 +3145,107 @@ mod burst { super::assert_covered(checked > 0, "no burst of a single flow was ever delivered"); } + #[tokio::test] + #[dpdk::with_eal] + async fn a_burst_of_one_translated_flow_allocates_once() { + static CHECKED: LazyLock = LazyLock::new(|| AtomicU64::new(0)); + + fn two_sided() -> Option { + let local = VpcExpose::empty() + .make_masquerade(None) + .ok()? + .ip("1.1.0.0/16".parse::().ok()?.into()) + .as_range("2.2.0.0/16".parse::().ok()?.into()) + .ok()?; + let remote = VpcExpose::empty() + .make_static_nat() + .ok()? + .ip("3.3.0.0/16".parse::().ok()?.into()) + .as_range("4.4.0.0/16".parse::().ok()?.into()) + .ok()?; + config::external::overlay::vpcpeering::contract::overlay_between( + vec![local], + vec![remote], + ) + .ok() + } + + let overlay = two_sided().expect("a valid two-sided configuration"); + + bolero::check!() + .with_max_len(MAX_INPUT_LEN) + .with_generator(Burst) + .for_each(|members| { + let m = members[0]; + let src: IpAddr = format!("1.1.0.{}", m.host) + .parse() + .unwrap_or_else(|_| unreachable!()); + let dst: IpAddr = "4.4.0.1".parse().unwrap_or_else(|_| unreachable!()); + let packet = || udp(src, dst, 4000, m.dport).map(|p| tunnelled(&p)); + + let tables = || topology(&[vni(LOCAL_VNI), vni(REMOTE_VNI)]); + let (Some(mut alone), Some(mut burst)) = ( + Fabric::routed_over(&overlay, tables()), + Fabric::routed_over(&overlay, tables()), + ) else { + return; + }; + let Some(one) = packet() else { return }; + let single = treatment(&alone.send(one)); + if !matches!(single.verdict, Verdict::Delivered { .. }) { + return; + } + let cost_of_one = alone.flows(); + + assert_eq!( + single.inner_dst, + Some("3.3.0.1".parse().unwrap_or_else(|_| unreachable!())), + "the far side's static nat did not translate the destination, so this \ + configuration does not reach the case this test is for" + ); + + let Some(together) = (0..BURST).map(|_| packet()).collect::>>() + else { + return; + }; + let out = burst.send_batch(together); + + for (i, packet) in out.iter().enumerate() { + let t = treatment(packet); + assert_eq!( + t.inner_sport, single.inner_sport, + "packet {i} of a burst of one masqueraded-and-translated flow was given \ + a different public port from the same packet sent alone: the burst \ + allocated more than once" + ); + assert_eq!( + t.inner_src, single.inner_src, + "packet {i} of a burst of one masqueraded-and-translated flow left under \ + a different public address" + ); + assert_eq!( + t.verdict, single.verdict, + "packet {i} of a burst of one masqueraded-and-translated flow reached a \ + different verdict" + ); + } + assert_eq!( + burst.flows(), + cost_of_one, + "a burst of {BURST} packets of one masqueraded-and-translated flow cost more \ + flow-table entries than one packet of it did" + ); + CHECKED.fetch_add(1, Ordering::Relaxed); + }); + + let checked = CHECKED.load(Ordering::Relaxed); + eprintln!("translated-single-flow-bursts={checked}"); + super::assert_covered( + checked > 0, + "no burst of a single masqueraded-and-translated flow was ever delivered", + ); + } + #[tokio::test] #[dpdk::with_eal] async fn a_burst_is_treated_the_same_as_one_packet_at_a_time() { @@ -2997,6 +3530,10 @@ mod routed { Self::new(vni(LOCAL_VNI), vni(REMOTE_VNI)) } + pub(crate) fn from(self) -> Vni { + self.from + } + fn reversed(self) -> Self { Self::new(self.to, self.from) } @@ -3234,7 +3771,10 @@ mod routed { enum State { Opening, AwaitingRequest, - Replying { public: (IpAddr, u16) }, + Replying { + public: (IpAddr, u16), + landed: (IpAddr, u16), + }, AwaitingReply, Closed, Abandoned, @@ -3294,10 +3834,24 @@ mod routed { self.state = State::Abandoned; return; }; + let (Some(landed_dst), Some(landed_port)) = + (carried.ip_destination(), carried.transport_dst_port()) + else { + self.note("the delivered request had no destination tuple to answer from"); + self.state = State::Abandoned; + return; + }; self.note(&format!("request left as {public_src}:{}", port.get())); + if landed_dst != self.dst { + self.note(&format!( + "request landed on {landed_dst}:{}", + landed_port.get() + )); + } self.public = Some((public_src, port.get())); self.state = State::Replying { public: (public_src, port.get()), + landed: (landed_dst, landed_port.get()), }; } @@ -3352,8 +3906,11 @@ mod routed { self.state = State::AwaitingRequest; Some(tunnelled_from(self.path.from, &request)) } - State::Replying { public: (ip, port) } => { - let reply = udp(self.dst, ip, self.dport, port)?; + State::Replying { + public: (ip, port), + landed: (from_ip, from_port), + } => { + let reply = udp(from_ip, ip, from_port, port)?; self.state = State::AwaitingReply; Some(tunnelled_from(self.path.reversed().from, &reply)) } @@ -3527,7 +4084,7 @@ mod routed { enum InboundState { Reaching, AwaitingArrival, - Answering, + Answering { reply_to: (IpAddr, u16) }, AwaitingAnswer, Closed, Abandoned, @@ -3575,8 +4132,17 @@ mod routed { "reached the right host on the wrong port. {}", self.describe() ); + let (Some(src), Some(sport)) = (arrived.ip_source(), arrived.transport_src_port()) + else { + self.log + .push("the arrived request had no source tuple to answer".to_owned()); + self.state = InboundState::Abandoned; + return; + }; self.log.push("arrived inside".to_owned()); - self.state = InboundState::Answering; + self.state = InboundState::Answering { + reply_to: (src, sport.get()), + }; } fn judge_answer(&mut self, got: &Packet) { @@ -3619,8 +4185,10 @@ mod routed { self.state = InboundState::AwaitingArrival; Some(tunnelled_from(self.path.to, &request)) } - InboundState::Answering => { - let answer = udp(self.internal, self.from, self.internal_port, self.sport)?; + InboundState::Answering { + reply_to: (to_ip, to_port), + } => { + let answer = udp(self.internal, to_ip, self.internal_port, to_port)?; self.state = InboundState::AwaitingAnswer; Some(tunnelled_from(self.path.from, &answer)) } @@ -3690,7 +4258,7 @@ mod routed { #[cfg(test)] mod model { - use super::derive::loads_for; + use super::derive::loads_carried; use super::routed::{Conversation, exposes, inner, inside, tunnelled}; use super::*; use concurrency::sync::Mutex; @@ -3699,7 +4267,7 @@ mod model { use concurrency::thread; #[cfg_attr(not(feature = "shuttle"), allow(unused_imports))] use concurrency::thread::BuilderExt; - use config::external::overlay::algebra::{Footprint, Sequence}; + use config::external::overlay::algebra::{Draft, Footprint, Guard, Sequence}; use net::packet::test_utils::build_test_udp_ipv4_packet; type Tuple = (Option, Option); @@ -3928,7 +4496,8 @@ mod model { .with_generator(generated::Generated) .with_iterations(CASES) .for_each(|(ops, vary, schedule)| { - let validated = Sequence::fold(ops) + let draft = Sequence::fold(ops); + let validated = draft .overlay() .unwrap_or_else(|e| panic!("{ops:?} does not assemble: {e}")) .validate() @@ -3939,18 +4508,23 @@ mod model { .values() .map(config::external::overlay::vpc::ValidatedVpc::vni) .collect(); - if vnis.is_empty() || loads_for(&validated, vary).len() < 2 { + if vnis.is_empty() || loads_carried(&validated, vary, &draft).len() < 2 { THIN.fetch_add(1, Ordering::Relaxed); return; } SPLIT.fetch_add(1, Ordering::Relaxed); - let drawn = - concurrency::sync::Arc::new((validated, vnis, vary.clone(), schedule.clone())); + let drawn = concurrency::sync::Arc::new(( + validated, + vnis, + vary.clone(), + schedule.clone(), + draft, + )); let entering = handle.clone(); concurrency::stress(move || { - let (validated, vnis, vary, schedule) = &*drawn; + let (validated, vnis, vary, schedule, draft) = &*drawn; let tables = topology(vnis); let fleet = Fleet::lowering(validated, Some(&tables), Arc::new(FlowTable::default())); @@ -3969,7 +4543,7 @@ mod model { tracectl::evidence::capture(format!("worker-{which}")); let mut worker = blueprint.worker(); let mut mine: Vec> = - loads_for(validated, vary) + loads_carried(validated, vary, draft) .into_iter() .enumerate() .filter(|(nth, _)| nth % 2 == which) @@ -4790,9 +5364,15 @@ mod model { within + u16::try_from(which).unwrap_or_else(|_| unreachable!()) * 32_768 } - fn outside(footprint: &Footprint) -> impl Fn(derive::Named<'_>) -> bool + '_ { + fn outside<'a>( + footprint: &'a Footprint, + draft: &'a Draft, + ) -> impl Fn(derive::Named<'_>) -> bool + 'a { + let carried = derive::carried_by(draft); move |named| { - !footprint.touches_peering_named(named.peering) + carried(named) + && draft.guard_named(named.peering) != Some(Guard::PermitFlow) + && !footprint.touches_peering_named(named.peering) && !footprint.touches_vpc_named(named.local) && !footprint.touches_vpc_named(named.remote) } @@ -4833,8 +5413,9 @@ mod model { let running = assemble(&before); let enacted = assemble(&Sequence::fold(ops)); - let framed = derive::loads_where(&running, vary, &outside(&footprint)).len(); - let total = derive::loads_for(&running, vary).len(); + let framed = + derive::loads_where(&running, vary, &outside(&footprint, &before)).len(); + let total = derive::loads_carried(&running, vary, &before).len(); FRAMED_OUT.fetch_add( u64::try_from(total - framed).unwrap_or_else(|_| unreachable!()), Ordering::Relaxed, @@ -4862,11 +5443,12 @@ mod model { vary.clone(), footprint, *change, + before, )); let entering = handle.clone(); concurrency::stress(move || { - let (running, enacted, vnis, vary, footprint, change) = &*drawn; + let (running, enacted, vnis, vary, footprint, change, before) = &*drawn; let tables = topology(vnis); let fleet = Fleet::lowering(running, Some(&tables), Arc::new(FlowTable::default())); @@ -4887,7 +5469,7 @@ mod model { let _evidence = tracectl::evidence::capture(format!("framed-{which}")); let mut worker = blueprint.worker(); - let outside = outside(footprint); + let outside = outside(footprint, before); gate.wait(); let mut seen = Vec::new(); for round in 1..=ROUNDS { diff --git a/dpdk-sys/build.rs b/dpdk-sys/build.rs index edf8e41a03..77618df278 100644 --- a/dpdk-sys/build.rs +++ b/dpdk-sys/build.rs @@ -76,6 +76,7 @@ fn main() { "rte_net_virtio", "rte_net_vhost", "rte_net_i40e", + "rte_net_e1000", "rte_vhost", "rte_net_mlx5", "rte_common_mlx5", diff --git a/hardware/src/nic/mod.rs b/hardware/src/nic/mod.rs index 69213f9bcc..9ccc8a4789 100644 --- a/hardware/src/nic/mod.rs +++ b/hardware/src/nic/mod.rs @@ -73,7 +73,14 @@ impl GetDriver for PciNic { fn driver(&self) -> Result, DriverErr> { let device_path = self.device_path().map_err(DriverErr::Sysfs)?; info!("found device {self} under device path {:?}", device_path); - let driver_path = device_path.relative("driver").map_err(DriverErr::Sysfs)?; + let driver_path = match device_path.relative("driver") { + Ok(p) => p, + Err(SysfsErr::IoError(e)) if e.kind() == ErrorKind::NotFound => { + info!("no driver symlink for {self} (no driver bound)"); + return Ok(None); + } + Err(e) => return Err(DriverErr::Sysfs(e)), + }; info!("{self} is using driver path {driver_path:?}"); match driver_path.inner().file_name() { Some(os_str) => match os_str.to_str() { @@ -99,6 +106,10 @@ impl std::fmt::Display for PciNic { /// Enum describing supported PCI drivers. #[derive(Debug, Copy, Clone, PartialEq, Eq, strum::EnumString, strum::IntoStaticStr)] pub enum PciDriver { + #[strum(serialize = "e1000")] + E1000, + #[strum(serialize = "e1000e")] + E1000E, /// Intel's i40e driver. #[strum(serialize = "i40e")] I40e, @@ -318,14 +329,7 @@ impl BindToVfioPci for PciNic { } } Ok(None) => { - let msg = format!( - "device {self} is unknown to the operating system. You may need to load (modprobe) a driver" - ); - error!("{msg}"); - return Err(DriverErr::Sysfs(SysfsErr::IoError(std::io::Error::new( - ErrorKind::Unsupported, - msg, - )))); + info!("device {self} has no driver bound; proceeding to vfio-pci bind"); } Err(err) => { error!("failed to get device driver: {:?}", err); diff --git a/nat/src/masquerade/nf.rs b/nat/src/masquerade/nf.rs index b7549bfdbe..57540d8d32 100644 --- a/nat/src/masquerade/nf.rs +++ b/nat/src/masquerade/nf.rs @@ -241,7 +241,14 @@ impl Masquerade { { return Some(xlate); } - let looked_up = self.flow_table.lookup(&FlowKey::try_from(packet).ok()?)?; + + let looked_up = FlowKey::try_from(packet) + .ok() + .and_then(|current| self.flow_table.lookup(¤t)) + .or_else(|| { + let initial = packet.meta().flow_key.as_deref().copied()?; + self.flow_table.lookup(&initial) + })?; Self::masquerade_state_of(packet, &looked_up) }