Skip to main content

hatchery_c2/restreamer/
wg.rs

1//! WireGuard binding rules for the restreamer (A2).
2//!
3//! Production: both the node uplink and the core drain listeners bind to the
4//! C2 tunnel address (never `0.0.0.0`). Kernel WireGuard provides the outer
5//! transport layer; the multiplex and nested crypto sit inside.
6//!
7//! Loopback binds remain allowed for local tests. [`RestreamerWgBind::production`]
8//! rejects loopback and unspecified addresses.
9
10use std::net::{IpAddr, SocketAddr};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct RestreamerWgBind {
14    /// C2 address inside the WireGuard tunnel.
15    pub c2_tunnel_ip: IpAddr,
16    /// Node multiplex uplink (`c2_tunnel_ip:port`).
17    pub node_listen: SocketAddr,
18    /// Core drain listen (`c2_tunnel_ip:port`), distinct from node_listen.
19    pub drain_listen: SocketAddr,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum WgBindError {
24    Unspecified,
25    PortZero,
26    NotOnTunnel { got: IpAddr, expected: IpAddr },
27    SamePort,
28    LoopbackInProduction,
29}
30
31impl std::fmt::Display for WgBindError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Unspecified => write!(f, "restreamer must not bind 0.0.0.0"),
35            Self::PortZero => write!(f, "restreamer ports must be nonzero"),
36            Self::NotOnTunnel { got, expected } => {
37                write!(f, "listen ip {got} is not the C2 tunnel ip {expected}")
38            }
39            Self::SamePort => write!(f, "node and drain listen ports must differ"),
40            Self::LoopbackInProduction => {
41                write!(f, "production restreamer binds must use the WG tunnel address")
42            }
43        }
44    }
45}
46
47impl std::error::Error for WgBindError {}
48
49impl RestreamerWgBind {
50    /// Validate a production bind: both listeners on `c2_tunnel_ip`, nonzero
51    /// distinct ports, not loopback, not unspecified.
52    pub fn production(
53        c2_tunnel_ip: IpAddr,
54        node_port: u16,
55        drain_port: u16,
56    ) -> Result<Self, WgBindError> {
57        if c2_tunnel_ip.is_unspecified() {
58            return Err(WgBindError::Unspecified);
59        }
60        if c2_tunnel_ip.is_loopback() {
61            return Err(WgBindError::LoopbackInProduction);
62        }
63        if node_port == 0 || drain_port == 0 {
64            return Err(WgBindError::PortZero);
65        }
66        if node_port == drain_port {
67            return Err(WgBindError::SamePort);
68        }
69        let node_listen = SocketAddr::new(c2_tunnel_ip, node_port);
70        let drain_listen = SocketAddr::new(c2_tunnel_ip, drain_port);
71        Self::validate(c2_tunnel_ip, node_listen, drain_listen, true)
72    }
73
74    /// Validate listeners share the tunnel IP (loopback allowed when `require_tunnel` is false).
75    pub fn validate(
76        c2_tunnel_ip: IpAddr,
77        node_listen: SocketAddr,
78        drain_listen: SocketAddr,
79        require_tunnel: bool,
80    ) -> Result<Self, WgBindError> {
81        if node_listen.ip().is_unspecified() || drain_listen.ip().is_unspecified() {
82            return Err(WgBindError::Unspecified);
83        }
84        if node_listen.port() == 0 || drain_listen.port() == 0 {
85            return Err(WgBindError::PortZero);
86        }
87        if node_listen.port() == drain_listen.port() && node_listen.ip() == drain_listen.ip() {
88            return Err(WgBindError::SamePort);
89        }
90        if require_tunnel {
91            if c2_tunnel_ip.is_loopback() {
92                return Err(WgBindError::LoopbackInProduction);
93            }
94            if node_listen.ip() != c2_tunnel_ip {
95                return Err(WgBindError::NotOnTunnel {
96                    got: node_listen.ip(),
97                    expected: c2_tunnel_ip,
98                });
99            }
100            if drain_listen.ip() != c2_tunnel_ip {
101                return Err(WgBindError::NotOnTunnel {
102                    got: drain_listen.ip(),
103                    expected: c2_tunnel_ip,
104                });
105            }
106        }
107        Ok(Self {
108            c2_tunnel_ip,
109            node_listen,
110            drain_listen,
111        })
112    }
113
114    /// Address core dials for the drain stream (over WG).
115    pub fn drain_addr(&self) -> SocketAddr {
116        self.drain_listen
117    }
118
119    /// Address nodes dial for the multiplex uplink (over WG).
120    pub fn node_addr(&self) -> SocketAddr {
121        self.node_listen
122    }
123}
124
125/// Core-side dial target once HQ's WireGuard tunnel is up.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct CoreDrainTarget {
128    pub c2_tunnel_ip: IpAddr,
129    pub drain_port: u16,
130}
131
132impl CoreDrainTarget {
133    pub fn new(c2_tunnel_ip: IpAddr, drain_port: u16) -> Result<Self, WgBindError> {
134        if c2_tunnel_ip.is_unspecified() || drain_port == 0 {
135            return Err(WgBindError::PortZero);
136        }
137        if c2_tunnel_ip.is_loopback() {
138            // Allowed for tests; production HQ uses the tunnel IP from enroll.
139        }
140        Ok(Self {
141            c2_tunnel_ip,
142            drain_port,
143        })
144    }
145
146    pub fn socket_addr(&self) -> SocketAddr {
147        SocketAddr::new(self.c2_tunnel_ip, self.drain_port)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::net::Ipv4Addr;
155
156    #[test]
157    fn production_bind_on_tunnel_ip() {
158        let ip = IpAddr::V4(Ipv4Addr::new(10, 88, 1, 1));
159        let bind = RestreamerWgBind::production(ip, 19001, 19002).unwrap();
160        assert_eq!(bind.node_addr().ip(), ip);
161        assert_eq!(bind.drain_addr().port(), 19002);
162    }
163
164    #[test]
165    fn production_rejects_loopback_and_same_port() {
166        let lo = IpAddr::V4(Ipv4Addr::LOCALHOST);
167        assert!(matches!(
168            RestreamerWgBind::production(lo, 1, 2),
169            Err(WgBindError::LoopbackInProduction)
170        ));
171        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
172        assert!(matches!(
173            RestreamerWgBind::production(ip, 9, 9),
174            Err(WgBindError::SamePort)
175        ));
176    }
177
178    #[test]
179    fn listeners_must_use_tunnel_ip() {
180        let tunnel = IpAddr::V4(Ipv4Addr::new(10, 88, 1, 1));
181        let other = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 88, 1, 2)), 19001);
182        let drain = SocketAddr::new(tunnel, 19002);
183        assert!(matches!(
184            RestreamerWgBind::validate(tunnel, other, drain, true),
185            Err(WgBindError::NotOnTunnel { .. })
186        ));
187    }
188
189    #[test]
190    fn core_drain_target_addr() {
191        let t = CoreDrainTarget::new(IpAddr::V4(Ipv4Addr::new(10, 88, 1, 1)), 19002).unwrap();
192        assert_eq!(t.socket_addr().to_string(), "10.88.1.1:19002");
193    }
194}
195
196/// Bring up a kernel WireGuard interface for a restreamer bind. Needs CAP_NET_ADMIN
197/// and the `wg` tool; ignored by default so ordinary CI stays green.
198#[cfg(test)]
199mod bringup_tests {
200    use super::*;
201    use std::net::{IpAddr, Ipv4Addr};
202    use std::process::Command;
203
204    fn have_wg() -> bool {
205        Command::new("wg").arg("help").output().map(|o| o.status.success() || o.status.code().is_some()).unwrap_or(false)
206    }
207
208    #[test]
209    #[ignore = "needs CAP_NET_ADMIN and wg; opt-in with --ignored"]
210    fn wireguard_bring_up_for_restreamer_bind() {
211        if !have_wg() {
212            eprintln!("wg not installed; skipping bring-up body");
213            return;
214        }
215        let ifname = format!("hyrst{}", std::process::id() % 10000);
216        let tunnel = IpAddr::V4(Ipv4Addr::new(10, 66, 0, 1));
217        let bind = RestreamerWgBind::production(tunnel, 9100, 9101).expect("production bind");
218        assert_eq!(bind.node_listen.ip(), tunnel);
219        assert_eq!(bind.drain_listen.ip(), tunnel);
220
221        // Create a temporary WireGuard device, assign the tunnel address, then tear it down.
222        // No peers, no keys logged. Failures here mean the host lacks privileges.
223        let add = Command::new("ip").args(["link", "add", &ifname, "type", "wireguard"]).output().expect("ip");
224        if !add.status.success() {
225            let err = String::from_utf8_lossy(&add.stderr);
226            panic!("ip link add failed (need CAP_NET_ADMIN): {err}");
227        }
228        let _ = Command::new("ip").args(["addr", "add", &format!("{tunnel}/32"), "dev", &ifname]).status();
229        let _ = Command::new("ip").args(["link", "set", "dev", &ifname, "up"]).status();
230        let show = Command::new("wg").args(["show", &ifname]).output().expect("wg show");
231        assert!(show.status.success(), "wg show {}", String::from_utf8_lossy(&show.stderr));
232        let _ = Command::new("ip").args(["link", "del", &ifname]).status();
233    }
234}