diff --git a/lib/proxy/control.rs b/lib/proxy/control.rs index 219e496..0ff5489 100644 --- a/lib/proxy/control.rs +++ b/lib/proxy/control.rs @@ -106,7 +106,7 @@ fn parse_rules(rules: Vec) -> std::result::Result, ErrorObject let parsed_rule = rule.parse().map_err(|_| { rpc_error( INVALID_PARAMS, - format!("invalid rule {rule:?}: expected TARGET, \"in TARGET\", or \"out TARGET\""), + format!("invalid rule {rule:?}: expected TARGET[:PORTS], \"in TARGET[:PORTS]\", or \"out TARGET[:PORTS]\""), ) })?; parsed.push(parsed_rule); @@ -645,13 +645,13 @@ mod tests { assert_eq!( policy .rules - .policy_decision(Ipv4Address::new(10, 0, 0, 1), Direction::Out), + .policy_decision(Ipv4Address::new(10, 0, 0, 1), None, Direction::Out), Some(PolicyDecision::Block) ); assert_eq!( policy .rules - .policy_decision(Ipv4Address::new(192, 168, 64, 1), Direction::Out), + .policy_decision(Ipv4Address::new(192, 168, 64, 1), None, Direction::Out), Some(PolicyDecision::Block) ); } @@ -707,11 +707,11 @@ mod tests { assert_eq!(response["result"]["ruleCount"], 2); let target = Ipv4Address::new(10, 1, 2, 3); assert_eq!( - policy.rules.policy_decision(target, Direction::In), + policy.rules.policy_decision(target, None, Direction::In), Some(PolicyDecision::Block) ); assert_eq!( - policy.rules.policy_decision(target, Direction::Out), + policy.rules.policy_decision(target, None, Direction::Out), Some(PolicyDecision::AllowStateful) ); } diff --git a/lib/proxy/host.rs b/lib/proxy/host.rs index 7c6e1f6..5e70e44 100644 --- a/lib/proxy/host.rs +++ b/lib/proxy/host.rs @@ -1,5 +1,5 @@ use crate::proxy::flows::{FlowDirection, FlowMatch}; -use crate::proxy::{Direction, PolicyDecision, Proxy}; +use crate::proxy::{Direction, PolicyDecision, Proxy, transport_ports}; use anyhow::{Context, Result}; use dhcproto::v4::{CLIENT_PORT, SERVER_PORT}; use smoltcp::phy::ChecksumCapabilities; @@ -84,9 +84,11 @@ impl Proxy<'_> { }; // The flow is either pending or untracked, evaluate it against inbound policy + let src_port = transport_ports(ipv4_pkt).map(|(src_port, _)| src_port); + match self .rules - .policy_decision(ipv4_pkt.src_addr(), Direction::In) + .policy_decision(ipv4_pkt.src_addr(), src_port, Direction::In) { // Return traffic was handled above; enforce explicit inbound blocks here Some(PolicyDecision::Block) => None, @@ -99,9 +101,12 @@ impl Proxy<'_> { // No inbound rule matched, so allow by default. Track the flow when needed // so its reply is not treated as a new outbound flow - None => { - self.admit_with_tracking_if_stateful(pending, ipv4_pkt.src_addr(), Direction::Out) - } + None => self.admit_with_tracking_if_stateful( + pending, + ipv4_pkt.src_addr(), + src_port, + Direction::Out, + ), } } } diff --git a/lib/proxy/mod.rs b/lib/proxy/mod.rs index 62c2d30..fb9b3e3 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -17,9 +17,9 @@ pub use exposed_port::ExposedPort; use flows::{FlowTable, PendingFlow}; use ipnet::Ipv4Net; use mac_address::MacAddress; -pub use rule::{Direction, Rule, Target}; +pub use rule::{Direction, Ports, Rule, Target}; pub(crate) use rules::{PolicyDecision, Rules}; -use smoltcp::wire::{EthernetAddress, EthernetFrame, Ipv4Address}; +use smoltcp::wire::{EthernetAddress, EthernetFrame, IpProtocol, Ipv4Address, Ipv4Packet}; use std::io::ErrorKind; use std::os::unix::io::{AsRawFd, RawFd}; use std::time::Duration; @@ -60,7 +60,7 @@ impl Proxy<'_> { let mut host = Host::new( vm_net_type, vm_mac_address, - !allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()))), + !allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()), None)), )?; let poller_timeout = Duration::from_millis(100); let control = control_fd @@ -264,9 +264,13 @@ impl Proxy<'_> { &mut self, pending: Option, peer_addr: Ipv4Address, + peer_port: Option, return_direction: Direction, ) -> Option<()> { - if self.rules.is_stateful(peer_addr, return_direction) { + if self + .rules + .is_stateful(peer_addr, peer_port, return_direction) + { self.admit_with_tracking_if_trackable(pending) } else { Some(()) @@ -274,10 +278,28 @@ impl Proxy<'_> { } } +/// Returns the TCP or UDP source and destination ports, which only the first fragment carries. +pub(crate) fn transport_ports(ipv4_pkt: &Ipv4Packet<&[u8]>) -> Option<(u16, u16)> { + if !matches!(ipv4_pkt.next_header(), IpProtocol::Tcp | IpProtocol::Udp) + || ipv4_pkt.frag_offset() != 0 + { + return None; + } + + // TCP and UDP both begin with the source port, then the destination port + match ipv4_pkt.payload() { + [src_hi, src_lo, dst_hi, dst_lo, ..] => Some(( + u16::from_be_bytes([*src_hi, *src_lo]), + u16::from_be_bytes([*dst_hi, *dst_lo]), + )), + _ => None, + } +} + #[cfg(test)] mod tests { use crate::NetType; - use crate::proxy::Proxy; + use crate::proxy::{Proxy, transport_ports}; use mac_address::MacAddress; use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; use serial_test::serial; @@ -382,6 +404,31 @@ mod tests { assert!(proxy.allowed_from_host_ipv4(&reply).is_some()); } + #[test] + fn test_transport_ports_only_in_first_fragment() { + let src = Ipv4Address::new(10, 0, 0, 2); + let dst = Ipv4Address::new(192, 168, 0, 10); + let mut bytes = udp_packet(src, 50000, dst, 8444); + + assert_eq!( + transport_ports(&Ipv4Packet::new_unchecked(bytes.as_slice())), + Some((50000, 8444)) + ); + + Ipv4Packet::new_unchecked(bytes.as_mut_slice()).set_frag_offset(8); + assert_eq!( + transport_ports(&Ipv4Packet::new_unchecked(bytes.as_slice())), + None + ); + + let mut bytes = udp_packet(src, 50000, dst, 8444); + Ipv4Packet::new_unchecked(bytes.as_mut_slice()).set_next_header(IpProtocol::Icmp); + assert_eq!( + transport_ports(&Ipv4Packet::new_unchecked(bytes.as_slice())), + None + ); + } + fn create_proxy<'test>(allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> { let (vm_fd, _) = socketpair( AddressFamily::Unix, diff --git a/lib/proxy/rule.rs b/lib/proxy/rule.rs index 1ba0ebd..305176f 100644 --- a/lib/proxy/rule.rs +++ b/lib/proxy/rule.rs @@ -4,10 +4,11 @@ use std::str::FromStr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Rule { - Stateless(Target), + Stateless(Target, Option), Stateful { direction: Direction, target: Target, + ports: Option, }, } @@ -17,50 +18,104 @@ pub enum Target { Host, } +/// An inclusive range of TCP and UDP ports on the target's side of a flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ports { + first: u16, + last: u16, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { In, Out, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseRuleError; + +impl Display for ParseRuleError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid rule") + } +} + +impl std::error::Error for ParseRuleError {} + +impl From for ParseRuleError { + fn from(_: ipnet::AddrParseError) -> Self { + ParseRuleError + } +} + impl Rule { pub(super) fn normalized(self) -> Self { match self { - Rule::Stateless(target) => Rule::Stateless(target.normalized()), - Rule::Stateful { direction, target } => Rule::Stateful { + Rule::Stateless(target, ports) => Rule::Stateless(target.normalized(), ports), + Rule::Stateful { + direction, + target, + ports, + } => Rule::Stateful { direction, target: target.normalized(), + ports, }, } } } impl FromStr for Rule { - type Err = ipnet::AddrParseError; + type Err = ParseRuleError; fn from_str(input: &str) -> Result { - let (direction, target) = if let Some(target) = input.strip_prefix("in ") { - (Direction::In, target) - } else if let Some(target) = input.strip_prefix("out ") { - (Direction::Out, target) + let (direction, destination) = if let Some(destination) = input.strip_prefix("in ") { + (Some(Direction::In), destination.trim_start_matches(' ')) + } else if let Some(destination) = input.strip_prefix("out ") { + (Some(Direction::Out), destination.trim_start_matches(' ')) } else { - return input.parse().map(Rule::Stateless); + (None, input) + }; + + // Neither IPv4 CIDRs nor @host contain a colon + let (target, ports) = match destination.split_once(':') { + Some((target, ports)) => (target.parse()?, Some(ports.parse()?)), + None => (destination.parse()?, None), }; - let target = target.trim_start_matches(' ').parse()?; - Ok(Rule::Stateful { direction, target }) + Ok(match direction { + None => Rule::Stateless(target, ports), + Some(direction) => Rule::Stateful { + direction, + target, + ports, + }, + }) } } impl Display for Rule { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Rule::Stateless(target) => Display::fmt(target, formatter), - Rule::Stateful { direction, target } => match direction { - Direction::In => write!(formatter, "in {target}"), - Direction::Out => write!(formatter, "out {target}"), - }, + let (direction, target, ports) = match self { + Rule::Stateless(target, ports) => (None, target, ports), + Rule::Stateful { + direction, + target, + ports, + } => (Some(direction), target, ports), + }; + + match direction { + Some(Direction::In) => formatter.write_str("in ")?, + Some(Direction::Out) => formatter.write_str("out ")?, + None => {} + } + Display::fmt(target, formatter)?; + if let Some(ports) = ports { + write!(formatter, ":{ports}")?; } + + Ok(()) } } @@ -94,9 +149,55 @@ impl Display for Target { } } +impl Ports { + pub(crate) fn contains(self, port: u16) -> bool { + self.first <= port && port <= self.last + } +} + +impl FromStr for Ports { + type Err = ParseRuleError; + + fn from_str(input: &str) -> Result { + fn port(input: &str) -> Result { + if input.is_empty() || !input.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ParseRuleError); + } + match input.parse::() { + Ok(0) | Err(_) => Err(ParseRuleError), + Ok(port) => Ok(port), + } + } + + let (first, last) = match input.split_once('-') { + Some((first, last)) => (port(first)?, port(last)?), + None => { + let port = port(input)?; + (port, port) + } + }; + + if first > last { + return Err(ParseRuleError); + } + + Ok(Ports { first, last }) + } +} + +impl Display for Ports { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + if self.first == self.last { + write!(formatter, "{}", self.first) + } else { + write!(formatter, "{}-{}", self.first, self.last) + } + } +} + #[cfg(test)] mod tests { - use super::{Direction, Rule, Target}; + use super::{Direction, Ports, Rule, Target}; use ipnet::Ipv4Net; use std::str::FromStr; @@ -104,7 +205,7 @@ mod tests { fn parses_stateless_target() { assert_eq!( "@host".parse::().unwrap(), - Rule::Stateless(Target::Host) + Rule::Stateless(Target::Host, None) ); } @@ -117,6 +218,7 @@ mod tests { Rule::Stateful { direction: Direction::In, target: Target::Host, + ports: None, } ); assert_eq!( @@ -124,13 +226,23 @@ mod tests { Rule::Stateful { direction: Direction::Out, target: private_network, + ports: None, } ); } #[test] fn displays_normalized_rules() { - for (input, expected) in [("in 10.1.2.3/8", "in 10.0.0.0/8"), ("@host", "@host")] { + for (input, expected) in [ + ("in 10.1.2.3/8", "in 10.0.0.0/8"), + ("@host", "@host"), + ("10.1.2.3/8:8444", "10.0.0.0/8:8444"), + ( + "out 192.168.0.10/32:8000-8100", + "out 192.168.0.10/32:8000-8100", + ), + ("@host:22-22", "@host:22"), + ] { assert_eq!( input.parse::().unwrap().normalized().to_string(), expected @@ -151,8 +263,49 @@ mod tests { " in @host", "in @host ", "in\t@host", + "10.0.0.0/8:", + "10.0.0.0/8:0", + "10.0.0.0/8:65536", + "10.0.0.0/8:+80", + "10.0.0.0/8: 80", + "10.0.0.0/8:80-", + "10.0.0.0/8:-80", + "10.0.0.0/8:90-80", + "10.0.0.0/8:80:90", + "@host:ssh", ] { assert!(input.parse::().is_err(), "{input:?} should fail"); } } + + #[test] + fn parses_ports() { + let server = Target::Prefix(Ipv4Net::from_str("192.168.0.10/32").unwrap()); + + assert_eq!( + "192.168.0.10/32:8444".parse::().unwrap(), + Rule::Stateless(server, Some("8444".parse().unwrap())) + ); + assert_eq!( + "out @host:1-65535".parse::().unwrap(), + Rule::Stateful { + direction: Direction::Out, + target: Target::Host, + ports: Some(Ports { + first: 1, + last: 65535 + }), + } + ); + } + + #[test] + fn port_ranges_are_inclusive() { + let ports: Ports = "8000-8002".parse().unwrap(); + + assert!(!ports.contains(7999)); + assert!(ports.contains(8000)); + assert!(ports.contains(8002)); + assert!(!ports.contains(8003)); + } } diff --git a/lib/proxy/rules.rs b/lib/proxy/rules.rs index 008a1a7..0fe71f9 100644 --- a/lib/proxy/rules.rs +++ b/lib/proxy/rules.rs @@ -1,4 +1,4 @@ -use super::{Direction, Rule, Target}; +use super::{Direction, Ports, Rule, Target}; use ipnet::Ipv4Net; use prefix_trie::PrefixMap; use smoltcp::wire::Ipv4Address; @@ -23,11 +23,22 @@ enum Mode { Stateful, } +/// A rule with ports, matched by a linear scan since policies have few of them. +#[derive(Debug, Clone, Copy)] +struct PortEntry { + prefix: Ipv4Net, + ports: Ports, + action: Action, +} + #[derive(Default)] pub(crate) struct Rules { mode: Mode, inbound: PrefixMap, outbound: PrefixMap, + // Rules with ports take precedence over rules without them + inbound_ports: Vec, + outbound_ports: Vec, } impl Rules { @@ -62,36 +73,63 @@ impl Rules { rules } + /// `port` is the remote side's TCP or UDP port, when the packet has one. pub(crate) fn policy_decision( &self, address: Ipv4Address, + port: Option, direction: Direction, ) -> Option { - match (self.select(address, direction)?, self.mode) { + match (self.select(address, port, direction)?, self.mode) { (Action::Block, _) => Some(PolicyDecision::Block), (Action::Allow, Mode::Legacy) => Some(PolicyDecision::AllowStateless), (Action::Allow, Mode::Stateful) => Some(PolicyDecision::AllowStateful), } } - pub(crate) fn is_stateful(&self, address: Ipv4Address, direction: Direction) -> bool { - self.mode == Mode::Stateful && self.select(address, direction).is_some() + pub(crate) fn is_stateful( + &self, + address: Ipv4Address, + port: Option, + direction: Direction, + ) -> bool { + self.mode == Mode::Stateful && self.select(address, port, direction).is_some() } pub(crate) fn len(&self) -> usize { - self.inbound.len() + self.outbound.len() + self.inbound.len() + + self.outbound.len() + + self.inbound_ports.len() + + self.outbound_ports.len() } pub(crate) fn has_stateful(&self) -> bool { self.mode == Mode::Stateful } - fn select(&self, address: Ipv4Address, direction: Direction) -> Option { - let entries = match direction { - Direction::In => &self.inbound, - Direction::Out => &self.outbound, + fn select( + &self, + address: Ipv4Address, + port: Option, + direction: Direction, + ) -> Option { + let (entries, port_entries) = match direction { + Direction::In => (&self.inbound, &self.inbound_ports), + Direction::Out => (&self.outbound, &self.outbound_ports), }; + if let Some(port) = port { + // The longest prefix match wins, and blocking wins a tie + let selected = port_entries + .iter() + .filter(|entry| entry.prefix.contains(&address) && entry.ports.contains(port)) + .max_by_key(|entry| (entry.prefix.prefix_len(), entry.action == Action::Block)); + + if let Some(entry) = selected { + return Some(entry.action); + } + } + entries .get_lpm(&Ipv4Net::from(address)) .map(|(_, action)| *action) @@ -99,16 +137,20 @@ impl Rules { fn insert(&mut self, rule: Rule, action: Action, host_address: Ipv4Address) { match rule { - Rule::Stateless(target) => { + Rule::Stateless(target, ports) => { // Bare rules apply in both directions in stateful mode if self.mode == Mode::Stateful { - self.insert_direction(Direction::In, target, action, host_address); + self.insert_direction(Direction::In, target, ports, action, host_address); } - self.insert_direction(Direction::Out, target, action, host_address); + self.insert_direction(Direction::Out, target, ports, action, host_address); } - Rule::Stateful { direction, target } => { - self.insert_direction(direction, target, action, host_address); + Rule::Stateful { + direction, + target, + ports, + } => { + self.insert_direction(direction, target, ports, action, host_address); } } } @@ -117,6 +159,7 @@ impl Rules { &mut self, direction: Direction, target: Target, + ports: Option, action: Action, host_address: Ipv4Address, ) { @@ -124,6 +167,29 @@ impl Rules { Target::Prefix(prefix) => prefix, Target::Host => host_address.into(), }; + + if let Some(ports) = ports { + let port_entries = match direction { + Direction::In => &mut self.inbound_ports, + Direction::Out => &mut self.outbound_ports, + }; + + // Replace identical rules like the prefix map does, so blocking rules inserted last win + match port_entries + .iter_mut() + .find(|entry| entry.prefix == prefix && entry.ports == ports) + { + Some(entry) => entry.action = action, + None => port_entries.push(PortEntry { + prefix, + ports, + action, + }), + } + + return; + } + let entries = match direction { Direction::In => &mut self.inbound, Direction::Out => &mut self.outbound, @@ -157,17 +223,17 @@ mod tests { rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Allow, HOST); assert_eq!( - rules.policy_decision(target, Direction::In), + rules.policy_decision(target, None, Direction::In), Some(PolicyDecision::AllowStateful) ); assert_eq!( - rules.policy_decision(target, Direction::Out), + rules.policy_decision(target, None, Direction::Out), Some(PolicyDecision::Block) ); rules.insert("10.0.0.1/32".parse().unwrap(), Action::Allow, HOST); assert_eq!( - rules.policy_decision(target, Direction::Out), + rules.policy_decision(target, None, Direction::Out), Some(PolicyDecision::AllowStateful) ); } @@ -185,11 +251,11 @@ mod tests { } assert_eq!( - rules.policy_decision(HOST, Direction::In), + rules.policy_decision(HOST, None, Direction::In), Some(PolicyDecision::Block) ); assert_eq!( - rules.policy_decision(HOST, Direction::Out), + rules.policy_decision(HOST, None, Direction::Out), Some(PolicyDecision::AllowStateful) ); assert_eq!(rules.len(), 2); @@ -202,9 +268,9 @@ mod tests { rules.insert("10.0.0.0/8".parse().unwrap(), Action::Block, HOST); - assert!(rules.policy_decision(target, Direction::In).is_none()); + assert!(rules.policy_decision(target, None, Direction::In).is_none()); assert_eq!( - rules.policy_decision(target, Direction::Out), + rules.policy_decision(target, None, Direction::Out), Some(PolicyDecision::Block) ); } @@ -218,7 +284,7 @@ mod tests { rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Block, HOST); assert_eq!( - rules.policy_decision(target, Direction::In), + rules.policy_decision(target, None, Direction::In), Some(PolicyDecision::AllowStateful) ); } @@ -236,7 +302,7 @@ mod tests { rules.insert(block.parse().unwrap(), Action::Block, HOST); assert_eq!( - rules.policy_decision(target, Direction::Out), + rules.policy_decision(target, None, Direction::Out), Some(PolicyDecision::Block) ); } @@ -251,11 +317,11 @@ mod tests { assert_eq!(rules.len(), 3); assert!(rules.has_stateful()); assert_eq!( - rules.policy_decision(HOST, Direction::In), + rules.policy_decision(HOST, None, Direction::In), Some(PolicyDecision::Block) ); assert_eq!( - rules.policy_decision(HOST, Direction::Out), + rules.policy_decision(HOST, None, Direction::Out), Some(PolicyDecision::AllowStateful) ); } @@ -269,11 +335,109 @@ mod tests { rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST); rules.insert("out 10.0.0.0/8".parse().unwrap(), Action::Block, HOST); - assert!(rules.is_stateful(stateful_target, Direction::Out)); - assert!(rules.is_stateful(stateless_target, Direction::Out)); - assert!(rules.is_stateful(stateful_target, Direction::In)); + assert!(rules.is_stateful(stateful_target, None, Direction::Out)); + assert!(rules.is_stateful(stateless_target, None, Direction::Out)); + assert!(rules.is_stateful(stateful_target, None, Direction::In)); rules.insert("10.0.0.0/8".parse().unwrap(), Action::Allow, HOST); - assert!(rules.is_stateful(stateful_target, Direction::Out)); + assert!(rules.is_stateful(stateful_target, None, Direction::Out)); + } + + #[test] + fn test_port_rule_opens_one_port_through_a_private_block() { + let server = Ipv4Address::new(192, 168, 0, 10); + let rules = Rules::new( + HOST, + &["192.168.0.10/32:8444".parse().unwrap()], + &["192.168.0.0/16".parse().unwrap()], + ); + + assert_eq!( + rules.policy_decision(server, Some(8444), Direction::Out), + Some(PolicyDecision::AllowStateless) + ); + for port in [Some(22), Some(8443), Some(8445), None] { + assert_eq!( + rules.policy_decision(server, port, Direction::Out), + Some(PolicyDecision::Block), + "port {port:?}" + ); + } + assert_eq!( + rules.policy_decision(Ipv4Address::new(192, 168, 0, 1), Some(8444), Direction::Out), + Some(PolicyDecision::Block) + ); + assert_eq!(rules.len(), 2); + } + + #[test] + fn test_port_rule_outranks_a_longer_rule_without_ports() { + let server = Ipv4Address::new(192, 168, 0, 10); + let rules = Rules::new( + HOST, + &["192.168.0.0/24:8444".parse().unwrap()], + &["192.168.0.10/32".parse().unwrap()], + ); + + assert_eq!( + rules.policy_decision(server, Some(8444), Direction::Out), + Some(PolicyDecision::AllowStateless) + ); + assert_eq!( + rules.policy_decision(server, Some(22), Direction::Out), + Some(PolicyDecision::Block) + ); + } + + #[test] + fn test_port_rules_longest_prefix_then_block_wins() { + let server = Ipv4Address::new(10, 1, 2, 3); + let rules = Rules::new( + HOST, + &[ + "10.0.0.0/8:8000-9000".parse().unwrap(), + "10.1.2.3/32:8444".parse().unwrap(), + ], + &[ + "10.1.0.0/16:8000-9000".parse().unwrap(), + "10.1.2.3/32:8444".parse().unwrap(), + ], + ); + + // /32 allow and /32 block on the same port: block + assert_eq!( + rules.policy_decision(server, Some(8444), Direction::Out), + Some(PolicyDecision::Block) + ); + // /16 block beats /8 allow + assert_eq!( + rules.policy_decision(server, Some(8500), Direction::Out), + Some(PolicyDecision::Block) + ); + // Only the /8 allow covers this address + assert_eq!( + rules.policy_decision(Ipv4Address::new(10, 9, 9, 9), Some(8500), Direction::Out), + Some(PolicyDecision::AllowStateless) + ); + } + + #[test] + fn test_port_rules_follow_direction_in_stateful_mode() { + let peer = Ipv4Address::new(10, 1, 2, 3); + let rules = Rules::new( + HOST, + &["out 10.1.2.3/32:443".parse().unwrap()], + &["10.0.0.0/8".parse().unwrap()], + ); + + assert_eq!( + rules.policy_decision(peer, Some(443), Direction::Out), + Some(PolicyDecision::AllowStateful) + ); + assert_eq!( + rules.policy_decision(peer, Some(443), Direction::In), + Some(PolicyDecision::Block) + ); + assert!(rules.is_stateful(peer, Some(443), Direction::Out)); } } diff --git a/lib/proxy/vm.rs b/lib/proxy/vm.rs index eb2b5c0..3676373 100644 --- a/lib/proxy/vm.rs +++ b/lib/proxy/vm.rs @@ -1,5 +1,5 @@ use crate::proxy::flows::{FlowDirection, FlowMatch}; -use crate::proxy::{Direction, PolicyDecision, Proxy}; +use crate::proxy::{Direction, PolicyDecision, Proxy, transport_ports}; use anyhow::Context; use anyhow::Result; use dhcproto::v4::SERVER_PORT; @@ -77,15 +77,24 @@ impl Proxy<'_> { // The flow is either pending or untracked, evaluate it against outbound policy let dst_addr = ipv4_pkt.dst_addr(); + let dst_port = transport_ports(&ipv4_pkt).map(|(_, dst_port)| dst_port); - match self.rules.policy_decision(dst_addr, Direction::Out) { + match self + .rules + .policy_decision(dst_addr, dst_port, Direction::Out) + { // Return traffic was handled above; enforce explicit outbound blocks here Some(PolicyDecision::Block) => return None, // Track statelessly allowed traffic only when needed so its reply is not // treated as a new inbound flow Some(PolicyDecision::AllowStateless) => { - return self.admit_with_tracking_if_stateful(pending, dst_addr, Direction::In); + return self.admit_with_tracking_if_stateful( + pending, + dst_addr, + dst_port, + Direction::In, + ); } // Untracked packets cannot satisfy stateful policy diff --git a/src/main.rs b/src/main.rs index a5324b6..1a682c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,9 @@ struct Args { Targets are:\n\n\ * IPv4 CIDRs\n\ * @host, which matches the vmnet bridge gateway IP\n\n\ + A target may end in :PORT or :FIRST-LAST to match only TCP and UDP traffic with \ + that port on the target's side. A rule with ports takes precedence over any rule \ + without them.\n\n\ Directional rules make bare TARGET rules stateful in both directions.\n\n\ When used with --block, the longest prefix match wins. If an identical rule is both \ allowed and blocked, blocking takes precedence.\n\n\ @@ -84,6 +87,7 @@ struct Args { * --allow=192.168.0.0/24 — allow stateless traffic with this LAN\n\ * --allow=\"in @host\" — allow stateful flows initiated from @host\n\ * --allow=\"out 192.168.0.0/24\" — allow stateful flows initiated toward this LAN\n\ + * --allow=192.168.0.10/32:8443 — allow stateless traffic with one port of this LAN host\n\ * --allow=\"in @host,out 192.168.0.0/24\" — multiple rules may be comma-separated", value_name = "comma-separated rules", use_value_delimiter = true, @@ -100,6 +104,9 @@ struct Args { Targets are:\n\n\ * IPv4 CIDRs\n\ * @host, which matches the vmnet bridge gateway IP\n\n\ + A target may end in :PORT or :FIRST-LAST to match only TCP and UDP traffic with \ + that port on the target's side. A rule with ports takes precedence over any rule \ + without them.\n\n\ Directional rules make bare TARGET rules stateful in both directions.\n\n\ When used with --allow, the longest prefix match wins. If an identical rule is both \ allowed and blocked, blocking takes precedence.\n\n\ @@ -107,6 +114,7 @@ struct Args { * --block=0.0.0.0/0 — establish a stateless default-deny egress policy\n\ * --block=\"out @host\" — block stateful flows initiated toward @host\n\ * --block=\"out 66.66.66.0/24\" — block stateful flows initiated toward this CIDR\n\ + * --block=@host:22 — block stateless traffic to SSH on @host\n\ * --block=\"out @host,out 66.66.66.0/24\" — multiple rules may be comma-separated", value_name = "comma-separated rules", use_value_delimiter = true,