1use std::collections::BTreeMap;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FirewallRule {
35 pub protocol: String,
37 pub from_port: i64,
39 pub to_port: i64,
40 pub cidr: Option<String>,
42}
43
44#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct NaclRule {
72 pub rule_number: i64,
74 pub egress: bool,
75 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#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SubnetFirewall {
86 pub network_name: String,
90 pub instances: Vec<InstanceFirewall>,
91 pub nacl: Vec<NaclRule>,
92}
93
94const TABLE: &str = "inet fakecloud_ec2";
98
99pub fn render_ruleset(subnets: &[SubnetFirewall]) -> String {
109 let mut out = String::new();
110 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 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 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 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 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
189fn 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
212fn 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
225fn 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 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
247fn 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 other if other.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
273 parts.push(format!("ip protocol {other}"))
274 }
275 _ => {}
276 }
277}
278
279fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum EnforcementMode {
306 Nftables,
308 Disabled,
310}
311
312pub 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
343pub fn host_shares_daemon_netns() -> bool {
349 cfg!(target_os = "linux")
350}
351
352pub 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
365pub 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 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 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 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 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 assert!(!rs.contains("nacl-allow"));
545 }
546
547 #[test]
548 fn nacl_lower_numbered_allow_shadows_higher_numbered_deny() {
549 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 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 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 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 assert_eq!(
598 resolve_enforcement_mode(Some("1"), true, || false),
599 EnforcementMode::Disabled
600 );
601 assert_eq!(
604 resolve_enforcement_mode(Some("1"), false, || true),
605 EnforcementMode::Disabled
606 );
607 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}