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
8use 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
15/// The IPv6 prefix used by FIPS mesh addresses.
16pub 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/// Platform firewall configuration for a FIPS host-facing TUN interface.
22#[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    /// Build a firewall config for `interface`.
32    #[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    /// Allow inbound TCP connections to the supplied destination ports.
43    #[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    /// Override the managed Linux nftables table name.
50    #[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    /// Override the managed macOS PF anchor name.
57    #[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    /// TUN interface matched by the rules.
64    #[must_use]
65    pub fn interface(&self) -> &str {
66        &self.interface
67    }
68
69    /// Normalized inbound TCP destination ports.
70    #[must_use]
71    pub fn inbound_tcp_ports(&self) -> &[u16] {
72        &self.inbound_tcp_ports
73    }
74
75    /// Managed Linux nftables table name.
76    #[must_use]
77    pub fn linux_table_name(&self) -> &str {
78        &self.linux_table_name
79    }
80
81    /// Managed macOS PF anchor name.
82    #[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/// RAII guard for installed host firewall rules.
96///
97/// Dropping the guard removes only the managed table/anchor. It does not flush
98/// the host's main firewall ruleset.
99#[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    /// True when this target has an implemented host firewall backend.
117    #[must_use]
118    pub const fn platform_supported() -> bool {
119        cfg!(any(target_os = "linux", target_os = "macos"))
120    }
121
122    /// True when this target has an implemented backend and the required
123    /// platform command is present.
124    #[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    /// Install host firewall rules for the configured mesh interface.
135    ///
136    /// Returns an error on unsupported platforms or when the platform firewall
137    /// command is unavailable. Callers should treat that as a hard failure
138    /// before exposing the host tunnel.
139    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    /// Remove managed firewall artifacts without requiring a live guard.
158    ///
159    /// Useful after crash/restart paths where the previous process may have
160    /// died before its guard could drop.
161    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/// Errors returned while installing platform firewall rules.
191#[derive(Debug, Error)]
192pub enum HostFirewallError {
193    /// Host firewall support is not implemented for the current platform.
194    #[error("host firewall is not supported on this platform")]
195    UnsupportedPlatform,
196
197    /// A required platform command was not found in PATH.
198    #[error("required firewall command `{0}` was not found")]
199    MissingCommand(&'static str),
200
201    /// A user-supplied or kernel-supplied name was unsafe for rule rendering.
202    #[error("invalid {field}: {value}")]
203    InvalidName {
204        /// Field name.
205        field: &'static str,
206        /// Invalid value.
207        value: String,
208    },
209
210    /// Failed to spawn or communicate with a platform firewall command.
211    #[error("failed to run `{command}`: {source}")]
212    CommandIo {
213        /// Command name.
214        command: &'static str,
215        /// I/O error.
216        #[source]
217        source: std::io::Error,
218    },
219
220    /// A platform firewall command exited unsuccessfully.
221    #[error("`{command}` exited with {status}: {stderr}")]
222    CommandFailed {
223        /// Command name.
224        command: &'static str,
225        /// Process exit status.
226        status: std::process::ExitStatus,
227        /// Captured stderr.
228        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}