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