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