1use std::net::{IpAddr, SocketAddr};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct RestreamerWgBind {
14 pub c2_tunnel_ip: IpAddr,
16 pub node_listen: SocketAddr,
18 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 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 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 pub fn drain_addr(&self) -> SocketAddr {
116 self.drain_listen
117 }
118
119 pub fn node_addr(&self) -> SocketAddr {
121 self.node_listen
122 }
123}
124
125#[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 }
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#[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 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}