Skip to main content

fips_core/
host_firewall.rs

1//! Host firewall helpers for FIPS mesh TUN interfaces.
2//!
3//! This module intentionally owns only narrowly scoped rules for one mesh
4//! interface. The policy is default-deny for FIPS-addressed inbound traffic and
5//! outbound traffic, with stateful outbound TCP allowed and optional inbound TCP
6//! service ports.
7
8#[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
17/// The IPv6 prefix used by FIPS mesh addresses.
18pub 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/// Platform firewall configuration for a FIPS host-facing TUN interface.
24#[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    /// Build a firewall config for `interface`.
34    #[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    /// Allow inbound TCP connections to the supplied destination ports.
45    #[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    /// Override the managed Linux nftables table name.
52    #[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    /// Override the managed macOS PF anchor name.
59    #[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    /// TUN interface matched by the rules.
66    #[must_use]
67    pub fn interface(&self) -> &str {
68        &self.interface
69    }
70
71    /// Normalized inbound TCP destination ports.
72    #[must_use]
73    pub fn inbound_tcp_ports(&self) -> &[u16] {
74        &self.inbound_tcp_ports
75    }
76
77    /// Managed Linux nftables table name.
78    #[must_use]
79    pub fn linux_table_name(&self) -> &str {
80        &self.linux_table_name
81    }
82
83    /// Managed macOS PF anchor name.
84    #[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/// RAII guard for installed host firewall rules.
98///
99/// Dropping the guard removes only the managed table/anchor. It does not flush
100/// the host's main firewall ruleset.
101#[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    /// True when this target has an implemented host firewall backend.
122    #[must_use]
123    pub const fn platform_supported() -> bool {
124        cfg!(any(target_os = "linux", target_os = "macos"))
125    }
126
127    /// True when this target has an implemented backend and the required
128    /// platform command is present.
129    #[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    /// Install host firewall rules for the configured mesh interface.
140    ///
141    /// Returns an error on unsupported platforms or when the platform firewall
142    /// command is unavailable. Callers should treat that as a hard failure
143    /// before exposing the host tunnel.
144    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    /// Remove managed firewall artifacts without requiring a live guard.
163    ///
164    /// Useful after crash/restart paths where the previous process may have
165    /// died before its guard could drop.
166    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/// Errors returned while installing platform firewall rules.
198#[derive(Debug, Error)]
199pub enum HostFirewallError {
200    /// Host firewall support is not implemented for the current platform.
201    #[error("host firewall is not supported on this platform")]
202    UnsupportedPlatform,
203
204    /// A required platform command was not found in PATH.
205    #[error("required firewall command `{0}` was not found")]
206    MissingCommand(&'static str),
207
208    /// A user-supplied or kernel-supplied name was unsafe for rule rendering.
209    #[error("invalid {field}: {value}")]
210    InvalidName {
211        /// Field name.
212        field: &'static str,
213        /// Invalid value.
214        value: String,
215    },
216
217    /// Failed to spawn or communicate with a platform firewall command.
218    #[error("failed to run `{command}`: {source}")]
219    CommandIo {
220        /// Command name.
221        command: &'static str,
222        /// I/O error.
223        #[source]
224        source: std::io::Error,
225    },
226
227    /// A platform firewall command exited unsuccessfully.
228    #[error("`{command}` exited with {status}: {stderr}")]
229    CommandFailed {
230        /// Command name.
231        command: &'static str,
232        /// Process exit status.
233        status: std::process::ExitStatus,
234        /// Captured stderr.
235        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}