1#[cfg(any(test, target_os = "macos"))]
9use std::fmt::Write as _;
10#[cfg(any(target_os = "linux", target_os = "macos"))]
11use std::process::Output;
12#[cfg(any(target_os = "linux", target_os = "macos"))]
13use std::process::{Command, Stdio};
14
15use thiserror::Error;
16
17pub const FIPS_MESH_IPV6_PREFIX: &str = "fd00::/8";
19
20const DEFAULT_LINUX_TABLE_NAME: &str = "fips_host";
21const DEFAULT_MACOS_ANCHOR_NAME: &str = "com.apple/fips/host";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct HostFirewallConfig {
26 interface: String,
27 inbound_tcp_ports: Vec<u16>,
28 linux_table_name: String,
29 macos_anchor_name: String,
30}
31
32impl HostFirewallConfig {
33 #[must_use]
35 pub fn new(interface: impl Into<String>) -> Self {
36 Self {
37 interface: interface.into(),
38 inbound_tcp_ports: Vec::new(),
39 linux_table_name: DEFAULT_LINUX_TABLE_NAME.to_string(),
40 macos_anchor_name: DEFAULT_MACOS_ANCHOR_NAME.to_string(),
41 }
42 }
43
44 #[must_use]
46 pub fn with_inbound_tcp_ports(mut self, ports: impl IntoIterator<Item = u16>) -> Self {
47 self.inbound_tcp_ports = normalized_tcp_ports(ports);
48 self
49 }
50
51 #[must_use]
53 pub fn with_linux_table_name(mut self, table_name: impl Into<String>) -> Self {
54 self.linux_table_name = table_name.into();
55 self
56 }
57
58 #[must_use]
60 pub fn with_macos_anchor_name(mut self, anchor_name: impl Into<String>) -> Self {
61 self.macos_anchor_name = anchor_name.into();
62 self
63 }
64
65 #[must_use]
67 pub fn interface(&self) -> &str {
68 &self.interface
69 }
70
71 #[must_use]
73 pub fn inbound_tcp_ports(&self) -> &[u16] {
74 &self.inbound_tcp_ports
75 }
76
77 #[must_use]
79 pub fn linux_table_name(&self) -> &str {
80 &self.linux_table_name
81 }
82
83 #[must_use]
85 pub fn macos_anchor_name(&self) -> &str {
86 &self.macos_anchor_name
87 }
88
89 fn validate(&self) -> Result<(), HostFirewallError> {
90 validate_interface_name(&self.interface)?;
91 validate_nft_table_name(&self.linux_table_name)?;
92 validate_pf_anchor_name(&self.macos_anchor_name)?;
93 Ok(())
94 }
95}
96
97#[derive(Debug)]
102pub struct HostFirewallGuard {
103 #[cfg(any(target_os = "linux", target_os = "macos"))]
104 backend: HostFirewallBackend,
105}
106
107#[cfg(any(target_os = "linux", target_os = "macos"))]
108#[derive(Debug)]
109enum HostFirewallBackend {
110 #[cfg(target_os = "linux")]
111 Linux { table_name: String },
112 #[cfg(target_os = "macos")]
113 Macos {
114 anchor_name: String,
115 enable_token: Option<String>,
116 },
117}
118
119impl HostFirewallGuard {
120 #[must_use]
122 pub const fn platform_supported() -> bool {
123 cfg!(any(target_os = "linux", target_os = "macos"))
124 }
125
126 #[must_use]
129 pub fn platform_available() -> bool {
130 #[cfg(target_os = "linux")]
131 return command_exists("nft");
132 #[cfg(target_os = "macos")]
133 return command_exists("pfctl");
134 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
135 return false;
136 }
137
138 pub fn install(config: &HostFirewallConfig) -> Result<Self, HostFirewallError> {
144 config.validate()?;
145
146 #[cfg(target_os = "linux")]
147 {
148 install_linux_firewall(config)
149 }
150 #[cfg(target_os = "macos")]
151 {
152 install_macos_firewall(config)
153 }
154 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
155 {
156 let _ = config;
157 Err(HostFirewallError::UnsupportedPlatform)
158 }
159 }
160
161 pub fn cleanup_disabled_artifacts(config: &HostFirewallConfig) {
166 #[cfg(target_os = "linux")]
167 remove_nft_table(config.linux_table_name());
168 #[cfg(target_os = "macos")]
169 flush_pf_anchor(config.macos_anchor_name());
170 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
171 let _ = config;
172 }
173}
174
175impl Drop for HostFirewallGuard {
176 fn drop(&mut self) {
177 #[cfg(target_os = "linux")]
178 {
179 let HostFirewallBackend::Linux { table_name } = &self.backend;
180 remove_nft_table(table_name);
181 }
182 #[cfg(target_os = "macos")]
183 {
184 let HostFirewallBackend::Macos {
185 anchor_name,
186 enable_token,
187 } = &self.backend;
188 flush_pf_anchor(anchor_name);
189 if let Some(token) = enable_token {
190 release_pf_enable_token(token);
191 }
192 }
193 }
194}
195
196#[derive(Debug, Error)]
198pub enum HostFirewallError {
199 #[error("host firewall is not supported on this platform")]
201 UnsupportedPlatform,
202
203 #[error("required firewall command `{0}` was not found")]
205 MissingCommand(&'static str),
206
207 #[error("invalid {field}: {value}")]
209 InvalidName {
210 field: &'static str,
212 value: String,
214 },
215
216 #[error("failed to run `{command}`: {source}")]
218 CommandIo {
219 command: &'static str,
221 #[source]
223 source: std::io::Error,
224 },
225
226 #[error("`{command}` exited with {status}: {stderr}")]
228 CommandFailed {
229 command: &'static str,
231 status: std::process::ExitStatus,
233 stderr: String,
235 },
236}
237
238fn normalized_tcp_ports(ports: impl IntoIterator<Item = u16>) -> Vec<u16> {
239 let mut ports = ports.into_iter().collect::<Vec<_>>();
240 ports.sort_unstable();
241 ports.dedup();
242 ports
243}
244
245fn validate_interface_name(name: &str) -> Result<(), HostFirewallError> {
246 validate_name(
247 "interface",
248 name,
249 |ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'),
250 false,
251 )
252}
253
254fn validate_nft_table_name(name: &str) -> Result<(), HostFirewallError> {
255 if name.is_empty()
256 || !name
257 .chars()
258 .next()
259 .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
260 {
261 return Err(HostFirewallError::InvalidName {
262 field: "nft table name",
263 value: name.to_string(),
264 });
265 }
266 validate_name(
267 "nft table name",
268 name,
269 |ch| ch.is_ascii_alphanumeric() || ch == '_',
270 false,
271 )
272}
273
274fn validate_pf_anchor_name(name: &str) -> Result<(), HostFirewallError> {
275 validate_name(
276 "pf anchor name",
277 name,
278 |ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'),
279 true,
280 )
281}
282
283fn validate_name(
284 field: &'static str,
285 value: &str,
286 valid_char: impl Fn(char) -> bool,
287 allow_slash: bool,
288) -> Result<(), HostFirewallError> {
289 let slash_ok = allow_slash && !value.starts_with('/') && !value.ends_with('/');
290 let slash_valid = !value.contains('/') || slash_ok;
291 if value.is_empty() || !slash_valid || !value.chars().all(valid_char) {
292 return Err(HostFirewallError::InvalidName {
293 field,
294 value: value.to_string(),
295 });
296 }
297 Ok(())
298}
299
300#[cfg(any(test, target_os = "linux"))]
301#[must_use]
302pub fn render_nft_host_firewall_rules(
303 table_name: &str,
304 iface: &str,
305 inbound_tcp_ports: &[u16],
306) -> String {
307 let ports = normalized_tcp_ports(inbound_tcp_ports.iter().copied());
308 let inbound_tcp_rule = match ports.as_slice() {
309 [] => String::new(),
310 [port] => format!(" tcp dport {port} accept\n"),
311 ports => {
312 let joined = ports
313 .iter()
314 .map(u16::to_string)
315 .collect::<Vec<_>>()
316 .join(", ");
317 format!(" tcp dport {{ {joined} }} accept\n")
318 }
319 };
320
321 format!(
322 "table inet {table_name} {{\n\
323 chain input {{\n\
324 type filter hook input priority 0; policy accept;\n\
325 iifname != \"{iface}\" return\n\
326 meta nfproto != ipv6 return\n\
327 ip6 saddr != {FIPS_MESH_IPV6_PREFIX} return\n\
328 ct state established,related accept\n\
329 icmpv6 type {{ echo-request, echo-reply }} accept\n\
330 {inbound_tcp_rule}\
331 counter drop\n\
332 }}\n\
333 chain output {{\n\
334 type filter hook output priority 0; policy accept;\n\
335 oifname != \"{iface}\" return\n\
336 meta nfproto != ipv6 return\n\
337 ip6 daddr != {FIPS_MESH_IPV6_PREFIX} return\n\
338 ct state established,related accept\n\
339 icmpv6 type {{ echo-request, echo-reply }} accept\n\
340 meta l4proto tcp accept\n\
341 counter drop\n\
342 }}\n\
343 }}\n"
344 )
345}
346
347#[cfg(any(test, target_os = "macos"))]
348#[must_use]
349pub fn render_macos_pf_host_firewall_rules(iface: &str, inbound_tcp_ports: &[u16]) -> String {
350 let ports = normalized_tcp_ports(inbound_tcp_ports.iter().copied());
351 let mut rules = String::from("# Managed by fips-core for FIPS host routing.\n");
352
353 match ports.as_slice() {
354 [] => {}
355 [port] => {
356 let _ = writeln!(
357 rules,
358 "pass in quick on {iface} inet6 proto tcp from {FIPS_MESH_IPV6_PREFIX} to any port {port} flags S/SA keep state"
359 );
360 }
361 ports => {
362 let joined = ports
363 .iter()
364 .map(u16::to_string)
365 .collect::<Vec<_>>()
366 .join(", ");
367 let _ = writeln!(
368 rules,
369 "pass in quick on {iface} inet6 proto tcp from {FIPS_MESH_IPV6_PREFIX} to any port {{ {joined} }} flags S/SA keep state"
370 );
371 }
372 }
373
374 let _ = write!(
375 rules,
376 "pass in quick on {iface} inet6 proto icmp6 from {FIPS_MESH_IPV6_PREFIX} to any icmp6-type {{ echoreq, echorep }} keep state\n\
377 pass out quick on {iface} inet6 proto icmp6 from any to {FIPS_MESH_IPV6_PREFIX} icmp6-type {{ echoreq, echorep }} keep state\n\
378 pass out quick on {iface} inet6 proto tcp from any to {FIPS_MESH_IPV6_PREFIX} flags S/SA keep state\n\
379 block drop in quick on {iface} inet6 from {FIPS_MESH_IPV6_PREFIX} to any\n\
380 block drop out quick on {iface} inet6 from any to {FIPS_MESH_IPV6_PREFIX}\n"
381 );
382 rules
383}
384
385#[cfg(target_os = "linux")]
386fn install_linux_firewall(
387 config: &HostFirewallConfig,
388) -> Result<HostFirewallGuard, HostFirewallError> {
389 if !command_exists("nft") {
390 return Err(HostFirewallError::MissingCommand("nft"));
391 }
392
393 let rules = render_nft_host_firewall_rules(
394 config.linux_table_name(),
395 config.interface(),
396 config.inbound_tcp_ports(),
397 );
398 remove_nft_table(config.linux_table_name());
399 let mut child = Command::new("nft")
400 .arg("-f")
401 .arg("-")
402 .stdin(Stdio::piped())
403 .stderr(Stdio::piped())
404 .spawn()
405 .map_err(|source| HostFirewallError::CommandIo {
406 command: "nft",
407 source,
408 })?;
409 {
410 let stdin = child
411 .stdin
412 .as_mut()
413 .ok_or_else(|| HostFirewallError::CommandIo {
414 command: "nft",
415 source: std::io::Error::new(
416 std::io::ErrorKind::BrokenPipe,
417 "nft stdin unavailable",
418 ),
419 })?;
420 use std::io::Write as _;
421 stdin
422 .write_all(rules.as_bytes())
423 .map_err(|source| HostFirewallError::CommandIo {
424 command: "nft",
425 source,
426 })?;
427 }
428 let output = child
429 .wait_with_output()
430 .map_err(|source| HostFirewallError::CommandIo {
431 command: "nft",
432 source,
433 })?;
434 ensure_success("nft", output)?;
435
436 Ok(HostFirewallGuard {
437 backend: HostFirewallBackend::Linux {
438 table_name: config.linux_table_name().to_string(),
439 },
440 })
441}
442
443#[cfg(target_os = "linux")]
444fn remove_nft_table(table_name: &str) {
445 if !command_exists("nft") {
446 return;
447 }
448 let _ = Command::new("nft")
449 .arg("delete")
450 .arg("table")
451 .arg("inet")
452 .arg(table_name)
453 .stdout(Stdio::null())
454 .stderr(Stdio::null())
455 .status();
456}
457
458#[cfg(target_os = "macos")]
459fn install_macos_firewall(
460 config: &HostFirewallConfig,
461) -> Result<HostFirewallGuard, HostFirewallError> {
462 if !command_exists("pfctl") {
463 return Err(HostFirewallError::MissingCommand("pfctl"));
464 }
465
466 let rules = render_macos_pf_host_firewall_rules(config.interface(), config.inbound_tcp_ports());
467 let _ = run_pfctl(&["-a", config.macos_anchor_name(), "-F", "rules"], None)?;
468 run_pfctl(&["-a", config.macos_anchor_name(), "-f", "-"], Some(&rules))?;
469 let enable_output = run_pfctl(&["-E"], None)?;
470 let enable_token = parse_pf_enable_token(&String::from_utf8_lossy(&enable_output.stdout));
471
472 Ok(HostFirewallGuard {
473 backend: HostFirewallBackend::Macos {
474 anchor_name: config.macos_anchor_name().to_string(),
475 enable_token,
476 },
477 })
478}
479
480#[cfg(target_os = "macos")]
481fn flush_pf_anchor(anchor_name: &str) {
482 if !command_exists("pfctl") {
483 return;
484 }
485 let _ = run_pfctl(&["-a", anchor_name, "-F", "rules"], None);
486}
487
488#[cfg(target_os = "macos")]
489fn release_pf_enable_token(token: &str) {
490 if !command_exists("pfctl") {
491 return;
492 }
493 let _ = run_pfctl(&["-X", token], None);
494}
495
496#[cfg(target_os = "macos")]
497fn run_pfctl(args: &[&str], stdin: Option<&str>) -> Result<Output, HostFirewallError> {
498 let mut command = Command::new("pfctl");
499 command.args(args).stderr(Stdio::piped());
500 if stdin.is_some() {
501 command.stdin(Stdio::piped());
502 }
503 let mut child = command
504 .spawn()
505 .map_err(|source| HostFirewallError::CommandIo {
506 command: "pfctl",
507 source,
508 })?;
509 if let Some(input) = stdin {
510 let child_stdin = child
511 .stdin
512 .as_mut()
513 .ok_or_else(|| HostFirewallError::CommandIo {
514 command: "pfctl",
515 source: std::io::Error::new(
516 std::io::ErrorKind::BrokenPipe,
517 "pfctl stdin unavailable",
518 ),
519 })?;
520 use std::io::Write as _;
521 child_stdin
522 .write_all(input.as_bytes())
523 .map_err(|source| HostFirewallError::CommandIo {
524 command: "pfctl",
525 source,
526 })?;
527 }
528 let output = child
529 .wait_with_output()
530 .map_err(|source| HostFirewallError::CommandIo {
531 command: "pfctl",
532 source,
533 })?;
534 ensure_success("pfctl", output)
535}
536
537#[cfg(target_os = "macos")]
538fn parse_pf_enable_token(output: &str) -> Option<String> {
539 output.lines().find_map(|line| {
540 let (label, value) = line.split_once(':')?;
541 if label.trim().eq_ignore_ascii_case("token") {
542 let token = value.trim();
543 if !token.is_empty() {
544 return Some(token.to_string());
545 }
546 }
547 None
548 })
549}
550
551#[cfg(any(target_os = "linux", target_os = "macos"))]
552fn ensure_success(command: &'static str, output: Output) -> Result<Output, HostFirewallError> {
553 if output.status.success() {
554 Ok(output)
555 } else {
556 Err(HostFirewallError::CommandFailed {
557 command,
558 status: output.status,
559 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
560 })
561 }
562}
563
564#[cfg(any(target_os = "linux", target_os = "macos"))]
565fn command_exists(command: &str) -> bool {
566 Command::new("sh")
567 .arg("-c")
568 .arg(format!("command -v {command} >/dev/null 2>&1"))
569 .status()
570 .is_ok_and(|status| status.success())
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn config_normalizes_inbound_tcp_ports() {
579 let config = HostFirewallConfig::new("fips0").with_inbound_tcp_ports([443, 22, 22]);
580
581 assert_eq!(config.inbound_tcp_ports(), &[22, 443]);
582 }
583
584 #[test]
585 fn rejects_unsafe_names() {
586 assert!(HostFirewallConfig::new("utun0").validate().is_ok());
587 assert!(HostFirewallConfig::new("utun0; reboot").validate().is_err());
588 assert!(
589 HostFirewallConfig::new("utun0")
590 .with_linux_table_name("1bad")
591 .validate()
592 .is_err()
593 );
594 assert!(
595 HostFirewallConfig::new("utun0")
596 .with_macos_anchor_name("/bad")
597 .validate()
598 .is_err()
599 );
600 }
601
602 #[test]
603 fn nft_rules_default_to_outbound_tcp_and_diagnostic_echo() {
604 let rules = render_nft_host_firewall_rules("fips_host", "nvpn0", &[]);
605
606 assert!(rules.contains("table inet fips_host"));
607 assert!(rules.contains("iifname != \"nvpn0\" return"));
608 assert!(rules.contains("oifname != \"nvpn0\" return"));
609 assert!(rules.contains("ip6 saddr != fd00::/8 return"));
610 assert!(rules.contains("ip6 daddr != fd00::/8 return"));
611 assert!(rules.contains("meta l4proto tcp accept"));
612 assert_eq!(
613 rules
614 .matches("icmpv6 type { echo-request, echo-reply } accept")
615 .count(),
616 2
617 );
618 assert!(!rules.contains("tcp dport"));
619 }
620
621 #[test]
622 fn nft_rules_allow_configured_inbound_tcp_ports() {
623 let rules = render_nft_host_firewall_rules("fips_host", "nvpn0", &[443, 22, 22]);
624
625 assert!(rules.contains("tcp dport { 22, 443 } accept"));
626 }
627
628 #[test]
629 fn macos_pf_rules_default_to_outbound_tcp_and_diagnostic_echo() {
630 let rules = render_macos_pf_host_firewall_rules("utun8", &[]);
631
632 assert!(rules.contains("pass out quick on utun8 inet6 proto tcp"));
633 assert!(rules.contains(
634 "pass in quick on utun8 inet6 proto icmp6 from fd00::/8 to any icmp6-type { echoreq, echorep }"
635 ));
636 assert!(rules.contains(
637 "pass out quick on utun8 inet6 proto icmp6 from any to fd00::/8 icmp6-type { echoreq, echorep }"
638 ));
639 assert!(rules.contains("block drop in quick on utun8 inet6 from fd00::/8 to any"));
640 assert!(rules.contains("block drop out quick on utun8 inet6 from any to fd00::/8"));
641 assert!(!rules.contains("pass in quick on utun8 inet6 proto tcp"));
642 assert!(!rules.contains("proto udp"));
643 }
644
645 #[test]
646 fn macos_pf_rules_allow_configured_inbound_tcp_ports() {
647 let rules = render_macos_pf_host_firewall_rules("utun8", &[443, 22, 22]);
648
649 assert!(rules.contains(
650 "pass in quick on utun8 inet6 proto tcp from fd00::/8 to any port { 22, 443 }"
651 ));
652 }
653
654 #[cfg(target_os = "macos")]
655 #[test]
656 fn parses_pf_enable_token() {
657 assert_eq!(
658 parse_pf_enable_token("Token : 1234567890\n"),
659 Some("1234567890".to_string())
660 );
661 }
662}