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    #[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    /// True when this target has an implemented host firewall backend.
121    #[must_use]
122    pub const fn platform_supported() -> bool {
123        cfg!(any(target_os = "linux", target_os = "macos"))
124    }
125
126    /// True when this target has an implemented backend and the required
127    /// platform command is present.
128    #[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    /// Install host firewall rules for the configured mesh interface.
139    ///
140    /// Returns an error on unsupported platforms or when the platform firewall
141    /// command is unavailable. Callers should treat that as a hard failure
142    /// before exposing the host tunnel.
143    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    /// Remove managed firewall artifacts without requiring a live guard.
162    ///
163    /// Useful after crash/restart paths where the previous process may have
164    /// died before its guard could drop.
165    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/// Errors returned while installing platform firewall rules.
197#[derive(Debug, Error)]
198pub enum HostFirewallError {
199    /// Host firewall support is not implemented for the current platform.
200    #[error("host firewall is not supported on this platform")]
201    UnsupportedPlatform,
202
203    /// A required platform command was not found in PATH.
204    #[error("required firewall command `{0}` was not found")]
205    MissingCommand(&'static str),
206
207    /// A user-supplied or kernel-supplied name was unsafe for rule rendering.
208    #[error("invalid {field}: {value}")]
209    InvalidName {
210        /// Field name.
211        field: &'static str,
212        /// Invalid value.
213        value: String,
214    },
215
216    /// Failed to spawn or communicate with a platform firewall command.
217    #[error("failed to run `{command}`: {source}")]
218    CommandIo {
219        /// Command name.
220        command: &'static str,
221        /// I/O error.
222        #[source]
223        source: std::io::Error,
224    },
225
226    /// A platform firewall command exited unsuccessfully.
227    #[error("`{command}` exited with {status}: {stderr}")]
228    CommandFailed {
229        /// Command name.
230        command: &'static str,
231        /// Process exit status.
232        status: std::process::ExitStatus,
233        /// Captured stderr.
234        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}