Skip to main content

fakecloud_ec2/runtime/
firewall.rs

1//! Security-group + network-ACL packet filtering (issue #1745 phase 3).
2//!
3//! Phase 2 isolates instances at L3 by giving each subnet its own daemon
4//! bridge. That stops cross-VPC traffic but does nothing *within* a subnet —
5//! security-group and NACL rules still block nothing. This module closes that
6//! gap by translating the SG/NACL model into an **nftables** ruleset and
7//! applying it on the host, scoped to fakecloud's per-subnet bridges.
8//!
9//! ## Why nftables, and why opt-in
10//!
11//! Real packet filtering needs `CAP_NET_ADMIN`, which instance containers
12//! deliberately don't have. nftables (over iptables) is chosen for its atomic
13//! ruleset swaps — a clean fit for the dynamic Authorize/Revoke churn of
14//! security groups. Because applying host firewall rules is privileged and can
15//! interfere with a user's own networking, enforcement is **opt-in** via
16//! `FAKECLOUD_EC2_SG_ENFORCEMENT` and **degrades gracefully**: when nft or
17//! `CAP_NET_ADMIN` is missing (CI, Docker Desktop, rootless podman) the driver
18//! logs one warning and falls back to metadata-only — phase-2 isolation still
19//! holds, exactly as before (no regression).
20//!
21//! ## What's tested where
22//!
23//! The translation from the SG/NACL model to the nft ruleset
24//! ([`render_ruleset`]) is pure and exhaustively unit-tested. The apply path
25//! shells out to `nft -f -`; it cannot be exercised in CI (no `CAP_NET_ADMIN`),
26//! so it is kept thin and the *generated ruleset* is the verified artifact.
27
28use std::collections::BTreeMap;
29
30/// A single allow rule flattened out of a security group: one protocol/port
31/// range from one CIDR (referenced-group and prefix-list sources are resolved
32/// to CIDRs by the caller, or dropped when they can't be).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FirewallRule {
35    /// `tcp` | `udp` | `icmp` | `-1` (all protocols).
36    pub protocol: String,
37    /// Port range; `-1`/`-1` means "all ports" (omit the port match).
38    pub from_port: i64,
39    pub to_port: i64,
40    /// Source (ingress) / destination (egress) IPv4 CIDR. `None` = anywhere.
41    pub cidr: Option<String>,
42}
43
44/// One instance's firewall view: its address on the subnet bridge plus the
45/// ingress/egress rules flattened from every security group attached to it.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct InstanceFirewall {
48    pub private_ip: String,
49    pub ingress: Vec<FirewallRule>,
50    pub egress: Vec<FirewallRule>,
51}
52
53/// One running instance's flattened firewall view, keyed by both its id (for
54/// the k8s NetworkPolicy `podSelector`) and its IP (for nft). The shared
55/// intermediate the service layer produces from EC2 state; the nft model
56/// builder and the k8s NetworkPolicy builder both consume it.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct InstanceRules {
59    pub instance_id: String,
60    pub subnet_id: String,
61    pub private_ip: String,
62    pub ingress: Vec<FirewallRule>,
63    pub egress: Vec<FirewallRule>,
64}
65
66/// A subnet-level NACL entry. NACLs are stateless and apply to the whole
67/// subnet; AWS evaluates them in ascending `rule_number` order, first match
68/// wins (so a lower-numbered `allow` shadows a higher-numbered `deny` for the
69/// same traffic).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct NaclRule {
72    /// AWS rule number; lower numbers evaluate first.
73    pub rule_number: i64,
74    pub egress: bool,
75    /// True = allow, false = deny.
76    pub allow: bool,
77    pub protocol: String,
78    pub from_port: i64,
79    pub to_port: i64,
80    pub cidr: Option<String>,
81}
82
83/// Everything needed to render the firewall for one subnet bridge.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SubnetFirewall {
86    /// The daemon network name (`fakecloud-subnet-<id>`); doubles as the nft
87    /// chain comment so a human reading `nft list ruleset` can see which subnet
88    /// a rule belongs to.
89    pub network_name: String,
90    pub instances: Vec<InstanceFirewall>,
91    pub nacl: Vec<NaclRule>,
92}
93
94/// The nftables table fakecloud owns. Kept in its own table so a full
95/// `flush table` + re-add is an atomic, side-effect-free swap that never
96/// touches docker's own iptables/nftables rules.
97const TABLE: &str = "inet fakecloud_ec2";
98
99/// Render the complete nft ruleset for a set of subnets. Deterministic
100/// (subnets and rules emitted in the order given; the caller sorts for
101/// stability) so the output can be diffed and unit-tested.
102///
103/// Model: a single `forward` chain, default-accept, that for every instance
104/// emits its allow rules followed by a default-deny to that instance's IP.
105/// Established/related traffic is accepted up front so security groups behave
106/// statefully, like AWS. NACL deny rules are emitted per subnet before the
107/// per-instance rules (stateless, subnet-wide).
108pub fn render_ruleset(subnets: &[SubnetFirewall]) -> String {
109    let mut out = String::new();
110    // `add table` first so the following `flush` doesn't error on the *first*
111    // apply (when the table doesn't exist yet) — which would fail the entire
112    // `nft -f -` load and leave enforcement silently off. `add` is idempotent;
113    // `add`+`flush`+re-add is the canonical atomic-replace idiom.
114    out.push_str(&format!("add table {TABLE}\n"));
115    out.push_str(&format!("flush table {TABLE}\n"));
116    out.push_str(&format!("table {TABLE} {{\n"));
117    out.push_str("  chain forward {\n");
118    out.push_str("    type filter hook forward priority -5; policy accept;\n");
119    // Stateful: let replies through so SG rules only need to describe the
120    // opening direction, matching AWS security-group semantics.
121    out.push_str("    ct state established,related accept\n");
122
123    for subnet in subnets {
124        out.push_str(&format!("    # subnet {}\n", subnet.network_name));
125
126        // Subnet-wide NACL denies, evaluated in ascending rule-number order so
127        // a lower-numbered `allow` shadows a higher-numbered `deny` for the
128        // same traffic (AWS first-match semantics). A deny is emitted as a drop
129        // only when no earlier-numbered allow covers the identical
130        // direction/protocol/ports/CIDR — otherwise the allow wins and the deny
131        // never fires (bug-hunt 2026-06-18 finding 1.4). NACL allows ride the
132        // default-accept policy (the SG layer below still applies; NACL and SG
133        // are independent gates, both must permit).
134        let mut ordered = subnet.nacl.clone();
135        ordered.sort_by_key(|r| r.rule_number);
136        for (i, rule) in ordered.iter().enumerate() {
137            if rule.allow {
138                continue;
139            }
140            let shadowed = ordered[..i]
141                .iter()
142                .any(|earlier| earlier.allow && nacl_same_traffic(earlier, rule));
143            if shadowed {
144                continue;
145            }
146            if let Some(line) = render_nacl_drop(rule) {
147                out.push_str(&format!("    {line}\n"));
148            }
149        }
150
151        for inst in &subnet.instances {
152            // Ingress: allow matching, then default-deny to this instance.
153            for rule in &inst.ingress {
154                out.push_str(&format!(
155                    "    {}\n",
156                    render_rule(rule, Direction::Ingress, &inst.private_ip)
157                ));
158            }
159            out.push_str(&format!(
160                "    ip daddr {} drop comment \"default-deny ingress\"\n",
161                inst.private_ip
162            ));
163
164            // Egress: allow matching, then default-deny from this instance.
165            for rule in &inst.egress {
166                out.push_str(&format!(
167                    "    {}\n",
168                    render_rule(rule, Direction::Egress, &inst.private_ip)
169                ));
170            }
171            out.push_str(&format!(
172                "    ip saddr {} drop comment \"default-deny egress\"\n",
173                inst.private_ip
174            ));
175        }
176    }
177
178    out.push_str("  }\n");
179    out.push_str("}\n");
180    out
181}
182
183#[derive(Clone, Copy)]
184enum Direction {
185    Ingress,
186    Egress,
187}
188
189/// Render one allow rule. Ingress matches on `ip daddr <instance>` (+ optional
190/// `ip saddr <cidr>`); egress mirrors it.
191fn render_rule(rule: &FirewallRule, dir: Direction, instance_ip: &str) -> String {
192    let mut parts = Vec::new();
193    match dir {
194        Direction::Ingress => {
195            parts.push(format!("ip daddr {instance_ip}"));
196            if let Some(cidr) = normalized_cidr(&rule.cidr) {
197                parts.push(format!("ip saddr {cidr}"));
198            }
199        }
200        Direction::Egress => {
201            parts.push(format!("ip saddr {instance_ip}"));
202            if let Some(cidr) = normalized_cidr(&rule.cidr) {
203                parts.push(format!("ip daddr {cidr}"));
204            }
205        }
206    }
207    push_proto_ports(&mut parts, &rule.protocol, rule.from_port, rule.to_port);
208    parts.push("accept".to_string());
209    parts.join(" ")
210}
211
212/// Whether two NACL entries match the *same* traffic (same direction,
213/// protocol, port range, CIDR) — used to decide when a lower-numbered allow
214/// shadows a higher-numbered deny. Conservative: only exact matches shadow, so
215/// partially-overlapping rules still emit their drop (safer to over-deny than
216/// to silently allow).
217fn nacl_same_traffic(a: &NaclRule, b: &NaclRule) -> bool {
218    a.egress == b.egress
219        && a.protocol == b.protocol
220        && a.from_port == b.from_port
221        && a.to_port == b.to_port
222        && a.cidr == b.cidr
223}
224
225/// Render a NACL deny as a drop line scoped to its direction + match. Returns
226/// `None` for an allow rule (allows are the default-accept policy; only denies
227/// need an explicit line).
228fn render_nacl_drop(rule: &NaclRule) -> Option<String> {
229    if rule.allow {
230        return None;
231    }
232    let mut parts = Vec::new();
233    if let Some(cidr) = normalized_cidr(&rule.cidr) {
234        // Deny traffic from (ingress) / to (egress) the CIDR.
235        if rule.egress {
236            parts.push(format!("ip daddr {cidr}"));
237        } else {
238            parts.push(format!("ip saddr {cidr}"));
239        }
240    }
241    push_proto_ports(&mut parts, &rule.protocol, rule.from_port, rule.to_port);
242    parts.push("drop".to_string());
243    parts.push("comment \"nacl-deny\"".to_string());
244    Some(parts.join(" "))
245}
246
247/// Append protocol + (for tcp/udp) destination-port matching to an nft rule.
248/// Protocol `-1` matches everything (no clause); a `-1` port range likewise
249/// omits the port match.
250fn push_proto_ports(parts: &mut Vec<String>, protocol: &str, from: i64, to: i64) {
251    match protocol {
252        "-1" | "" => {}
253        "icmp" | "1" => parts.push("ip protocol icmp".to_string()),
254        proto @ ("tcp" | "udp" | "6" | "17") => {
255            let p = match proto {
256                "6" => "tcp",
257                "17" => "udp",
258                other => other,
259            };
260            parts.push(p.to_string());
261            if from >= 0 && to >= 0 {
262                if from == to {
263                    parts.push(format!("dport {from}"));
264                } else {
265                    parts.push(format!("dport {from}-{to}"));
266                }
267            }
268        }
269        // An unrecognized protocol is interpolated into the nft script, so
270        // restrict it to the protocol-token charset `[a-z0-9-]` to avoid
271        // ruleset injection (finding 2.2); anything else emits no proto match.
272        other if other.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
273            parts.push(format!("ip protocol {other}"))
274        }
275        _ => {}
276    }
277}
278
279/// Drop `0.0.0.0/0` (which nft rejects as a no-op match) to `None`, and strip a
280/// redundant `/32` host suffix so single-host rules read cleanly.
281///
282/// Also **sanitizes**: the CIDR comes from an Authorize/RevokeSecurityGroup
283/// param and is interpolated raw into the `nft -f -` script, so a value
284/// containing nft metacharacters (whitespace, `;`, `{`, newline, …) could
285/// inject ruleset syntax. Anything outside the IPv4/IPv6-CIDR character set
286/// `[0-9a-fA-F.:/]` is rejected to `None` (the match clause is dropped, never
287/// the whole rule), closing that injection surface (bug-hunt 2026-06-18
288/// finding 2.2).
289fn normalized_cidr(cidr: &Option<String>) -> Option<String> {
290    let c = cidr.as_deref()?;
291    if c == "0.0.0.0/0" || c.is_empty() {
292        return None;
293    }
294    if !c
295        .chars()
296        .all(|ch| ch.is_ascii_hexdigit() || matches!(ch, '.' | ':' | '/'))
297    {
298        return None;
299    }
300    Some(c.trim_end_matches("/32").to_string())
301}
302
303/// How security-group enforcement is backed in this process.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum EnforcementMode {
306    /// nftables on the host (requires `CAP_NET_ADMIN` + `nft`).
307    Nftables,
308    /// Degraded: rules are tracked but not enforced (metadata-only).
309    Disabled,
310}
311
312/// Decide the enforcement mode from the environment. Enforcement is opt-in:
313/// `FAKECLOUD_EC2_SG_ENFORCEMENT` must be set to `1`/`true`/`nftables`, `nft`
314/// must be runnable, AND the daemon must run on this host's network namespace
315/// (`host_local`). `env`, `host_local`, and `nft_probe` are injected so the
316/// decision is unit-testable without touching the environment or running `nft`.
317///
318/// `host_local` guards the false-positive on Docker Desktop / podman-machine
319/// (macOS/Windows): there the per-subnet bridges live inside the daemon's Linux
320/// VM, so `nft` on the host installs rules against the wrong netfilter and
321/// silently filters nothing — yet the probe would pass on a Linux box. We treat
322/// only a native-Linux host as able to filter (bug-hunt 2026-06-18 finding 1.5),
323/// so `enforced` never claims active enforcement that can't take effect.
324pub fn resolve_enforcement_mode(
325    env: Option<&str>,
326    host_local: bool,
327    nft_probe: impl FnOnce() -> bool,
328) -> EnforcementMode {
329    let opted_in = matches!(
330        env.map(|v| v.to_ascii_lowercase()).as_deref(),
331        Some("1") | Some("true") | Some("nftables") | Some("on")
332    );
333    if !opted_in || !host_local {
334        return EnforcementMode::Disabled;
335    }
336    if nft_probe() {
337        EnforcementMode::Nftables
338    } else {
339        EnforcementMode::Disabled
340    }
341}
342
343/// Whether the container daemon shares this process's network namespace, so
344/// host nftables rules actually see the inter-container traffic. True only on a
345/// native-Linux host; Docker Desktop / podman-machine on macOS/Windows run the
346/// daemon in a separate Linux VM. (Honest default; can be overridden by the
347/// caller when fakecloud and the daemon are known to share a netns.)
348pub fn host_shares_daemon_netns() -> bool {
349    cfg!(target_os = "linux")
350}
351
352/// True when `nft list ruleset` runs successfully — i.e. nft exists and this
353/// process holds enough capability to read the ruleset (a good proxy for being
354/// able to write it).
355pub fn nft_available() -> bool {
356    std::process::Command::new("nft")
357        .args(["list", "ruleset"])
358        .stdout(std::process::Stdio::null())
359        .stderr(std::process::Stdio::null())
360        .status()
361        .map(|s| s.success())
362        .unwrap_or(false)
363}
364
365/// Group instances by their subnet network name into the per-subnet model the
366/// renderer consumes. Pure helper so the service layer can build the model from
367/// its own state without depending on render internals.
368pub fn group_by_subnet(
369    instances: Vec<(String, InstanceFirewall)>,
370    nacls: BTreeMap<String, Vec<NaclRule>>,
371) -> Vec<SubnetFirewall> {
372    let mut by_net: BTreeMap<String, Vec<InstanceFirewall>> = BTreeMap::new();
373    for (network_name, inst) in instances {
374        by_net.entry(network_name).or_default().push(inst);
375    }
376    by_net
377        .into_iter()
378        .map(|(network_name, mut instances)| {
379            instances.sort_by(|a, b| a.private_ip.cmp(&b.private_ip));
380            let nacl = nacls.get(&network_name).cloned().unwrap_or_default();
381            SubnetFirewall {
382                network_name,
383                instances,
384                nacl,
385            }
386        })
387        .collect()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn tcp(port: i64, cidr: Option<&str>) -> FirewallRule {
395        FirewallRule {
396            protocol: "tcp".into(),
397            from_port: port,
398            to_port: port,
399            cidr: cidr.map(str::to_string),
400        }
401    }
402
403    #[test]
404    fn renders_allow_then_default_deny_for_ingress() {
405        let model = vec![SubnetFirewall {
406            network_name: "fakecloud-subnet-a".into(),
407            instances: vec![InstanceFirewall {
408                private_ip: "172.30.0.2".into(),
409                ingress: vec![tcp(22, Some("10.0.0.0/8"))],
410                egress: vec![],
411            }],
412            nacl: vec![],
413        }];
414        let rs = render_ruleset(&model);
415        // `add table` must precede `flush table` so the first apply (table
416        // absent) doesn't error and abort the whole ruleset load.
417        let add = rs.find("add table inet fakecloud_ec2").expect("add table");
418        let flush = rs
419            .find("flush table inet fakecloud_ec2")
420            .expect("flush table");
421        assert!(add < flush, "add table must come before flush:\n{rs}");
422        assert!(rs.contains("ct state established,related accept"));
423        assert!(rs.contains("ip daddr 172.30.0.2 ip saddr 10.0.0.0/8 tcp dport 22 accept"));
424        assert!(rs.contains("ip daddr 172.30.0.2 drop comment \"default-deny ingress\""));
425        // egress had no explicit allows -> still a default-deny line
426        assert!(rs.contains("ip saddr 172.30.0.2 drop comment \"default-deny egress\""));
427    }
428
429    #[test]
430    fn all_protocols_and_anywhere_omit_match_clauses() {
431        let rule = FirewallRule {
432            protocol: "-1".into(),
433            from_port: -1,
434            to_port: -1,
435            cidr: Some("0.0.0.0/0".into()),
436        };
437        let line = render_rule(&rule, Direction::Ingress, "172.30.0.5");
438        // no saddr (anywhere), no proto, no port:
439        assert_eq!(line, "ip daddr 172.30.0.5 accept");
440    }
441
442    #[test]
443    fn port_range_and_single_port() {
444        let range = FirewallRule {
445            protocol: "tcp".into(),
446            from_port: 8000,
447            to_port: 8100,
448            cidr: None,
449        };
450        assert!(render_rule(&range, Direction::Egress, "172.30.0.9")
451            .contains("tcp dport 8000-8100 accept"));
452        assert!(
453            render_rule(&tcp(443, None), Direction::Ingress, "172.30.0.9")
454                .contains("tcp dport 443 accept")
455        );
456    }
457
458    #[test]
459    fn icmp_and_numeric_protocols() {
460        let icmp = FirewallRule {
461            protocol: "icmp".into(),
462            from_port: -1,
463            to_port: -1,
464            cidr: None,
465        };
466        assert!(render_rule(&icmp, Direction::Ingress, "172.30.0.2").contains("ip protocol icmp"));
467        let udp = FirewallRule {
468            protocol: "17".into(),
469            from_port: 53,
470            to_port: 53,
471            cidr: None,
472        };
473        assert!(render_rule(&udp, Direction::Ingress, "172.30.0.2").contains("udp dport 53"));
474    }
475
476    #[test]
477    fn host_cidr_strips_slash_32() {
478        let r = tcp(22, Some("203.0.113.7/32"));
479        assert!(render_rule(&r, Direction::Ingress, "172.30.0.2")
480            .contains("ip saddr 203.0.113.7 tcp dport 22"));
481    }
482
483    #[test]
484    fn cidr_with_nft_metacharacters_is_dropped_not_injected() {
485        // A CIDR carrying nft syntax (`;`, spaces, words) must never reach the
486        // `nft -f -` script (finding 2.2). The match clause is omitted; the
487        // rule still renders safely and terminates in `accept`.
488        let r = tcp(22, Some("10.0.0.0/8; drop comment \"x\""));
489        let line = render_rule(&r, Direction::Ingress, "172.30.0.2");
490        assert!(!line.contains(';'), "no injected semicolon: {line}");
491        assert!(!line.contains("comment"), "no injected tokens: {line}");
492        assert!(
493            !line.contains("ip saddr"),
494            "malformed cidr clause omitted: {line}"
495        );
496        assert!(line.ends_with("accept"), "rule still valid: {line}");
497    }
498
499    #[test]
500    fn unknown_protocol_with_bad_chars_emits_no_proto_match() {
501        let r = FirewallRule {
502            protocol: "tcp; drop".into(),
503            from_port: -1,
504            to_port: -1,
505            cidr: None,
506        };
507        let line = render_rule(&r, Direction::Ingress, "172.30.0.2");
508        assert!(
509            !line.contains(';') && !line.contains("ip protocol"),
510            "{line}"
511        );
512        assert_eq!(line, "ip daddr 172.30.0.2 accept");
513    }
514
515    #[test]
516    fn nacl_deny_emitted_before_instance_rules() {
517        let model = vec![SubnetFirewall {
518            network_name: "fakecloud-subnet-a".into(),
519            instances: vec![InstanceFirewall {
520                private_ip: "172.30.0.2".into(),
521                ingress: vec![],
522                egress: vec![],
523            }],
524            nacl: vec![NaclRule {
525                rule_number: 100,
526                egress: false,
527                allow: false,
528                protocol: "tcp".into(),
529                from_port: 3389,
530                to_port: 3389,
531                cidr: Some("198.51.100.0/24".into()),
532            }],
533        }];
534        let rs = render_ruleset(&model);
535        let deny = rs
536            .find("ip saddr 198.51.100.0/24 tcp dport 3389 drop")
537            .unwrap();
538        let inst = rs.find("ip daddr 172.30.0.2 drop").unwrap();
539        assert!(
540            deny < inst,
541            "nacl deny must precede the instance default-deny"
542        );
543        // allow NACL entries produce no explicit line
544        assert!(!rs.contains("nacl-allow"));
545    }
546
547    #[test]
548    fn nacl_lower_numbered_allow_shadows_higher_numbered_deny() {
549        // AWS first-match-by-rule-number: `100 allow tcp/22 10/8` must win over
550        // `200 deny tcp/22 10/8`, so the deny is NOT emitted (finding 1.4).
551        let nacl_entry = |rule_number, allow| NaclRule {
552            rule_number,
553            egress: false,
554            allow,
555            protocol: "tcp".into(),
556            from_port: 22,
557            to_port: 22,
558            cidr: Some("10.0.0.0/8".into()),
559        };
560        let model = vec![SubnetFirewall {
561            network_name: "fakecloud-subnet-a".into(),
562            instances: vec![InstanceFirewall {
563                private_ip: "172.30.0.2".into(),
564                ingress: vec![],
565                egress: vec![],
566            }],
567            // Intentionally out of order to exercise the sort.
568            nacl: vec![nacl_entry(200, false), nacl_entry(100, true)],
569        }];
570        let rs = render_ruleset(&model);
571        assert!(
572            !rs.contains("ip saddr 10.0.0.0/8 tcp dport 22 drop"),
573            "a lower-numbered allow must shadow the deny:\n{rs}"
574        );
575
576        // Reverse the precedence: deny 100 before allow 200 -> deny fires.
577        let model2 = vec![SubnetFirewall {
578            network_name: "fakecloud-subnet-a".into(),
579            instances: vec![],
580            nacl: vec![nacl_entry(100, false), nacl_entry(200, true)],
581        }];
582        assert!(render_ruleset(&model2).contains("ip saddr 10.0.0.0/8 tcp dport 22 drop"));
583    }
584
585    #[test]
586    fn enforcement_mode_is_opt_in_and_capability_gated() {
587        // not opted in -> disabled regardless of nft availability / host
588        assert_eq!(
589            resolve_enforcement_mode(None, true, || true),
590            EnforcementMode::Disabled
591        );
592        assert_eq!(
593            resolve_enforcement_mode(Some("0"), true, || true),
594            EnforcementMode::Disabled
595        );
596        // opted in but nft missing -> degrade
597        assert_eq!(
598            resolve_enforcement_mode(Some("1"), true, || false),
599            EnforcementMode::Disabled
600        );
601        // opted in + capable but daemon not host-local (Docker Desktop/VM) ->
602        // degrade rather than falsely claim enforced (finding 1.5)
603        assert_eq!(
604            resolve_enforcement_mode(Some("1"), false, || true),
605            EnforcementMode::Disabled
606        );
607        // opted in + host-local + capable -> nftables
608        assert_eq!(
609            resolve_enforcement_mode(Some("nftables"), true, || true),
610            EnforcementMode::Nftables
611        );
612        assert_eq!(
613            resolve_enforcement_mode(Some("TRUE"), true, || true),
614            EnforcementMode::Nftables
615        );
616    }
617
618    #[test]
619    fn group_by_subnet_sorts_and_attaches_nacls() {
620        let instances = vec![
621            (
622                "net-a".to_string(),
623                InstanceFirewall {
624                    private_ip: "172.30.0.9".into(),
625                    ingress: vec![],
626                    egress: vec![],
627                },
628            ),
629            (
630                "net-a".to_string(),
631                InstanceFirewall {
632                    private_ip: "172.30.0.2".into(),
633                    ingress: vec![],
634                    egress: vec![],
635                },
636            ),
637        ];
638        let mut nacls = BTreeMap::new();
639        nacls.insert(
640            "net-a".to_string(),
641            vec![NaclRule {
642                rule_number: 100,
643                egress: false,
644                allow: false,
645                protocol: "-1".into(),
646                from_port: -1,
647                to_port: -1,
648                cidr: Some("10.0.0.0/8".into()),
649            }],
650        );
651        let grouped = group_by_subnet(instances, nacls);
652        assert_eq!(grouped.len(), 1);
653        assert_eq!(grouped[0].instances[0].private_ip, "172.30.0.2");
654        assert_eq!(grouped[0].instances[1].private_ip, "172.30.0.9");
655        assert_eq!(grouped[0].nacl.len(), 1);
656    }
657}