diff --git a/CHANGELOG.md b/CHANGELOG.md index c2daae7..fad9de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). * **Security**: in case of vulnerabilities. ## [unreleased] +### Added +- Add support for NAT anchors and rules. ## [0.6.0] - 2024-09-04 diff --git a/examples/flush_rules.rs b/examples/flush_rules.rs index 6425ea2..c67150f 100644 --- a/examples/flush_rules.rs +++ b/examples/flush_rules.rs @@ -17,6 +17,10 @@ fn main() { .expect("Unable to flush filter rules"); println!("Flushed filter rules under anchor {}", anchor_name); + pf.flush_rules(&anchor_name, pfctl::RulesetKind::Nat) + .expect("Unable to flush nat rules"); + println!("Flushed nat rules under anchor {}", anchor_name); + pf.flush_rules(&anchor_name, pfctl::RulesetKind::Redirect) .expect("Unable to flush redirect rules"); println!("Flushed redirect rules under anchor {}", anchor_name); diff --git a/src/anchor.rs b/src/anchor.rs index 6fb2f3e..ef7fa1d 100644 --- a/src/anchor.rs +++ b/src/anchor.rs @@ -13,6 +13,7 @@ use crate::ffi; #[non_exhaustive] pub enum AnchorKind { Filter, + Nat, Redirect, Scrub, } @@ -21,6 +22,7 @@ impl From for u8 { fn from(anchor_kind: AnchorKind) -> u8 { match anchor_kind { AnchorKind::Filter => ffi::pfvar::PF_PASS as u8, + AnchorKind::Nat => ffi::pfvar::PF_NAT as u8, AnchorKind::Redirect => ffi::pfvar::PF_RDR as u8, AnchorKind::Scrub => ffi::pfvar::PF_SCRUB as u8, } diff --git a/src/lib.rs b/src/lib.rs index 97a8477..5b76f98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -360,6 +360,33 @@ impl PfCtl { trans.commit() } + pub fn add_nat_rule(&mut self, anchor: &str, rule: &NatRule) -> Result<()> { + // prepare pfioc_rule + let mut pfioc_rule = unsafe { mem::zeroed::() }; + utils::copy_anchor_name(anchor, &mut pfioc_rule.anchor[..])?; + rule.try_copy_to(&mut pfioc_rule.rule)?; + + let pool_ticket = utils::get_pool_ticket(self.fd())?; + + if let Some(nat_to) = rule.get_nat_to() { + // register NAT address in newly created address pool + utils::add_pool_address(self.fd(), nat_to.ip(), pool_ticket)?; + + // copy address pool in pf_rule + let nat_pool = nat_to.ip().to_pool_addr_list()?; + pfioc_rule.rule.rpool.list = unsafe { nat_pool.to_palist() }; + nat_to.port().try_copy_to(&mut pfioc_rule.rule.rpool)?; + } + + // set tickets + pfioc_rule.pool_ticket = pool_ticket; + pfioc_rule.ticket = utils::get_ticket(self.fd(), anchor, AnchorKind::Nat)?; + + // append rule + pfioc_rule.action = ffi::pfvar::PF_CHANGE_ADD_TAIL as u32; + ioctl_guard!(ffi::pf_change_rule(self.fd(), &mut pfioc_rule)) + } + pub fn add_redirect_rule(&mut self, anchor: &str, rule: &RedirectRule) -> Result<()> { // prepare pfioc_rule let mut pfioc_rule = unsafe { mem::zeroed::() }; @@ -402,6 +429,7 @@ impl PfCtl { let mut anchor_change = AnchorChange::new(); match kind { RulesetKind::Filter => anchor_change.set_filter_rules(Vec::new()), + RulesetKind::Nat => anchor_change.set_nat_rules(Vec::new()), RulesetKind::Redirect => anchor_change.set_redirect_rules(Vec::new()), RulesetKind::Scrub => anchor_change.set_scrub_rules(Vec::new()), }; diff --git a/src/rule/mod.rs b/src/rule/mod.rs index 88290ee..f57871f 100644 --- a/src/rule/mod.rs +++ b/src/rule/mod.rs @@ -11,7 +11,10 @@ use crate::{ ffi, Error, ErrorInternal, Result, }; use ipnetwork::IpNetwork; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + ops::Deref, +}; mod addr_family; pub use self::addr_family::*; @@ -159,6 +162,110 @@ impl TryCopyTo for FilterRule { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_builder::Builder)] +#[builder(setter(into))] +#[builder(build_fn(error = "Error"))] +pub struct NatRule { + action: NatRuleAction, + #[builder(default)] + interface: Interface, + #[builder(default)] + af: AddrFamily, + #[builder(default)] + from: Endpoint, + #[builder(default)] + to: Endpoint, +} + +impl NatRule { + /// Returns the `AddrFamily` this rule matches against. Returns an `InvalidRuleCombination` + /// error if this rule has an invalid combination of address families. + fn get_af(&self) -> Result { + let endpoint_af = compatible_af(self.from.get_af(), self.to.get_af())?; + if let Some(nat_to) = self.get_nat_to() { + let nat_af = compatible_af(endpoint_af, nat_to.0.get_af())?; + compatible_af(self.af, nat_af) + } else { + compatible_af(self.af, endpoint_af) + } + } + + /// Accessor for `nat_to` + pub fn get_nat_to(&self) -> Option { + match self.action { + NatRuleAction::Nat { nat_to } => Some(nat_to), + NatRuleAction::NoNat => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NatEndpoint(Endpoint); + +impl Deref for NatEndpoint { + type Target = Endpoint; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for NatEndpoint { + fn from(ip: Ip) -> Self { + // Default NAT port range + const NAT_LOWER_DEFAULT: u16 = 32768; + const NAT_UPPER_DEFAULT: u16 = 49151; + + Self(Endpoint::new( + ip, + Port::Range( + NAT_LOWER_DEFAULT, + NAT_UPPER_DEFAULT, + PortRangeModifier::Inclusive, + ), + )) + } +} + +impl Default for NatEndpoint { + fn default() -> Self { + Self::from(Ip::Any) + } +} + +impl From for NatEndpoint { + fn from(endpoint: Endpoint) -> Self { + Self(endpoint) + } +} + +impl From for NatEndpoint { + fn from(ip: Ipv4Addr) -> Self { + Self::from(Ip::from(ip)) + } +} + +impl From for NatEndpoint { + fn from(ip: Ipv6Addr) -> Self { + Self::from(Ip::from(ip)) + } +} + +impl TryCopyTo for NatRule { + type Error = crate::Error; + + fn try_copy_to(&self, pf_rule: &mut ffi::pfvar::pf_rule) -> Result<()> { + pf_rule.action = self.action.into(); + self.interface.try_copy_to(&mut pf_rule.ifname)?; + pf_rule.af = self.get_af()?.into(); + + self.from.try_copy_to(&mut pf_rule.src)?; + self.to.try_copy_to(&mut pf_rule.dst)?; + + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, derive_builder::Builder)] #[builder(setter(into))] #[builder(build_fn(error = "Error"))] diff --git a/src/rule/rule_action.rs b/src/rule/rule_action.rs index d21c732..d6dfb10 100644 --- a/src/rule/rule_action.rs +++ b/src/rule/rule_action.rs @@ -6,7 +6,7 @@ // option. This file may not be copied, modified, or distributed // except according to those terms. -use crate::ffi; +use crate::{ffi, NatEndpoint}; /// Enum describing what should happen to a packet that matches a filter rule. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -58,6 +58,22 @@ impl From for u32 { } } +/// Enum describing what should happen to a packet that matches a NAT rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NatRuleAction { + Nat { nat_to: NatEndpoint }, + NoNat, +} + +impl From for u8 { + fn from(rule_action: NatRuleAction) -> Self { + match rule_action { + NatRuleAction::Nat { .. } => ffi::pfvar::PF_NAT as u8, + NatRuleAction::NoNat => ffi::pfvar::PF_NONAT as u8, + } + } +} + /// Enum describing what should happen to a packet that matches a redirect rule. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RedirectRuleAction { diff --git a/src/ruleset.rs b/src/ruleset.rs index 9f9509f..cc0fab9 100644 --- a/src/ruleset.rs +++ b/src/ruleset.rs @@ -13,6 +13,7 @@ use crate::ffi; #[non_exhaustive] pub enum RulesetKind { Filter, + Nat, Redirect, Scrub, } @@ -21,6 +22,7 @@ impl From for i32 { fn from(ruleset_kind: RulesetKind) -> Self { match ruleset_kind { RulesetKind::Filter => ffi::pfvar::PF_RULESET_FILTER as i32, + RulesetKind::Nat => ffi::pfvar::PF_RULESET_NAT as i32, RulesetKind::Redirect => ffi::pfvar::PF_RULESET_RDR as i32, RulesetKind::Scrub => ffi::pfvar::PF_RULESET_SCRUB as i32, } diff --git a/src/transaction.rs b/src/transaction.rs index 07082d5..a6eb89e 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -7,8 +7,8 @@ // except according to those terms. use crate::{ - conversion::TryCopyTo, ffi, utils, FilterRule, PoolAddrList, RedirectRule, Result, RulesetKind, - ScrubRule, + conversion::TryCopyTo, ffi, utils, FilterRule, NatRule, PoolAddrList, RedirectRule, Result, + RulesetKind, ScrubRule, }; use std::{ collections::HashMap, @@ -60,6 +60,13 @@ impl Transaction { .map(|rules| (anchor.clone(), rules)) }) .collect(); + let nat_changes: Vec<(String, Vec)> = self + .change_by_anchor + .iter_mut() + .filter_map(|(anchor, change)| { + change.nat_rules.take().map(|rules| (anchor.clone(), rules)) + }) + .collect(); let redirect_changes: Vec<(String, Vec)> = self .change_by_anchor .iter_mut() @@ -87,6 +94,11 @@ impl Transaction { let mut pfioc_elements: Vec = filter_changes .iter() .map(|(anchor, _)| Self::new_trans_element(anchor, RulesetKind::Filter)) + .chain( + nat_changes + .iter() + .map(|(anchor, _)| Self::new_trans_element(anchor, RulesetKind::Nat)), + ) .chain( redirect_changes .iter() @@ -115,6 +127,15 @@ impl Transaction { } } + // add NAT rules into transaction + for ((anchor_name, nat_rules), ticket) in + nat_changes.into_iter().zip(ticket_iterator.by_ref()) + { + for nat_rule in nat_rules.iter() { + Self::add_nat_rule(fd, &anchor_name, nat_rule, ticket)?; + } + } + // add redirect rules into transaction for ((anchor_name, redirect_rules), ticket) in redirect_changes.into_iter().zip(ticket_iterator.by_ref()) @@ -170,6 +191,33 @@ impl Transaction { Ok(()) } + /// Internal helper to add nat rule into transaction + fn add_nat_rule(fd: RawFd, anchor: &str, rule: &NatRule, ticket: u32) -> Result<()> { + // prepare pfioc_rule + let mut pfioc_rule = unsafe { mem::zeroed::() }; + utils::copy_anchor_name(anchor, &mut pfioc_rule.anchor[..])?; + rule.try_copy_to(&mut pfioc_rule.rule)?; + + let pool_ticket = utils::get_pool_ticket(fd)?; + + if let Some(nat_to) = rule.get_nat_to() { + // register NAT address in newly created address pool + utils::add_pool_address(fd, nat_to.ip(), pool_ticket)?; + + // copy address pool in pf_rule + let nat_pool = nat_to.ip().to_pool_addr_list()?; + pfioc_rule.rule.rpool.list = unsafe { nat_pool.to_palist() }; + nat_to.port().try_copy_to(&mut pfioc_rule.rule.rpool)?; + } + + // set tickets + pfioc_rule.pool_ticket = pool_ticket; + pfioc_rule.ticket = ticket; + + // add rule into transaction + ioctl_guard!(ffi::pf_add_rule(fd, &mut pfioc_rule)) + } + /// Internal helper to add redirect rule into transaction fn add_redirect_rule(fd: RawFd, anchor: &str, rule: &RedirectRule, ticket: u32) -> Result<()> { // prepare pfioc_rule @@ -242,6 +290,7 @@ impl Transaction { #[derive(Debug)] pub struct AnchorChange { filter_rules: Option>, + nat_rules: Option>, redirect_rules: Option>, scrub_rules: Option>, } @@ -257,6 +306,7 @@ impl AnchorChange { pub fn new() -> Self { AnchorChange { filter_rules: None, + nat_rules: None, redirect_rules: None, scrub_rules: None, } @@ -266,6 +316,10 @@ impl AnchorChange { self.filter_rules = Some(rules); } + pub fn set_nat_rules(&mut self, rules: Vec) { + self.nat_rules = Some(rules); + } + pub fn set_redirect_rules(&mut self, rules: Vec) { self.redirect_rules = Some(rules); } diff --git a/tests/helper/mod.rs b/tests/helper/mod.rs index 51f5dee..5c57905 100644 --- a/tests/helper/mod.rs +++ b/tests/helper/mod.rs @@ -32,6 +32,8 @@ macro_rules! test { ($name:ident $block:block) => { #[test] fn $name() { + eprintln!("NOTE: Make sure there are not other PF rules interfering with this test."); + let mut pf_state = helper::PfState::new(); pf_state.save(); diff --git a/tests/helper/pfcli.rs b/tests/helper/pfcli.rs index 01e4532..e7a6796 100644 --- a/tests/helper/pfcli.rs +++ b/tests/helper/pfcli.rs @@ -15,7 +15,11 @@ pub fn is_enabled() -> bool { } else if str.starts_with("Status: Disabled") { false } else { - panic!("Invalid response."); + let stderr = str_from_stdout(&output.stderr); + panic!( + "Invalid output from pfctl ({}), stdout:\n{str}\nstderr:\n{stderr}", + output.status + ); } } diff --git a/tests/nat_rules.rs b/tests/nat_rules.rs new file mode 100644 index 0000000..31a4685 --- /dev/null +++ b/tests/nat_rules.rs @@ -0,0 +1,104 @@ +#[macro_use] +#[allow(dead_code)] +mod helper; + +use crate::helper::pfcli; +use assert_matches::assert_matches; +use std::net::{Ipv4Addr, Ipv6Addr}; + +static ANCHOR_NAME: &str = "pfctl-rs.integration.testing.nat-rules"; + +fn nat_rule(dest: pfctl::Ip, nat_to: pfctl::Ip) -> pfctl::NatRule { + pfctl::NatRuleBuilder::default() + .action(pfctl::NatRuleAction::Nat { + nat_to: nat_to.into(), + }) + .to(pfctl::Endpoint::new(dest, 1234)) + .build() + .unwrap() +} + +fn nat_rule_ipv4() -> pfctl::NatRule { + nat_rule( + pfctl::Ip::from(Ipv4Addr::new(127, 0, 0, 1)), + pfctl::Ip::from(Ipv4Addr::new(127, 0, 0, 2)), + ) +} + +fn nat_rule_ipv6() -> pfctl::NatRule { + nat_rule( + pfctl::Ip::from(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), + pfctl::Ip::from(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 2)), + ) +} + +fn nonat_rule(dest: pfctl::Ip) -> pfctl::NatRule { + pfctl::NatRuleBuilder::default() + .action(pfctl::NatRuleAction::NoNat) + .to(pfctl::Endpoint::new(dest, 1234)) + .build() + .unwrap() +} + +fn nonat_rule_ipv4() -> pfctl::NatRule { + nonat_rule(pfctl::Ip::from(Ipv4Addr::new(127, 0, 0, 1))) +} + +fn nonat_rule_ipv6() -> pfctl::NatRule { + nonat_rule(pfctl::Ip::from(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))) +} + +fn before_each() { + pfctl::PfCtl::new() + .unwrap() + .try_add_anchor(ANCHOR_NAME, pfctl::AnchorKind::Nat) + .unwrap(); +} + +fn after_each() { + pfcli::flush_rules(ANCHOR_NAME, pfcli::FlushOptions::Nat); + pfctl::PfCtl::new() + .unwrap() + .try_remove_anchor(ANCHOR_NAME, pfctl::AnchorKind::Nat) + .unwrap(); +} + +test!(add_nat_rule_ipv4 { + let mut pf = pfctl::PfCtl::new().unwrap(); + let rule = nat_rule_ipv4(); + assert_matches!(pf.add_nat_rule(ANCHOR_NAME, &rule), Ok(())); + assert_eq!( + pfcli::get_nat_rules(ANCHOR_NAME), + &["nat inet from any to 127.0.0.1 port = 1234 -> 127.0.0.2"] + ); +}); + +test!(add_nat_rule_ipv6 { + let mut pf = pfctl::PfCtl::new().unwrap(); + let rule = nat_rule_ipv6(); + assert_matches!(pf.add_nat_rule(ANCHOR_NAME, &rule), Ok(())); + assert_eq!( + pfcli::get_nat_rules(ANCHOR_NAME), + &["nat inet6 from any to ::1 port = 1234 -> ::2"] + ); +}); + +test!(add_nonat_rule_ipv4 { + let mut pf = pfctl::PfCtl::new().unwrap(); + let rule = nonat_rule_ipv4(); + assert_matches!(pf.add_nat_rule(ANCHOR_NAME, &rule), Ok(())); + assert_eq!( + pfcli::get_nat_rules(ANCHOR_NAME), + &["no nat inet from any to 127.0.0.1 port = 1234"] + ); +}); + +test!(add_nonat_rule_ipv6 { + let mut pf = pfctl::PfCtl::new().unwrap(); + let rule = nonat_rule_ipv6(); + assert_matches!(pf.add_nat_rule(ANCHOR_NAME, &rule), Ok(())); + assert_eq!( + pfcli::get_nat_rules(ANCHOR_NAME), + &["no nat inet6 from any to ::1 port = 1234"] + ); +}); diff --git a/tests/transaction.rs b/tests/transaction.rs index eb29d3e..d15e106 100644 --- a/tests/transaction.rs +++ b/tests/transaction.rs @@ -9,7 +9,8 @@ use std::net::Ipv4Addr; const ANCHOR1_NAME: &str = "pfctl-rs.integration.testing.transactions-1"; const ANCHOR2_NAME: &str = "pfctl-rs.integration.testing.transactions-2"; const ANCHOR3_NAME: &str = "pfctl-rs.integration.testing.transactions-3"; -const ANCHORS: [&str; 3] = [ANCHOR1_NAME, ANCHOR2_NAME, ANCHOR3_NAME]; +const ANCHOR4_NAME: &str = "pfctl-rs.integration.testing.transactions-4"; +const ANCHORS: [&str; 4] = [ANCHOR1_NAME, ANCHOR2_NAME, ANCHOR3_NAME, ANCHOR4_NAME]; fn before_each() { for anchor_name in ANCHORS.iter() { @@ -17,6 +18,10 @@ fn before_each() { .unwrap() .try_add_anchor(anchor_name, pfctl::AnchorKind::Filter) .unwrap(); + pfctl::PfCtl::new() + .unwrap() + .try_add_anchor(anchor_name, pfctl::AnchorKind::Nat) + .unwrap(); pfctl::PfCtl::new() .unwrap() .try_add_anchor(anchor_name, pfctl::AnchorKind::Redirect) @@ -61,6 +66,22 @@ fn get_filter_rules() -> Vec { vec![rule1, rule2] } +fn get_nat_rules() -> Vec { + let rule1 = pfctl::NatRuleBuilder::default() + .action(pfctl::NatRuleAction::Nat { + nat_to: Ipv4Addr::new(127, 0, 0, 1).into(), + }) + .to(Ipv4Addr::new(1, 2, 3, 4)) + .build() + .unwrap(); + let rule2 = pfctl::NatRuleBuilder::default() + .action(pfctl::NatRuleAction::NoNat) + .to(Ipv4Addr::new(1, 3, 3, 7)) + .build() + .unwrap(); + vec![rule1, rule2] +} + fn get_redirect_rules() -> Vec { let rdr_rule1 = pfctl::RedirectRuleBuilder::default() .action(pfctl::RedirectRuleAction::Redirect) @@ -134,7 +155,7 @@ fn get_rules_filtered(anchor: &str, filter: impl Fn(&str) -> bool) -> Vec any port 4000", "rdr inet from 1.2.3.4 to any port = 5000 -> any port 6000", @@ -142,6 +163,23 @@ fn verify_redirect_rules(anchor: &str) { ); } +fn verify_nat_rules(anchor: &str) { + assert_eq!( + get_nat_rules_filtered(anchor, |rule| rule.contains("nat")), + &[ + "nat inet from any to 1.2.3.4 -> 127.0.0.1", + "no nat inet from any to 1.3.3.7", + ] + ); +} + +fn get_nat_rules_filtered(anchor: &str, filter: impl Fn(&str) -> bool) -> Vec { + pfcli::get_nat_rules(anchor) + .into_iter() + .filter(|rule| filter(rule)) + .collect::>() +} + fn verify_filter_marker(anchor: &str) { assert_eq!(pfcli::get_rules(anchor), &["pass all no state"]); } @@ -159,6 +197,7 @@ test!(replace_many_rulesets_in_one_anchor { let mut change = pfctl::AnchorChange::new(); change.set_filter_rules(get_filter_rules()); + change.set_nat_rules(get_nat_rules()); change.set_redirect_rules(get_redirect_rules()); change.set_scrub_rules(get_scrub_rules()); @@ -196,19 +235,24 @@ test!(replace_one_ruleset_in_many_anchors { change2.set_filter_rules(get_filter_rules()); let mut change3 = pfctl::AnchorChange::new(); - change3.set_scrub_rules(get_scrub_rules()); + change3.set_nat_rules(get_nat_rules()); + + let mut change4 = pfctl::AnchorChange::new(); + change4.set_scrub_rules(get_scrub_rules()); // create and run transaction let mut trans = pfctl::Transaction::new(); trans.add_change(ANCHOR1_NAME, change1); trans.add_change(ANCHOR2_NAME, change2); trans.add_change(ANCHOR3_NAME, change3); + trans.add_change(ANCHOR4_NAME, change4); assert_matches!(trans.commit(), Ok(())); // do final rules verification after transaction verify_filter_marker(ANCHOR1_NAME); verify_redirect_rules(ANCHOR1_NAME); verify_filter_rules(ANCHOR2_NAME); - verify_scrub_rules(ANCHOR3_NAME); + verify_nat_rules(ANCHOR3_NAME); + verify_scrub_rules(ANCHOR4_NAME); verify_redirect_marker(ANCHOR2_NAME); });