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