1use crate::NodeKind;
8use serde::{Deserialize, Serialize};
9use std::net::{IpAddr, SocketAddr};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum KeyStore {
15 StateDir,
17 OsKeyringOrHardware,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum TunnelMode {
25 NodeManaged,
27 External,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct IsolationProfile {
34 pub kind: NodeKind,
35 pub kill_switch: bool,
37 pub agents_unprivileged: bool,
39 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 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 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 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 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#[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 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 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 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}