Skip to main content

hatchery_c2_client/
hq_wg.rs

1//! HQ WireGuard client: one peer (C2), listen port 0, kernel interface.
2//!
3//! HQ is not a mesh peer. This module does not dial a node, does not install
4//! a route to any address except the single C2 tunnel address, and does not
5//! call the UDP+ChaCha mesh underlay or `open_wireguard_daemon_stub`.
6//! Failure to create `ip link add type wireguard` is an error.
7
8use std::net::{IpAddr, SocketAddr};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use thiserror::Error;
13
14/// One allowed IP. HQ accepts only the host route of the C2 tunnel address.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct HqWgAllowedIp {
17    pub address: IpAddr,
18    pub prefix_len: u8,
19}
20
21impl HqWgAllowedIp {
22    pub fn host(address: IpAddr) -> Self {
23        Self {
24            address,
25            prefix_len: match address {
26                IpAddr::V4(_) => 32,
27                IpAddr::V6(_) => 128,
28            },
29        }
30    }
31
32    pub fn parse(text: &str) -> Option<Self> {
33        let (addr, prefix) = text.split_once('/')?;
34        let address: IpAddr = addr.parse().ok()?;
35        let prefix_len: u8 = prefix.parse().ok()?;
36        let max = match address {
37            IpAddr::V4(_) => 32,
38            IpAddr::V6(_) => 128,
39        };
40        if prefix_len > max {
41            return None;
42        }
43        Some(Self { address, prefix_len })
44    }
45
46    fn cidr(&self) -> String {
47        format!("{}/{}", self.address, self.prefix_len)
48    }
49}
50
51/// The single C2 peer HQ dials.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct HqWgPeer {
54    pub public_key: String,
55    /// C2's public WireGuard endpoint (`host:port`). `None` is rejected.
56    pub endpoint: Option<SocketAddr>,
57    pub allowed_ips: Vec<HqWgAllowedIp>,
58    pub persistent_keepalive_secs: u16,
59}
60
61/// HQ-side client configuration. Listen port must be 0.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct HqWgClientConfig {
64    pub interface_name: String,
65    pub private_key_path: PathBuf,
66    pub listen_port: u16,
67    pub hq_tunnel_address: IpAddr,
68    pub c2_tunnel_address: IpAddr,
69    pub control_port: u16,
70    /// A2: port of the C2 restreamer drain listener on the tunnel IP.
71    pub drain_port: u16,
72    pub peers: Vec<HqWgPeer>,
73}
74
75impl HqWgClientConfig {
76    pub fn validate(&self) -> Result<(), HqWgClientError> {
77        if !valid_iface(&self.interface_name) {
78            return Err(HqWgClientError::InterfaceName);
79        }
80        if self.private_key_path.as_os_str().is_empty() {
81            return Err(HqWgClientError::PrivateKeyPath);
82        }
83        if self.listen_port != 0 {
84            return Err(HqWgClientError::ListenPort(self.listen_port));
85        }
86        if self.control_port == 0 {
87            return Err(HqWgClientError::ControlPort);
88        }
89        if self.drain_port == 0 {
90            return Err(HqWgClientError::DrainPort);
91        }
92        if self.hq_tunnel_address.is_unspecified()
93            || self.c2_tunnel_address.is_unspecified()
94            || self.hq_tunnel_address == self.c2_tunnel_address
95            || std::mem::discriminant(&self.hq_tunnel_address)
96                != std::mem::discriminant(&self.c2_tunnel_address)
97        {
98            return Err(HqWgClientError::TunnelAddress);
99        }
100        if self.peers.len() != 1 {
101            return Err(HqWgClientError::PeerCount(self.peers.len()));
102        }
103        let peer = &self.peers[0];
104        if peer.public_key.is_empty() {
105            return Err(HqWgClientError::PeerKey);
106        }
107        match peer.endpoint {
108            Some(endpoint) if !endpoint.ip().is_unspecified() && endpoint.port() != 0 => {}
109            _ => return Err(HqWgClientError::MissingEndpoint),
110        }
111        if peer.persistent_keepalive_secs == 0 {
112            return Err(HqWgClientError::KeepaliveDisabled);
113        }
114        if !allowed_ips_are_only_c2(&peer.allowed_ips, self.c2_tunnel_address) {
115            return Err(HqWgClientError::AllowedIps);
116        }
117        Ok(())
118    }
119
120    /// `c2_tunnel_ip:control_port`. Never the public WireGuard endpoint.
121    pub fn control_addr(&self) -> Result<SocketAddr, HqWgClientError> {
122        self.validate()?;
123        Ok(SocketAddr::new(self.c2_tunnel_address, self.control_port))
124    }
125
126    /// `c2_tunnel_ip:drain_port` — core dials the restreamer drain over WG.
127    pub fn drain_addr(&self) -> Result<SocketAddr, HqWgClientError> {
128        self.validate()?;
129        Ok(SocketAddr::new(self.c2_tunnel_address, self.drain_port))
130    }
131}
132
133fn allowed_ips_are_only_c2(allowed: &[HqWgAllowedIp], c2: IpAddr) -> bool {
134    let Some(one) = allowed.first() else {
135        return false;
136    };
137    if allowed.len() != 1 || one.address != c2 {
138        return false;
139    }
140    match c2 {
141        IpAddr::V4(_) => one.prefix_len == 32,
142        IpAddr::V6(_) => one.prefix_len == 128,
143    }
144}
145
146fn valid_iface(name: &str) -> bool {
147    let bytes = name.as_bytes();
148    (1..16).contains(&bytes.len())
149        && bytes.iter().all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'-')
150}
151
152#[derive(Debug, Error)]
153pub enum HqWgClientError {
154    #[error("HQ WireGuard client is missing the C2 public endpoint")]
155    MissingEndpoint,
156    #[error("HQ WireGuard listen port must be 0, got {0}")]
157    ListenPort(u16),
158    #[error("HQ WireGuard allowed IPs must be only the single C2 tunnel address")]
159    AllowedIps,
160    #[error("HQ WireGuard client must have exactly one peer (C2), got {0}")]
161    PeerCount(usize),
162    #[error("HQ WireGuard persistent keepalive must be on")]
163    KeepaliveDisabled,
164    #[error("HQ WireGuard interface name is invalid")]
165    InterfaceName,
166    #[error("HQ WireGuard private key path is empty")]
167    PrivateKeyPath,
168    #[error("HQ WireGuard tunnel addresses are invalid")]
169    TunnelAddress,
170    #[error("HQ WireGuard control port must be nonzero")]
171    ControlPort,
172    #[error("HQ WireGuard drain port must be nonzero")]
173    DrainPort,
174    #[error("HQ WireGuard peer public key is empty")]
175    PeerKey,
176    #[error("kernel wireguard interface was not created: {0}")]
177    InterfaceCreate(String),
178    #[error("kernel wireguard setup failed: {0}")]
179    Setup(String),
180}
181
182/// Creates the HQ interface with kernel WireGuard and installs a route only
183/// to the C2 tunnel address. A failed `ip link add type wireguard` is returned
184/// as [`HqWgClientError::InterfaceCreate`] with no other transport substituted.
185pub fn bring_up_hq_wireguard(config: &HqWgClientConfig) -> Result<SocketAddr, HqWgClientError> {
186    let control = config.control_addr()?;
187    let peer = &config.peers[0];
188    let endpoint = peer.endpoint.expect("validate requires an endpoint");
189    let key_path = config.private_key_path.as_path();
190    require_key_file(key_path)?;
191
192    create_wireguard_iface(&config.interface_name)?;
193    let configured = configure_wireguard(config, peer, endpoint);
194    if let Err(error) = configured {
195        delete_iface(&config.interface_name);
196        return Err(error);
197    }
198    Ok(control)
199}
200
201fn require_key_file(path: &Path) -> Result<(), HqWgClientError> {
202    let meta = std::fs::metadata(path).map_err(|error| {
203        HqWgClientError::Setup(format!("private key path {}: {error}", path.display()))
204    })?;
205    if !meta.is_file() || meta.len() == 0 {
206        return Err(HqWgClientError::Setup(format!(
207            "private key path {} is not a nonempty file",
208            path.display()
209        )));
210    }
211    Ok(())
212}
213
214fn create_wireguard_iface(name: &str) -> Result<(), HqWgClientError> {
215    let output = Command::new("ip")
216        .args(["link", "add", "dev", name, "type", "wireguard"])
217        .output()
218        .map_err(|error| {
219            HqWgClientError::InterfaceCreate(format!("ip link add type wireguard: {error}"))
220        })?;
221    if output.status.success() {
222        return Ok(());
223    }
224    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
225    Err(HqWgClientError::InterfaceCreate(format!(
226        "ip link add dev {name} type wireguard failed: {stderr}"
227    )))
228}
229
230fn configure_wireguard(
231    config: &HqWgClientConfig,
232    peer: &HqWgPeer,
233    endpoint: SocketAddr,
234) -> Result<(), HqWgClientError> {
235    let name = &config.interface_name;
236    let allowed = peer.allowed_ips[0].cidr();
237    command_ok(
238        "wg",
239        &[
240            "set",
241            name,
242            "listen-port",
243            "0",
244            "private-key",
245            &config.private_key_path.display().to_string(),
246            "peer",
247            &peer.public_key,
248            "endpoint",
249            &endpoint.to_string(),
250            "allowed-ips",
251            &allowed,
252            "persistent-keepalive",
253            &peer.persistent_keepalive_secs.to_string(),
254        ],
255    )?;
256    let prefix = match config.hq_tunnel_address {
257        IpAddr::V4(_) => 32,
258        IpAddr::V6(_) => 128,
259    };
260    let local = format!("{}/{}", config.hq_tunnel_address, prefix);
261    command_ok("ip", &["addr", "replace", &local, "dev", name])?;
262    command_ok("ip", &["link", "set", "up", "dev", name])?;
263    let route = format!("{}/{}", config.c2_tunnel_address, if prefix == 32 { 32 } else { 128 });
264    // AllowedIPs did not install a fib route on this kernel; the explicit
265    // route is still only the C2 tunnel host, never a node prefix.
266    command_ok("ip", &["route", "replace", &route, "dev", name])?;
267    Ok(())
268}
269
270fn command_ok(program: &str, args: &[&str]) -> Result<(), HqWgClientError> {
271    let output = Command::new(program).args(args).output().map_err(|error| {
272        HqWgClientError::Setup(format!("{program}: {error}"))
273    })?;
274    if output.status.success() {
275        return Ok(());
276    }
277    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
278    Err(HqWgClientError::Setup(format!(
279        "{program} {} failed: {stderr}",
280        args.join(" ")
281    )))
282}
283
284fn delete_iface(name: &str) {
285    let _ = Command::new("ip").args(["link", "del", "dev", name]).status();
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn sample() -> HqWgClientConfig {
293        HqWgClientConfig {
294            interface_name: "wg-hq".to_owned(),
295            private_key_path: PathBuf::from("/tmp/hq.priv"),
296            listen_port: 0,
297            hq_tunnel_address: "10.88.1.3".parse().unwrap(),
298            c2_tunnel_address: "10.88.1.2".parse().unwrap(),
299            control_port: 27441,
300            drain_port: 19002,
301            peers: vec![HqWgPeer {
302                public_key: "pyjbO05QwAyerdhvLEogM9JRv8P8Im52v2b3P1KHfwM=".to_owned(),
303                endpoint: Some("192.168.78.2:51820".parse().unwrap()),
304                allowed_ips: vec![HqWgAllowedIp::host("10.88.1.2".parse().unwrap())],
305                persistent_keepalive_secs: 25,
306            }],
307        }
308    }
309
310    #[test]
311    fn single_c2_tunnel_host_is_accepted() {
312        let config = sample();
313        assert!(config.validate().is_ok());
314        assert_eq!(
315            config.control_addr().unwrap(),
316            "10.88.1.2:27441".parse().unwrap()
317        );
318    }
319
320    #[test]
321    fn missing_endpoint_is_rejected() {
322        let mut config = sample();
323        config.peers[0].endpoint = None;
324        assert!(matches!(config.validate(), Err(HqWgClientError::MissingEndpoint)));
325        config.peers[0].endpoint = Some("0.0.0.0:51820".parse().unwrap());
326        assert!(matches!(config.validate(), Err(HqWgClientError::MissingEndpoint)));
327    }
328
329    #[test]
330    fn listen_port_other_than_zero_is_rejected() {
331        let mut config = sample();
332        config.listen_port = 51820;
333        assert!(matches!(config.validate(), Err(HqWgClientError::ListenPort(51820))));
334    }
335
336    #[test]
337    fn allowed_ips_other_than_the_c2_tunnel_address_are_rejected() {
338        let node: IpAddr = "10.88.1.1".parse().unwrap();
339        let c2: IpAddr = "10.88.1.2".parse().unwrap();
340        let cases = [
341            vec![HqWgAllowedIp { address: "10.88.1.0".parse().unwrap(), prefix_len: 24 }],
342            vec![HqWgAllowedIp { address: c2, prefix_len: 24 }],
343            vec![HqWgAllowedIp::host(c2), HqWgAllowedIp::host(node)],
344            vec![HqWgAllowedIp::host(node)],
345            vec![],
346        ];
347        for allowed in cases {
348            let mut config = sample();
349            config.peers[0].allowed_ips = allowed;
350            assert!(
351                matches!(config.validate(), Err(HqWgClientError::AllowedIps)),
352                "allowed-ips must be only 10.88.1.2/32"
353            );
354        }
355    }
356
357    #[test]
358    fn drain_addr_is_tunnel_port() {
359        let cfg = sample();
360        assert_eq!(cfg.drain_addr().unwrap().to_string(), "10.88.1.2:19002");
361    }
362}