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