Skip to main content

hatchery_enroll/
isolation.rs

1//! Isolation defaults per node kind and the egress kill-switch ruleset.
2//!
3//! The ruleset is rendered from data and only text leaves this module: the node (or an
4//! operator following the text instructions) feeds it to `nft -f -` or `iptables-restore`
5//! inside the isolated space. Nothing here touches the host it is compiled on.
6
7use crate::NodeKind;
8use serde::{Deserialize, Serialize};
9use std::net::{IpAddr, SocketAddr};
10
11/// Where a node keeps its secrets.
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum KeyStore {
15    /// A 0600 state directory owned by the node's own user (the default everywhere).
16    StateDir,
17    /// Local kind only: the operator keeps the admin key in the OS keyring or on a hardware key.
18    OsKeyringOrHardware,
19}
20
21/// How the node's tunnel to C2 is managed.
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum TunnelMode {
25    /// The node creates and owns kernel WireGuard (needs CAP_NET_ADMIN).
26    NodeManaged,
27    /// An OS-level WireGuard client already exists; the node runs with `--no-wg`.
28    External,
29}
30
31/// What a kind runs with unless the operator overrides it.
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct IsolationProfile {
34    pub kind: NodeKind,
35    /// Egress is dropped unless it goes through the tunnel (or is the tunnel's own UDP).
36    pub kill_switch: bool,
37    /// Provider sessions run as an unprivileged user; the node keeps the keys.
38    pub agents_unprivileged: bool,
39    /// The provisioning share (keys, env file) is unmounted before any agent starts.
40    pub drop_provisioning_share: bool,
41    pub tunnel: TunnelMode,
42    pub key_store: KeyStore,
43}
44
45impl IsolationProfile {
46    pub fn for_kind(kind: NodeKind) -> Self {
47        match kind {
48            // Light by default: a person's own machine. Everything below is opt-in.
49            NodeKind::Local => Self {
50                kind,
51                kill_switch: false,
52                agents_unprivileged: false,
53                drop_provisioning_share: false,
54                tunnel: TunnelMode::External,
55                key_store: KeyStore::StateDir,
56            },
57            NodeKind::Docker => Self {
58                kind,
59                kill_switch: true,
60                agents_unprivileged: true,
61                drop_provisioning_share: false,
62                tunnel: TunnelMode::NodeManaged,
63                key_store: KeyStore::StateDir,
64            },
65            // The primary isolated space: identity lives inside, nothing leaks around the tunnel.
66            NodeKind::Qemu => Self {
67                kind,
68                kill_switch: true,
69                agents_unprivileged: true,
70                drop_provisioning_share: true,
71                tunnel: TunnelMode::NodeManaged,
72                key_store: KeyStore::StateDir,
73            },
74            // Somebody else's machine / VPS: unprivileged agents, network policy is the host's.
75            NodeKind::Remote => Self {
76                kind,
77                kill_switch: false,
78                agents_unprivileged: true,
79                drop_provisioning_share: false,
80                tunnel: TunnelMode::NodeManaged,
81                key_store: KeyStore::StateDir,
82            },
83        }
84    }
85
86    /// Operator overrides from the environment-style flags: `Some` replaces the default.
87    pub fn with_overrides(
88        mut self,
89        kill_switch: Option<bool>,
90        agents_unprivileged: Option<bool>,
91    ) -> Self {
92        if let Some(value) = kill_switch {
93            self.kill_switch = value;
94        }
95        if let Some(value) = agents_unprivileged {
96            self.agents_unprivileged = value;
97        }
98        self
99    }
100}
101
102#[derive(Debug, thiserror::Error, Eq, PartialEq)]
103pub enum KillSwitchError {
104    #[error("interface name must be 1-15 characters of [A-Za-z0-9_.-]")]
105    InvalidInterface,
106    #[error("tunnel endpoint must be an IP:PORT literal (a hostname cannot be allowed without a resolver leak)")]
107    EndpointNotLiteral,
108}
109
110/// Egress kill-switch: everything is dropped except loopback, the tunnel interface, and the
111/// tunnel's own UDP to the C2 endpoint on the underlay. Rules match interface *names*, so
112/// deleting the tunnel interface (VPN down) leaves traffic dropped instead of rerouted.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct KillSwitch {
115    interface: String,
116    endpoint: SocketAddr,
117}
118
119impl KillSwitch {
120    pub fn new(interface: &str, endpoint: &str) -> Result<Self, KillSwitchError> {
121        let valid = !interface.is_empty()
122            && interface.len() <= 15
123            && interface
124                .chars()
125                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
126        if !valid {
127            return Err(KillSwitchError::InvalidInterface);
128        }
129        let endpoint: SocketAddr = endpoint
130            .parse()
131            .map_err(|_| KillSwitchError::EndpointNotLiteral)?;
132        Ok(Self {
133            interface: interface.to_owned(),
134            endpoint,
135        })
136    }
137
138    pub fn interface(&self) -> &str {
139        &self.interface
140    }
141
142    pub fn endpoint(&self) -> SocketAddr {
143        self.endpoint
144    }
145
146    pub const TABLE: &'static str = "hatchery_killswitch";
147
148    /// nftables ruleset (`nft -f -`). The final `counter drop` rules make leaks measurable.
149    pub fn nft_ruleset(&self) -> String {
150        let (family, ip) = match self.endpoint.ip() {
151            IpAddr::V4(ip) => ("ip", ip.to_string()),
152            IpAddr::V6(ip) => ("ip6", ip.to_string()),
153        };
154        let port = self.endpoint.port();
155        let wg = &self.interface;
156        let table = Self::TABLE;
157        format!(
158            "table inet {table} {{\n\
159             \x20 chain output {{\n\
160             \x20   type filter hook output priority 0; policy drop;\n\
161             \x20   oif \"lo\" accept\n\
162             \x20   oifname \"{wg}\" accept\n\
163             \x20   {family} daddr {ip} udp dport {port} accept\n\
164             \x20   counter drop\n\
165             \x20 }}\n\
166             \x20 chain input {{\n\
167             \x20   type filter hook input priority 0; policy drop;\n\
168             \x20   iif \"lo\" accept\n\
169             \x20   iifname \"{wg}\" accept\n\
170             \x20   {family} saddr {ip} udp sport {port} accept\n\
171             \x20   counter drop\n\
172             \x20 }}\n\
173             \x20 chain forward {{\n\
174             \x20   type filter hook forward priority 0; policy drop;\n\
175             \x20 }}\n\
176             }}\n"
177        )
178    }
179
180    /// The same policy for hosts that only have iptables (`iptables-restore`, IPv4 endpoint only).
181    pub fn iptables_restore(&self) -> Option<String> {
182        let IpAddr::V4(ip) = self.endpoint.ip() else {
183            return None;
184        };
185        let port = self.endpoint.port();
186        let wg = &self.interface;
187        Some(format!(
188            "*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT DROP [0:0]\n\
189             -A OUTPUT -o lo -j ACCEPT\n-A OUTPUT -o {wg} -j ACCEPT\n\
190             -A OUTPUT -d {ip}/32 -p udp --dport {port} -j ACCEPT\n\
191             -A INPUT -i lo -j ACCEPT\n-A INPUT -i {wg} -j ACCEPT\n\
192             -A INPUT -s {ip}/32 -p udp --sport {port} -j ACCEPT\nCOMMIT\n"
193        ))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn kinds_default_to_the_isolation_the_plan_names() {
203        let local = IsolationProfile::for_kind(NodeKind::Local);
204        assert!(
205            !local.kill_switch
206                && !local.agents_unprivileged
207                && local.tunnel == TunnelMode::External
208        );
209        let qemu = IsolationProfile::for_kind(NodeKind::Qemu);
210        assert!(qemu.kill_switch && qemu.agents_unprivileged && qemu.drop_provisioning_share);
211        let docker = IsolationProfile::for_kind(NodeKind::Docker);
212        assert!(
213            docker.kill_switch && docker.agents_unprivileged && !docker.drop_provisioning_share
214        );
215        let remote = IsolationProfile::for_kind(NodeKind::Remote);
216        assert!(!remote.kill_switch && remote.agents_unprivileged);
217        let relaxed = qemu.with_overrides(Some(false), None);
218        assert!(!relaxed.kill_switch && relaxed.agents_unprivileged);
219    }
220
221    #[test]
222    fn ruleset_drops_by_default_and_allows_only_loopback_tunnel_and_its_udp() {
223        let ks = KillSwitch::new("hatch0", "10.0.2.2:51820").unwrap();
224        let text = ks.nft_ruleset();
225        assert!(text.contains("policy drop;"));
226        assert_eq!(text.matches("policy drop;").count(), 3);
227        assert!(text.contains("ip daddr 10.0.2.2 udp dport 51820 accept"));
228        assert!(text.contains("oifname \"hatch0\" accept"));
229        assert!(text.contains("counter drop"));
230        // no catch-all accept
231        assert!(!text.lines().any(|l| l.trim() == "accept"));
232    }
233
234    #[test]
235    fn hostnames_and_odd_interface_names_are_refused() {
236        assert_eq!(
237            KillSwitch::new("hatch0", "c2.example:51820"),
238            Err(KillSwitchError::EndpointNotLiteral)
239        );
240        assert_eq!(
241            KillSwitch::new("a b", "10.0.0.1:1"),
242            Err(KillSwitchError::InvalidInterface)
243        );
244        assert_eq!(
245            KillSwitch::new("wg0\"; accept; \"", "10.0.0.1:1"),
246            Err(KillSwitchError::InvalidInterface)
247        );
248        assert_eq!(
249            KillSwitch::new("", "10.0.0.1:1"),
250            Err(KillSwitchError::InvalidInterface)
251        );
252        assert_eq!(
253            KillSwitch::new("0123456789abcdef", "10.0.0.1:1"),
254            Err(KillSwitchError::InvalidInterface)
255        );
256    }
257
258    #[test]
259    fn ipv6_endpoint_uses_ip6_and_iptables_variant_is_v4_only() {
260        let v6 = KillSwitch::new("wg0", "[fd00::1]:51820").unwrap();
261        assert!(v6
262            .nft_ruleset()
263            .contains("ip6 daddr fd00::1 udp dport 51820 accept"));
264        assert!(v6.iptables_restore().is_none());
265        let v4 = KillSwitch::new("wg0", "192.0.2.1:51820").unwrap();
266        let ipt = v4.iptables_restore().unwrap();
267        assert!(
268            ipt.contains(":OUTPUT DROP")
269                && ipt.contains("-d 192.0.2.1/32 -p udp --dport 51820 -j ACCEPT")
270        );
271    }
272}