use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirewallRule {
pub protocol: String,
pub from_port: i64,
pub to_port: i64,
pub cidr: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceFirewall {
pub private_ip: String,
pub ingress: Vec<FirewallRule>,
pub egress: Vec<FirewallRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceRules {
pub instance_id: String,
pub subnet_id: String,
pub private_ip: String,
pub ingress: Vec<FirewallRule>,
pub egress: Vec<FirewallRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NaclRule {
pub rule_number: i64,
pub egress: bool,
pub allow: bool,
pub protocol: String,
pub from_port: i64,
pub to_port: i64,
pub cidr: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubnetFirewall {
pub network_name: String,
pub instances: Vec<InstanceFirewall>,
pub nacl: Vec<NaclRule>,
}
const TABLE: &str = "inet fakecloud_ec2";
pub fn render_ruleset(subnets: &[SubnetFirewall]) -> String {
let mut out = String::new();
out.push_str(&format!("add table {TABLE}\n"));
out.push_str(&format!("flush table {TABLE}\n"));
out.push_str(&format!("table {TABLE} {{\n"));
out.push_str(" chain forward {\n");
out.push_str(" type filter hook forward priority -5; policy accept;\n");
out.push_str(" ct state established,related accept\n");
for subnet in subnets {
out.push_str(&format!(" # subnet {}\n", subnet.network_name));
let mut ordered = subnet.nacl.clone();
ordered.sort_by_key(|r| r.rule_number);
for (i, rule) in ordered.iter().enumerate() {
if rule.allow {
continue;
}
let shadowed = ordered[..i]
.iter()
.any(|earlier| earlier.allow && nacl_same_traffic(earlier, rule));
if shadowed {
continue;
}
if let Some(line) = render_nacl_drop(rule) {
out.push_str(&format!(" {line}\n"));
}
}
for inst in &subnet.instances {
for rule in &inst.ingress {
out.push_str(&format!(
" {}\n",
render_rule(rule, Direction::Ingress, &inst.private_ip)
));
}
out.push_str(&format!(
" ip daddr {} drop comment \"default-deny ingress\"\n",
inst.private_ip
));
for rule in &inst.egress {
out.push_str(&format!(
" {}\n",
render_rule(rule, Direction::Egress, &inst.private_ip)
));
}
out.push_str(&format!(
" ip saddr {} drop comment \"default-deny egress\"\n",
inst.private_ip
));
}
}
out.push_str(" }\n");
out.push_str("}\n");
out
}
#[derive(Clone, Copy)]
enum Direction {
Ingress,
Egress,
}
fn render_rule(rule: &FirewallRule, dir: Direction, instance_ip: &str) -> String {
let mut parts = Vec::new();
match dir {
Direction::Ingress => {
parts.push(format!("ip daddr {instance_ip}"));
if let Some(cidr) = normalized_cidr(&rule.cidr) {
parts.push(format!("ip saddr {cidr}"));
}
}
Direction::Egress => {
parts.push(format!("ip saddr {instance_ip}"));
if let Some(cidr) = normalized_cidr(&rule.cidr) {
parts.push(format!("ip daddr {cidr}"));
}
}
}
push_proto_ports(&mut parts, &rule.protocol, rule.from_port, rule.to_port);
parts.push("accept".to_string());
parts.join(" ")
}
fn nacl_same_traffic(a: &NaclRule, b: &NaclRule) -> bool {
a.egress == b.egress
&& a.protocol == b.protocol
&& a.from_port == b.from_port
&& a.to_port == b.to_port
&& a.cidr == b.cidr
}
fn render_nacl_drop(rule: &NaclRule) -> Option<String> {
if rule.allow {
return None;
}
let mut parts = Vec::new();
if let Some(cidr) = normalized_cidr(&rule.cidr) {
if rule.egress {
parts.push(format!("ip daddr {cidr}"));
} else {
parts.push(format!("ip saddr {cidr}"));
}
}
push_proto_ports(&mut parts, &rule.protocol, rule.from_port, rule.to_port);
parts.push("drop".to_string());
parts.push("comment \"nacl-deny\"".to_string());
Some(parts.join(" "))
}
fn push_proto_ports(parts: &mut Vec<String>, protocol: &str, from: i64, to: i64) {
match protocol {
"-1" | "" => {}
"icmp" | "1" => parts.push("ip protocol icmp".to_string()),
proto @ ("tcp" | "udp" | "6" | "17") => {
let p = match proto {
"6" => "tcp",
"17" => "udp",
other => other,
};
parts.push(p.to_string());
if from >= 0 && to >= 0 {
if from == to {
parts.push(format!("dport {from}"));
} else {
parts.push(format!("dport {from}-{to}"));
}
}
}
other if other.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
parts.push(format!("ip protocol {other}"))
}
_ => {}
}
}
fn normalized_cidr(cidr: &Option<String>) -> Option<String> {
let c = cidr.as_deref()?;
if c == "0.0.0.0/0" || c.is_empty() {
return None;
}
if !c
.chars()
.all(|ch| ch.is_ascii_hexdigit() || matches!(ch, '.' | ':' | '/'))
{
return None;
}
Some(c.trim_end_matches("/32").to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnforcementMode {
Nftables,
Disabled,
}
pub fn resolve_enforcement_mode(
env: Option<&str>,
host_local: bool,
nft_probe: impl FnOnce() -> bool,
) -> EnforcementMode {
let opted_in = matches!(
env.map(|v| v.to_ascii_lowercase()).as_deref(),
Some("1") | Some("true") | Some("nftables") | Some("on")
);
if !opted_in || !host_local {
return EnforcementMode::Disabled;
}
if nft_probe() {
EnforcementMode::Nftables
} else {
EnforcementMode::Disabled
}
}
pub fn host_shares_daemon_netns() -> bool {
cfg!(target_os = "linux")
}
pub fn nft_available() -> bool {
std::process::Command::new("nft")
.args(["list", "ruleset"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn group_by_subnet(
instances: Vec<(String, InstanceFirewall)>,
nacls: BTreeMap<String, Vec<NaclRule>>,
) -> Vec<SubnetFirewall> {
let mut by_net: BTreeMap<String, Vec<InstanceFirewall>> = BTreeMap::new();
for (network_name, inst) in instances {
by_net.entry(network_name).or_default().push(inst);
}
by_net
.into_iter()
.map(|(network_name, mut instances)| {
instances.sort_by(|a, b| a.private_ip.cmp(&b.private_ip));
let nacl = nacls.get(&network_name).cloned().unwrap_or_default();
SubnetFirewall {
network_name,
instances,
nacl,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tcp(port: i64, cidr: Option<&str>) -> FirewallRule {
FirewallRule {
protocol: "tcp".into(),
from_port: port,
to_port: port,
cidr: cidr.map(str::to_string),
}
}
#[test]
fn renders_allow_then_default_deny_for_ingress() {
let model = vec![SubnetFirewall {
network_name: "fakecloud-subnet-a".into(),
instances: vec![InstanceFirewall {
private_ip: "172.30.0.2".into(),
ingress: vec![tcp(22, Some("10.0.0.0/8"))],
egress: vec![],
}],
nacl: vec![],
}];
let rs = render_ruleset(&model);
let add = rs.find("add table inet fakecloud_ec2").expect("add table");
let flush = rs
.find("flush table inet fakecloud_ec2")
.expect("flush table");
assert!(add < flush, "add table must come before flush:\n{rs}");
assert!(rs.contains("ct state established,related accept"));
assert!(rs.contains("ip daddr 172.30.0.2 ip saddr 10.0.0.0/8 tcp dport 22 accept"));
assert!(rs.contains("ip daddr 172.30.0.2 drop comment \"default-deny ingress\""));
assert!(rs.contains("ip saddr 172.30.0.2 drop comment \"default-deny egress\""));
}
#[test]
fn all_protocols_and_anywhere_omit_match_clauses() {
let rule = FirewallRule {
protocol: "-1".into(),
from_port: -1,
to_port: -1,
cidr: Some("0.0.0.0/0".into()),
};
let line = render_rule(&rule, Direction::Ingress, "172.30.0.5");
assert_eq!(line, "ip daddr 172.30.0.5 accept");
}
#[test]
fn port_range_and_single_port() {
let range = FirewallRule {
protocol: "tcp".into(),
from_port: 8000,
to_port: 8100,
cidr: None,
};
assert!(render_rule(&range, Direction::Egress, "172.30.0.9")
.contains("tcp dport 8000-8100 accept"));
assert!(
render_rule(&tcp(443, None), Direction::Ingress, "172.30.0.9")
.contains("tcp dport 443 accept")
);
}
#[test]
fn icmp_and_numeric_protocols() {
let icmp = FirewallRule {
protocol: "icmp".into(),
from_port: -1,
to_port: -1,
cidr: None,
};
assert!(render_rule(&icmp, Direction::Ingress, "172.30.0.2").contains("ip protocol icmp"));
let udp = FirewallRule {
protocol: "17".into(),
from_port: 53,
to_port: 53,
cidr: None,
};
assert!(render_rule(&udp, Direction::Ingress, "172.30.0.2").contains("udp dport 53"));
}
#[test]
fn host_cidr_strips_slash_32() {
let r = tcp(22, Some("203.0.113.7/32"));
assert!(render_rule(&r, Direction::Ingress, "172.30.0.2")
.contains("ip saddr 203.0.113.7 tcp dport 22"));
}
#[test]
fn cidr_with_nft_metacharacters_is_dropped_not_injected() {
let r = tcp(22, Some("10.0.0.0/8; drop comment \"x\""));
let line = render_rule(&r, Direction::Ingress, "172.30.0.2");
assert!(!line.contains(';'), "no injected semicolon: {line}");
assert!(!line.contains("comment"), "no injected tokens: {line}");
assert!(
!line.contains("ip saddr"),
"malformed cidr clause omitted: {line}"
);
assert!(line.ends_with("accept"), "rule still valid: {line}");
}
#[test]
fn unknown_protocol_with_bad_chars_emits_no_proto_match() {
let r = FirewallRule {
protocol: "tcp; drop".into(),
from_port: -1,
to_port: -1,
cidr: None,
};
let line = render_rule(&r, Direction::Ingress, "172.30.0.2");
assert!(
!line.contains(';') && !line.contains("ip protocol"),
"{line}"
);
assert_eq!(line, "ip daddr 172.30.0.2 accept");
}
#[test]
fn nacl_deny_emitted_before_instance_rules() {
let model = vec![SubnetFirewall {
network_name: "fakecloud-subnet-a".into(),
instances: vec![InstanceFirewall {
private_ip: "172.30.0.2".into(),
ingress: vec![],
egress: vec![],
}],
nacl: vec![NaclRule {
rule_number: 100,
egress: false,
allow: false,
protocol: "tcp".into(),
from_port: 3389,
to_port: 3389,
cidr: Some("198.51.100.0/24".into()),
}],
}];
let rs = render_ruleset(&model);
let deny = rs
.find("ip saddr 198.51.100.0/24 tcp dport 3389 drop")
.unwrap();
let inst = rs.find("ip daddr 172.30.0.2 drop").unwrap();
assert!(
deny < inst,
"nacl deny must precede the instance default-deny"
);
assert!(!rs.contains("nacl-allow"));
}
#[test]
fn nacl_lower_numbered_allow_shadows_higher_numbered_deny() {
let nacl_entry = |rule_number, allow| NaclRule {
rule_number,
egress: false,
allow,
protocol: "tcp".into(),
from_port: 22,
to_port: 22,
cidr: Some("10.0.0.0/8".into()),
};
let model = vec![SubnetFirewall {
network_name: "fakecloud-subnet-a".into(),
instances: vec![InstanceFirewall {
private_ip: "172.30.0.2".into(),
ingress: vec![],
egress: vec![],
}],
nacl: vec![nacl_entry(200, false), nacl_entry(100, true)],
}];
let rs = render_ruleset(&model);
assert!(
!rs.contains("ip saddr 10.0.0.0/8 tcp dport 22 drop"),
"a lower-numbered allow must shadow the deny:\n{rs}"
);
let model2 = vec![SubnetFirewall {
network_name: "fakecloud-subnet-a".into(),
instances: vec![],
nacl: vec![nacl_entry(100, false), nacl_entry(200, true)],
}];
assert!(render_ruleset(&model2).contains("ip saddr 10.0.0.0/8 tcp dport 22 drop"));
}
#[test]
fn enforcement_mode_is_opt_in_and_capability_gated() {
assert_eq!(
resolve_enforcement_mode(None, true, || true),
EnforcementMode::Disabled
);
assert_eq!(
resolve_enforcement_mode(Some("0"), true, || true),
EnforcementMode::Disabled
);
assert_eq!(
resolve_enforcement_mode(Some("1"), true, || false),
EnforcementMode::Disabled
);
assert_eq!(
resolve_enforcement_mode(Some("1"), false, || true),
EnforcementMode::Disabled
);
assert_eq!(
resolve_enforcement_mode(Some("nftables"), true, || true),
EnforcementMode::Nftables
);
assert_eq!(
resolve_enforcement_mode(Some("TRUE"), true, || true),
EnforcementMode::Nftables
);
}
#[test]
fn group_by_subnet_sorts_and_attaches_nacls() {
let instances = vec![
(
"net-a".to_string(),
InstanceFirewall {
private_ip: "172.30.0.9".into(),
ingress: vec![],
egress: vec![],
},
),
(
"net-a".to_string(),
InstanceFirewall {
private_ip: "172.30.0.2".into(),
ingress: vec![],
egress: vec![],
},
),
];
let mut nacls = BTreeMap::new();
nacls.insert(
"net-a".to_string(),
vec![NaclRule {
rule_number: 100,
egress: false,
allow: false,
protocol: "-1".into(),
from_port: -1,
to_port: -1,
cidr: Some("10.0.0.0/8".into()),
}],
);
let grouped = group_by_subnet(instances, nacls);
assert_eq!(grouped.len(), 1);
assert_eq!(grouped[0].instances[0].private_ip, "172.30.0.2");
assert_eq!(grouped[0].instances[1].private_ip, "172.30.0.9");
assert_eq!(grouped[0].nacl.len(), 1);
}
}