From cd511cdaa5086d139e1c9caa1a1279d43ca5b625 Mon Sep 17 00:00:00 2001 From: Matthew Heon Date: Thu, 4 Jan 2024 14:09:37 -0500 Subject: [PATCH] Initial draft of nftables support This implementation lacks isolation support at present (that'll be a followon PR) and has only very minimal testing at the moment (including absolutely 0 testing for IPv6), but does handle all core tasks including forwarding and all types of port forwarding. Fixes #816 Signed-off-by: Matthew Heon --- Cargo.lock | 44 +++ Cargo.toml | 1 + src/error/mod.rs | 26 ++ src/firewall/mod.rs | 5 +- src/firewall/nft.rs | 778 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 src/firewall/nft.rs diff --git a/Cargo.lock b/Cargo.lock index 2dc9ad7d6..aad960ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,6 +1355,7 @@ dependencies = [ "netlink-packet-route", "netlink-packet-utils", "netlink-sys", + "nftables", "nispor", "nix 0.27.1", "once_cell", @@ -1452,6 +1453,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "nftables" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10abe0d631f93f30c3600ecb4ddd21561bcc6bac1837db11cda9c1c922207e7" +dependencies = [ + "serde", + "serde_json", + "serde_path_to_error", + "strum", + "strum_macros", + "thiserror", +] + [[package]] name = "nispor" version = "1.2.16" @@ -1929,6 +1944,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.17" @@ -2018,6 +2043,25 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index cb8a228a1..a4380844d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ sha2 = "0.10.8" netlink-packet-utils = "0.5.2" netlink-packet-route = "0.18.1" netlink-packet-core = "0.7.0" +nftables = "0.2.4" fs2 = "0.4.3" netlink-sys = "0.8.5" tokio = { version = "1.35", features = ["rt", "rt-multi-thread", "signal", "fs"] } diff --git a/src/error/mod.rs b/src/error/mod.rs index 9ca9d9801..bb6bbb6c8 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -81,6 +81,11 @@ pub enum NetavarkError { DHCPProxy(tonic::Status), List(NetavarkErrorList), + + Nftables(nftables::helper::NftablesError), + + SubnetParse(ipnet::AddrParseError), + AddrParse(std::net::AddrParseError), } /// Internal struct for JSON output @@ -160,6 +165,9 @@ impl fmt::Display for NetavarkError { Ok(()) } } + NetavarkError::Nftables(e) => write!(f, "nftables error: {e}"), + NetavarkError::SubnetParse(e) => write!(f, "parsing IP subnet error: {e}"), + NetavarkError::AddrParse(e) => write!(f, "parsing IP address error: {e}"), } } } @@ -213,3 +221,21 @@ impl From for NetavarkError { NetavarkError::DHCPProxy(err) } } + +impl From for NetavarkError { + fn from(err: nftables::helper::NftablesError) -> Self { + NetavarkError::Nftables(err) + } +} + +impl From for NetavarkError { + fn from(err: ipnet::AddrParseError) -> Self { + NetavarkError::SubnetParse(err) + } +} + +impl From for NetavarkError { + fn from(err: std::net::AddrParseError) -> Self { + NetavarkError::AddrParse(err) + } +} diff --git a/src/firewall/mod.rs b/src/firewall/mod.rs index f6b1086ac..c2eda43ac 100644 --- a/src/firewall/mod.rs +++ b/src/firewall/mod.rs @@ -8,6 +8,7 @@ use zbus::blocking::Connection; pub mod firewalld; pub mod fwnone; pub mod iptables; +pub mod nft; pub mod state; mod varktables; @@ -108,9 +109,7 @@ pub fn get_supported_firewall_driver( } FirewallImpl::Nftables => { info!("Using nftables firewall driver"); - Err(NetavarkError::msg( - "nftables support presently not available", - )) + nft::new() } FirewallImpl::Fwnone => { info!("Not using firewall"); diff --git a/src/firewall/nft.rs b/src/firewall/nft.rs new file mode 100644 index 000000000..57f450533 --- /dev/null +++ b/src/firewall/nft.rs @@ -0,0 +1,778 @@ +use crate::error::{NetavarkError, NetavarkResult}; +use crate::firewall; +use crate::network::internal_types; +use crate::network::types::PortMapping; +use ipnet::IpNet; +use nftables::batch::Batch; +use nftables::expr; +use nftables::helper::{self}; +use nftables::schema; +use nftables::stmt; +use nftables::types; +use std::collections::HashSet; +use std::net::IpAddr; + +const TABLENAME: &str = "netavark"; + +const INPUTCHAIN: &str = "INPUT"; +const FORWARDCHAIN: &str = "FORWARD"; +const POSTROUTINGCHAIN: &str = "POSTROUTING"; +const PREROUTINGCHAIN: &str = "PREROUTING"; +const OUTPUTCHAIN: &str = "OUTPUT"; +const DNATCHAIN: &str = "NETAVARK-HOSTPORT-DNAT"; +const MASKCHAIN: &str = "NETAVARK-HOSTPORT-SETMARK"; + +const MASK: u32 = 0x2000; + +pub struct Nftables {} + +pub fn new() -> Result, NetavarkError> { + Ok(Box::new(Nftables {})) +} + +impl firewall::FirewallDriver for Nftables { + fn driver_name(&self) -> &str { + firewall::NFTABLES + } + + fn setup_network(&self, network_setup: internal_types::SetupNetwork) -> NetavarkResult<()> { + let mut batch = Batch::new(); + + // Overall table + batch.add(schema::NfListObject::Table(schema::Table::new( + types::NfFamily::INet, + TABLENAME.to_string(), + ))); + + // Five default chains, one for each hook we have to monitor + batch.add(schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + INPUTCHAIN.to_string(), + Some(types::NfChainType::Filter), + Some(types::NfHook::Input), + Some(0), // Prio 0 == filter + None, + Some(types::NfChainPolicy::Accept), + ))); + batch.add(schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + FORWARDCHAIN.to_string(), + Some(types::NfChainType::Filter), + Some(types::NfHook::Forward), + Some(0), // Prio 0 == filter + None, + Some(types::NfChainPolicy::Accept), + ))); + batch.add(schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + POSTROUTINGCHAIN.to_string(), + Some(types::NfChainType::NAT), + Some(types::NfHook::Postrouting), + Some(100), // Prio 100 == srcnat + None, + Some(types::NfChainPolicy::Accept), + ))); + batch.add(schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + PREROUTINGCHAIN.to_string(), + Some(types::NfChainType::NAT), + Some(types::NfHook::Prerouting), + Some(-100), // Prio -100 == dnat + None, + Some(types::NfChainPolicy::Accept), + ))); + batch.add(schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + OUTPUTCHAIN.to_string(), + Some(types::NfChainType::NAT), + Some(types::NfHook::Output), + Some(-100), // Prio -100 == dnat + None, + Some(types::NfChainPolicy::Accept), + ))); + + // dnat rules. Not used here, but need to be created first, because they have rules that must be first in their chains. + // A lot of these are thus conditional on if the rule already exists or not. + let existing_rules = helper::get_current_ruleset(None, None)?; + + // Two extra chains, not hooked to anything, for our NAT pf rules + batch.add(make_basic_chain(DNATCHAIN)); + batch.add(make_basic_chain(MASKCHAIN)); + + // Postrouting chain needs a single rule to masquerade if mask is set. + // But only one copy of that rule. So check if such a rule exists. + let match_meta_masq = |r: &schema::Rule| -> bool { + // Match on any rule that matches against 0x2000 + for statement in &r.expr { + match statement { + stmt::Statement::Match(m) => match &m.right { + expr::Expression::Number(n) => { + if *n == MASK { + return true; + } + } + _ => continue, + }, + _ => continue, + } + } + false + }; + if get_matching_rules_in_chain(&existing_rules, POSTROUTINGCHAIN, match_meta_masq) + .is_empty() + { + // Postrouting: meta mark & 0x2000 == 0x2000 masquerade + batch.add(make_rule( + POSTROUTINGCHAIN, + vec![ + stmt::Statement::Match(stmt::Match { + left: expr::Expression::BinaryOperation(expr::BinaryOperation::AND( + Box::new(expr::Expression::Named(expr::NamedExpression::Meta( + expr::Meta { + key: expr::MetaKey::Mark, + }, + ))), + Box::new(expr::Expression::Number(MASK)), + )), + right: expr::Expression::Number(MASK), + op: stmt::Operator::EQ, + }), + stmt::Statement::Masquerade(None), + ], + )); + } + + // Mask chain needs a single rule to apply the mask. + // But only one copy of that rule. So check if such a rule exists. + let match_meta_mark = |r: &schema::Rule| -> bool { + // Match on any mangle rule. + for statement in &r.expr { + match statement { + stmt::Statement::Mangle(_) => return true, + _ => continue, + } + } + false + }; + if get_matching_rules_in_chain(&existing_rules, MASKCHAIN, match_meta_mark).is_empty() { + // Mask chain: mark or 0x2000 + batch.add(make_rule( + MASKCHAIN, + vec![stmt::Statement::Mangle(stmt::Mangle { + key: expr::Expression::Named(expr::NamedExpression::Meta(expr::Meta { + key: expr::MetaKey::Mark, + })), + value: expr::Expression::BinaryOperation(expr::BinaryOperation::OR( + Box::new(expr::Expression::Named(expr::NamedExpression::Meta( + expr::Meta { + key: expr::MetaKey::Mark, + }, + ))), + Box::new(expr::Expression::Number(MASK)), + )), + })], + )); + } + + // We need rules in Prerouting and Output pointing to our dnat chain. + // But only if they do not exist. + let match_jump_dnat = get_rule_matcher_jump_to(DNATCHAIN.to_string()); + // Prerouting: fib daddr type local jump + // Output: fib daddr type local jump + let mut rules_hash: HashSet = HashSet::new(); + rules_hash.insert(expr::FibFlag::Daddr); + let base_conditions: Vec = vec![ + stmt::Statement::Match(stmt::Match { + left: expr::Expression::Named(expr::NamedExpression::Fib(expr::Fib { + result: expr::FibResult::Type, + flags: rules_hash, + })), + right: expr::Expression::String("local".to_string()), + op: stmt::Operator::EQ, + }), + get_jump_action(DNATCHAIN), + ]; + if get_matching_rules_in_chain(&existing_rules, PREROUTINGCHAIN, &match_jump_dnat) + .is_empty() + { + batch.add(make_rule(PREROUTINGCHAIN, base_conditions.clone())); + } + if get_matching_rules_in_chain(&existing_rules, OUTPUTCHAIN, &match_jump_dnat).is_empty() { + batch.add(make_rule(OUTPUTCHAIN, base_conditions.clone())); + } + + // Basic forwarding for all subnets + if let Some(nets) = network_setup.subnets { + for subnet in nets { + let chain = get_subnet_chain_name(subnet); + + // Do we already have a chain for the subnet? + if get_chain(&existing_rules, &chain).is_some() { + continue; + } + + // We don't. Make one. + batch.add(make_basic_chain(&chain)); + + log::info!("Creating container chain {chain}"); + + // Subnet chain: ip daddr accept + batch.add(make_rule( + &chain, + vec![ + get_subnet_match(&subnet, "daddr", stmt::Operator::EQ), + stmt::Statement::Accept(None), + ], + )); + + // Subnet chain: ip daddr != 224.0.0.0/4 masquerade + batch.add(make_rule( + &chain, + vec![ + get_subnet_match(&("224.0.0.0/4".parse()?), "daddr", stmt::Operator::NEQ), + stmt::Statement::Masquerade(None), + ], + )); + + // Next, populate basic chains with forwarding rules + // Input chain: ip saddr udp dport 53 accept + batch.add(make_rule( + INPUTCHAIN, + vec![ + get_subnet_match(&subnet, "saddr", stmt::Operator::EQ), + stmt::Statement::Match(stmt::Match { + left: expr::Expression::Named(expr::NamedExpression::Payload( + expr::Payload { + protocol: "udp".to_string(), + field: "dport".to_string(), + }, + )), + right: expr::Expression::Number(53), + op: stmt::Operator::EQ, + }), + stmt::Statement::Accept(None), + ], + )); + // Forward chain: ip daddr ct state related,established accept + batch.add(make_rule( + FORWARDCHAIN, + vec![ + get_subnet_match(&subnet, "daddr", stmt::Operator::EQ), + stmt::Statement::Match(stmt::Match { + left: expr::Expression::Named(expr::NamedExpression::CT(expr::CT { + key: "state".to_string(), + family: None, + dir: None, + })), + right: expr::Expression::List(vec![ + expr::Expression::String("established".to_string()), + expr::Expression::String("related".to_string()), + ]), + op: stmt::Operator::IN, + }), + stmt::Statement::Accept(None), + ], + )); + // Forward chain: ip saddr accept + batch.add(make_rule( + FORWARDCHAIN, + vec![ + get_subnet_match(&subnet, "saddr", stmt::Operator::EQ), + stmt::Statement::Accept(None), + ], + )); + // Postrouting chain: ip saddr jump + batch.add(make_rule( + POSTROUTINGCHAIN, + vec![ + get_subnet_match(&subnet, "saddr", stmt::Operator::EQ), + get_jump_action(&chain), + ], + )); + } + } + + let rules = batch.to_nftables(); + + helper::apply_ruleset(&rules, None, None)?; + + Ok(()) + } + + fn teardown_network(&self, tear: internal_types::TearDownNetwork) -> NetavarkResult<()> { + if !tear.complete_teardown { + log::info!("Nothing to tear down, network still in use"); + + return Ok(()); + } + + let mut batch = Batch::new(); + + let existing_rules = helper::get_current_ruleset(None, None)?; + + if let Some(nets) = tear.config.subnets { + for subnet in nets { + // Match subnet, either saddr or daddr. + let match_subnet = |r: &schema::Rule| -> bool { + // Statement matching: We only care about match statements. + // Don't bother with left side. Just check if what they compare to is our subnet. + for statement in &r.expr { + match statement { + stmt::Statement::Match(m) => match &m.right { + expr::Expression::Named(expr::NamedExpression::Prefix(p)) => { + match *p.addr.clone() { + expr::Expression::String(s) => { + if s.clone() == subnet.addr().to_string() { + return true; + } + } + _ => continue, + } + } + _ => continue, + }, + _ => continue, + } + } + false + }; + + let mut to_remove: Vec = Vec::new(); + to_remove.append(&mut get_matching_rules_in_chain( + &existing_rules, + INPUTCHAIN, + match_subnet, + )); + to_remove.append(&mut get_matching_rules_in_chain( + &existing_rules, + FORWARDCHAIN, + match_subnet, + )); + to_remove.append(&mut get_matching_rules_in_chain( + &existing_rules, + POSTROUTINGCHAIN, + match_subnet, + )); + + log::debug!("Removing {} rules", to_remove.len()); + + for rule in to_remove { + batch.delete(schema::NfListObject::Rule(rule)); + } + + // Delete the chain last + let chain = get_subnet_chain_name(subnet); + if let Some(c) = get_chain(&existing_rules, &chain) { + batch.delete(schema::NfListObject::Chain(c)); + } + } + } + + let rules = batch.to_nftables(); + + helper::apply_ruleset(&rules, None, None)?; + Ok(()) + } + + fn setup_port_forward( + &self, + setup_portfw: internal_types::PortForwardConfig, + ) -> NetavarkResult<()> { + let mut batch = Batch::new(); + + let existing_rules = helper::get_current_ruleset(None, None)?; + + match setup_portfw.port_mappings { + Some(ports) => { + // Each container has a chain for its port-forwarding rules. + let ctr_dnat_chain = get_container_dnat_chain(setup_portfw.container_id); + + // Does it already exist? If so, assume this is a duplicate setup run, do nothing. + if get_chain(&existing_rules, &ctr_dnat_chain).is_some() { + return Ok(()); + } + + batch.add(make_basic_chain(&ctr_dnat_chain)); + + for port in ports { + // Condition to match destination ports (ports on the host) + let dport_cond = get_dport_cond(port); + // Destination address is only if user set an IP on the host to bind to. + // Used by multiple rules in this section. + let daddr_statement: Option = if !port.host_ip.is_empty() { + Some(get_ip_match( + &(port.host_ip.parse()?), + "daddr", + stmt::Operator::EQ, + )) + } else { + None + }; + + // dnat chain: dport jump + batch.add(make_rule( + DNATCHAIN, + vec![dport_cond.clone(), get_jump_action(&ctr_dnat_chain)], + )); + + if let Some(v4) = setup_portfw.container_ip_v4 { + // Container dnat chain: ip saddr ip daddr dport jump SETMARKCHAIN + batch.add(get_subnet_dport_match( + &ctr_dnat_chain, + &setup_portfw.subnet_v4, + &daddr_statement, + &dport_cond, + )); + + // Container dnat chain: ip saddr 127.0.0.1 ip daddr dport jump SETMARKCHAIN + // Unlike the others this is v4 only, so not abstracted into a function. + let mut localhost_jump_statements: Vec = Vec::new(); + localhost_jump_statements.push(get_ip_match( + &("127.0.0.1".parse()?), + "saddr", + stmt::Operator::EQ, + )); + if let Some(stmt) = &daddr_statement { + localhost_jump_statements.push(stmt.clone()); + } + localhost_jump_statements.push(dport_cond.clone()); + localhost_jump_statements.push(get_jump_action(MASKCHAIN)); + batch.add(make_rule(&ctr_dnat_chain, localhost_jump_statements)); + + for rule in + get_dnat_port_rules(&ctr_dnat_chain, port, &v4, &daddr_statement, false) + { + batch.add(rule.clone()); + } + } + if let Some(v6) = setup_portfw.container_ip_v6 { + // Container dnat chain: ip saddr ip daddr dport jump SETMARKCHAIN + batch.add(get_subnet_dport_match( + &ctr_dnat_chain, + &setup_portfw.subnet_v6, + &daddr_statement, + &dport_cond, + )); + + for rule in + get_dnat_port_rules(&ctr_dnat_chain, port, &v6, &daddr_statement, true) + { + batch.add(rule.clone()); + } + } + } + } + None => {} + } + + let rules = batch.to_nftables(); + + helper::apply_ruleset(&rules, None, None)?; + + Ok(()) + } + + fn teardown_port_forward( + &self, + teardown_pf: internal_types::TeardownPortForward, + ) -> NetavarkResult<()> { + let mut batch = Batch::new(); + + let existing_rules = helper::get_current_ruleset(None, None)?; + + let ctr_dnat_chain = get_container_dnat_chain(teardown_pf.config.container_id); + // Make a match rule to catch anything that jumps to container DNAT chain. + let match_jump_ctr_dnat = get_rule_matcher_jump_to(ctr_dnat_chain.clone()); + + let dnat_rules = + get_matching_rules_in_chain(&existing_rules, DNATCHAIN, &match_jump_ctr_dnat); + for rule in dnat_rules { + batch.delete(schema::NfListObject::Rule(rule)); + } + + // Each container has a chain for its port-forwarding rules. + // Remove it. Do this last so all references are gone first. + batch.delete(make_basic_chain(&ctr_dnat_chain)); + + let rules = batch.to_nftables(); + + helper::apply_ruleset(&rules, None, None)?; + + Ok(()) + } +} + +/// Convert a subnet into a chain name. +fn get_subnet_chain_name(subnet: IpNet) -> String { + // nftables is very lenient around chain name lengths. + // So let's use the full IP to be unambiguous. + // Replace . and : with _, and / with _nm (netmask), to remove special characters. + let subnet_clean = subnet + .to_string() + .replace('.', "_") + .replace(':', "-") + .replace('/', "_nm"); + + format!("nv_{}", subnet_clean) +} + +/// Convert a container ID into a dnat chain name +fn get_container_dnat_chain(cid: String) -> String { + format!("ctr_{}_dnat", cid) +} + +/// Get a statement to match the given IP address. +/// Field should be either "saddr" or "daddr" for matching source or destination. +fn get_ip_match(ip: &IpAddr, field: &str, op: stmt::Operator) -> stmt::Statement { + stmt::Statement::Match(stmt::Match { + left: ip_to_payload(ip, field), + right: expr::Expression::String(ip.to_string()), + op, + }) +} + +/// Convert a single IP into a Payload field. +/// Basically, pasts in "ip" or "ipv6" in protocol field based on whether this is a v4 or v6 address. +fn ip_to_payload(addr: &IpAddr, field: &str) -> expr::Expression { + let proto = match addr { + IpAddr::V4(_) => "ip".to_string(), + IpAddr::V6(_) => "ipv6".to_string(), + }; + + expr::Expression::Named(expr::NamedExpression::Payload(expr::Payload { + protocol: proto, + field: field.to_string(), + })) +} + +/// Get a statement to match the given subnet. +/// Field should be either "saddr" or "daddr" for matching source or destination. +fn get_subnet_match(net: &IpNet, field: &str, op: stmt::Operator) -> stmt::Statement { + stmt::Statement::Match(stmt::Match { + left: subnet_to_payload(net, field), + right: expr::Expression::Named(expr::NamedExpression::Prefix(expr::Prefix { + addr: Box::new(expr::Expression::String(net.addr().to_string())), + len: net.prefix_len() as u32, + })), + op, + }) +} + +/// Convert a subnet into a Payload field. +/// Basically, pastes in "ip" or "ipv6" in protocol field based on whether this +/// is a v4 or v6 subnet. +fn subnet_to_payload(net: &IpNet, field: &str) -> expr::Expression { + let proto = match net { + IpNet::V4(_) => "ip".to_string(), + IpNet::V6(_) => "ipv6".to_string(), + }; + + expr::Expression::Named(expr::NamedExpression::Payload(expr::Payload { + protocol: proto, + field: field.to_string(), + })) +} + +/// Get a condition to match destination port/ports based on a given PortMapping. +/// Properly handles port ranges, protocol, etc. +fn get_dport_cond(port: &PortMapping) -> stmt::Statement { + stmt::Statement::Match(stmt::Match { + left: expr::Expression::Named(expr::NamedExpression::Payload(expr::Payload { + protocol: port.protocol.clone(), + field: "dport".to_string(), + })), + right: if port.range > 1 { + // Ranges are a vector with a length of 2. + // First value start, second value end. + let range_vec = vec![ + expr::Expression::Number(port.host_port as u32), + expr::Expression::Number((port.host_port + port.range - 1) as u32), + ]; + expr::Expression::Range(expr::Range { range: range_vec }) + } else { + expr::Expression::Number(port.host_port as u32) + }, + op: stmt::Operator::EQ, + }) +} + +/// Make the first container DNAT chain rule, which is used for both IP and IPv6 DNAT. +fn get_subnet_dport_match( + dnat_chain: &str, + subnet: &Option, + host_ip_match: &Option, + dport_match: &stmt::Statement, +) -> schema::NfListObject { + // ip saddr ip daddr dport jump MARKCHAIN + let mut statements: Vec = Vec::new(); + if let Some(net) = &subnet { + statements.push(get_subnet_match(net, "saddr", stmt::Operator::EQ)); + } + if let Some(stmt) = &host_ip_match { + statements.push(stmt.clone()); + } + statements.push(dport_match.clone()); + statements.push(get_jump_action(MASKCHAIN)); + make_rule(dnat_chain, statements) +} + +/// Create DNAT rules for each port to be forwarded. +/// Used for both IP and IPv6 DNAT. +fn get_dnat_port_rules( + dnat_chain: &str, + port: &PortMapping, + ip: &IpAddr, + host_ip_match: &Option, + is_v6: bool, +) -> Vec { + let mut rules: Vec = Vec::new(); + + // Container dnat chain: ip daddr dport dnat to + // Unfortunately: We don't have range support in the schema. So we need 1 rule per port. + let range = if port.range == 0 { 1 } else { port.range }; + for i in 0..range { + let host_port: u32 = (port.host_port + i) as u32; + let ctr_port: u32 = (port.container_port + i) as u32; + + let mut statements: Vec = Vec::new(); + if let Some(stmt) = host_ip_match { + statements.push(stmt.clone()); + } + statements.push(stmt::Statement::Match(stmt::Match { + left: expr::Expression::Named(expr::NamedExpression::Payload(expr::Payload { + protocol: port.protocol.clone(), + field: "dport".to_string(), + })), + right: expr::Expression::Number(host_port), + op: stmt::Operator::EQ, + })); + statements.push(stmt::Statement::DNAT(Some(stmt::NAT { + addr: Some(expr::Expression::String(ip.to_string())), + family: Some(if is_v6 { + stmt::NATFamily::IP6 + } else { + stmt::NATFamily::IP + }), + port: Some(ctr_port), + flags: None, + }))); + rules.push(make_rule(dnat_chain, statements)); + } + + rules +} + +/// Create a statement to jump to the given target +fn get_jump_action(target: &str) -> stmt::Statement { + stmt::Statement::Jump(stmt::JumpTarget { + target: target.to_string(), + }) +} + +/// Create an instruction to make a basic chain (no hooks, no priority). +/// Chain is always inet, always in our overall netavark table. +fn make_basic_chain(name: &str) -> schema::NfListObject { + schema::NfListObject::Chain(schema::Chain::new( + types::NfFamily::INet, + TABLENAME.to_string(), + name.to_string(), + None, + None, + None, + None, + None, + )) +} + +/// Make a rule in the given chain with the given conditions +fn make_rule(chain: &str, conditions: Vec) -> schema::NfListObject { + schema::NfListObject::Rule(schema::Rule::new( + types::NfFamily::INet, + TABLENAME.to_string(), + chain.to_string(), + conditions, + )) +} + +/// Make a closure that matches any rule that jumps to the given chain. +fn get_rule_matcher_jump_to(jump_target: String) -> Box bool> { + Box::new(move |r: &schema::Rule| -> bool { + for statement in &r.expr { + match statement { + stmt::Statement::Jump(j) => { + return j.target == jump_target; + } + _ => continue, + } + } + false + }) +} + +/// Find all rules in the given chain which match the given closure (true == include). +/// Returns all those rules, in a vector. Vector will be empty if there are none. +fn get_matching_rules_in_chain bool>( + base_rules: &schema::Nftables, + chain: &str, + rule_match: F, +) -> Vec { + let mut rules: Vec = Vec::new(); + + // Basically, we get back a big, flat array of everything in the table. + // That makes this an absolute destructuring nightmare, but there's no avoiding it. + // Ignore everything we get back that is not a rule. + // Then ignore everything that is not in our table (not passed, but we only use one table). + // Then ignore everything that is not in the given chain. + // Then check conditions and add to the vector if it matches. + for object in &base_rules.objects { + match object { + schema::NfObject::CmdObject(_) => continue, + schema::NfObject::ListObject(obj) => match obj { + schema::NfListObject::Rule(r) => { + if r.table != *TABLENAME { + continue; + } + if r.chain != *chain { + continue; + } + + if rule_match(r) { + log::debug!("Matched {:?}", r); + rules.push(r.clone()); + } + } + _ => continue, + }, + } + } + + rules +} + +/// Get a chain with the given name in the Netavark table. +fn get_chain(base_rules: &schema::Nftables, chain: &str) -> Option { + for object in &base_rules.objects { + match object { + schema::NfObject::CmdObject(_) => continue, + schema::NfObject::ListObject(obj) => match obj { + schema::NfListObject::Chain(c) => { + if c.table != *TABLENAME { + continue; + } + if c.name == *chain { + log::debug!("Found chain {}", chain); + return Some(c.clone()); + } + } + _ => continue, + }, + } + } + + None +}