1use crate::addr::{format_host, is_global};
6use crate::portmap::{self, PortMapping};
7use crate::stun::{self, NatType};
8use serde::Serialize;
9use std::net::{IpAddr, UdpSocket};
10
11#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
12pub struct Candidate {
13 pub url: String,
15 pub label: String,
16 pub priority: u32,
17 pub needs_probe: bool,
18 pub family: u8,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum Hint {
26 Likely,
27 LanOnly,
28 None,
29}
30
31#[derive(Debug, Clone)]
32pub struct GatherOpts {
33 pub port: u16,
34 pub host_override: Option<IpAddr>,
36 pub port_mapping: bool,
37 pub ipv6: bool,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct NatReport {
42 pub ipv4: Ipv4Report,
43 pub ipv6: Ipv6Report,
44 pub portmap: PortmapReport,
45 pub candidates: Vec<CandidateReport>,
46 pub prober: Option<serde_json::Value>,
48 pub hint: Hint,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct Ipv4Report {
53 pub local: Vec<IpAddr>,
54 pub public: Option<IpAddr>,
55 pub nat: NatType,
56 pub cgnat: bool,
57 pub gateway_external: Option<IpAddr>,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct Ipv6Report {
62 pub present: bool,
63 pub global: Vec<IpAddr>,
64 pub inbound_ok: Option<bool>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct PortmapReport {
69 pub protocol: Option<portmap::MapProtocol>,
70 pub external: Option<String>,
71 pub error: Option<String>,
72 pub lease_s: Option<u32>,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct CandidateReport {
77 pub label: String,
78 pub ok: Option<bool>,
79}
80
81#[derive(Debug, Clone)]
82pub struct Gathered {
83 pub candidates: Vec<Candidate>,
84 pub san_ips: Vec<IpAddr>,
86 pub nat_report: NatReport,
87 pub p2p_hint: Hint,
88 pub mapping: Option<PortMapping>,
90}
91
92fn is_cgnat(ip: IpAddr) -> bool {
93 matches!(ip, IpAddr::V4(v4) if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
94}
95
96struct Builder {
97 port: u16,
98 candidates: Vec<Candidate>,
99 san_ips: Vec<IpAddr>,
100}
101
102impl Builder {
103 fn add(&mut self, ip: IpAddr, port: u16, label: &str, priority: u32, needs_probe: bool) {
104 let url = format!("https://{}:{}/seyd", format_host(ip), port);
105 if self.candidates.iter().any(|c| c.url == url) {
106 return;
107 }
108 tracing::info!("candidate [{label:<8} prio {priority:>3}] {url}");
109 self.candidates.push(Candidate {
110 url,
111 label: label.to_string(),
112 priority,
113 needs_probe,
114 family: if ip.is_ipv6() { 6 } else { 4 },
115 });
116 if !self.san_ips.contains(&ip) {
117 self.san_ips.push(ip);
118 }
119 let _ = self.port;
120 }
121}
122
123pub async fn gather(socks: &[UdpSocket], opts: &GatherOpts) -> Gathered {
131 let mut b = Builder {
132 port: opts.port,
133 candidates: Vec::new(),
134 san_ips: Vec::new(),
135 };
136 let local = crate::ifaces::interfaces();
137 let local_v4: Vec<IpAddr> = local.iter().copied().filter(IpAddr::is_ipv4).collect();
138 let global_v6: Vec<IpAddr> = local
139 .iter()
140 .copied()
141 .filter(|ip| ip.is_ipv6() && opts.ipv6)
142 .collect();
143
144 if let Some(ip) = opts.host_override {
145 b.add(ip, opts.port, "host-override", 250, false);
146 let report = NatReport {
147 ipv4: Ipv4Report {
148 local: local_v4,
149 public: None,
150 nat: NatType::Unknown,
151 cgnat: false,
152 gateway_external: None,
153 },
154 ipv6: Ipv6Report {
155 present: !global_v6.is_empty(),
156 global: global_v6,
157 inbound_ok: None,
158 },
159 portmap: PortmapReport {
160 protocol: None,
161 external: None,
162 error: Some("skipped: host override".into()),
163 lease_s: None,
164 },
165 candidates: b
166 .candidates
167 .iter()
168 .map(|c| CandidateReport {
169 label: c.label.clone(),
170 ok: None,
171 })
172 .collect(),
173 prober: None,
174 hint: Hint::Likely,
175 };
176 return Gathered {
177 candidates: b.candidates,
178 san_ips: b.san_ips,
179 nat_report: report,
180 p2p_hint: Hint::Likely,
181 mapping: None,
182 };
183 }
184
185 for ip in &local {
187 match ip {
188 IpAddr::V4(_) => b.add(*ip, opts.port, "host", 240, false),
189 IpAddr::V6(_) if opts.ipv6 => b.add(*ip, opts.port, "host6", 200, true), _ => {}
191 }
192 }
193
194 let mut mapping = None;
196 let mut portmap_report = PortmapReport {
197 protocol: None,
198 external: None,
199 error: None,
200 lease_s: None,
201 };
202 let mut gateway_external = None;
203 if opts.port_mapping {
204 match portmap::map_port(opts.port, 3600).await {
205 Some(m) => {
206 gateway_external = Some(m.external_ip);
207 portmap_report.protocol = Some(m.protocol);
208 portmap_report.external = Some(format!(
209 "{}:{}",
210 format_host(m.external_ip),
211 m.external_port
212 ));
213 portmap_report.lease_s = Some(m.lifetime_s);
214 if m.routable {
215 b.add(m.external_ip, m.external_port, "portmap", 220, false);
216 mapping = Some(m);
217 } else {
218 portmap_report.error =
219 Some("external address not globally routable (double NAT or CGNAT)".into());
220 mapping = Some(m);
222 }
223 }
224 None => portmap_report.error = Some("no-gateway-response".into()),
225 }
226 } else {
227 portmap_report.error = Some("disabled".into());
228 }
229 let advertised_mapping = mapping.as_ref().filter(|m| m.routable).is_some();
230
231 let nat = match socks.first() {
233 Some(s) => stun::discover(s).await,
234 None => stun::NatDiscovery::default(),
235 };
236 if let Some(r) = nat.reflexive {
237 b.add(r.ip(), r.port(), "srflx", 150, true);
238 } else if nat.symmetric {
239 tracing::info!("skipping reflexive candidate — symmetric NAT");
240 }
241
242 let hint = if advertised_mapping || nat.reflexive.is_some() || !global_v6.is_empty() {
243 Hint::Likely
244 } else if !b.candidates.is_empty() {
245 Hint::LanOnly
246 } else {
247 Hint::None
248 };
249 tracing::info!("NAT type: {:?} — P2P outlook: {:?}", nat.nat_type(), hint);
250
251 let public = nat
252 .reflexive
253 .map(|a| a.ip())
254 .or(nat.observed.first().map(|a| a.ip()))
255 .or(gateway_external.filter(|ip| is_global(*ip)));
256 let cgnat = gateway_external.map(is_cgnat).unwrap_or(false)
257 || (nat.symmetric && gateway_external.is_none() && local_v4.iter().any(|ip| is_cgnat(*ip)));
258
259 let report = NatReport {
260 ipv4: Ipv4Report {
261 local: local_v4,
262 public,
263 nat: nat.nat_type(),
264 cgnat,
265 gateway_external,
266 },
267 ipv6: Ipv6Report {
268 present: !global_v6.is_empty(),
269 global: global_v6,
270 inbound_ok: None,
271 },
272 portmap: portmap_report,
273 candidates: b
274 .candidates
275 .iter()
276 .map(|c| CandidateReport {
277 label: c.label.clone(),
278 ok: None,
279 })
280 .collect(),
281 prober: None,
282 hint,
283 };
284 let mut candidates = b.candidates;
285 candidates.sort_by_key(|c| std::cmp::Reverse(c.priority));
286 Gathered {
287 candidates,
288 san_ips: b.san_ips,
289 nat_report: report,
290 p2p_hint: hint,
291 mapping,
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn candidate_ordering_and_dedup() {
301 let mut b = Builder {
302 port: 4433,
303 candidates: vec![],
304 san_ips: vec![],
305 };
306 b.add("1.2.3.4".parse().unwrap(), 4433, "srflx", 150, true);
307 b.add("192.168.1.5".parse().unwrap(), 4433, "host", 240, false);
308 b.add("2606:4700::1111".parse().unwrap(), 4433, "host6", 200, true);
309 b.add("192.168.1.5".parse().unwrap(), 4433, "host", 240, false);
310 let mut c = b.candidates.clone();
311 c.sort_by_key(|c| std::cmp::Reverse(c.priority));
312 let labels: Vec<&str> = c.iter().map(|c| c.label.as_str()).collect();
313 assert_eq!(labels, ["host", "host6", "srflx"]);
314 assert_eq!(c[1].url, "https://[2606:4700::1111]:4433/seyd");
315 assert_eq!(c[1].family, 6);
316 assert_eq!(b.san_ips.len(), 3);
317 }
318
319 #[test]
320 fn cgnat_detection() {
321 assert!(is_cgnat("100.87.8.146".parse().unwrap()));
322 assert!(!is_cgnat("100.128.0.1".parse().unwrap()));
323 assert!(!is_cgnat("10.0.0.1".parse().unwrap()));
324 }
325
326 #[test]
327 fn report_serialises_to_plan_shape() {
328 let r = NatReport {
329 ipv4: Ipv4Report {
330 local: vec!["192.168.1.20".parse().unwrap()],
331 public: None,
332 nat: NatType::Symmetric,
333 cgnat: true,
334 gateway_external: Some("100.87.3.2".parse().unwrap()),
335 },
336 ipv6: Ipv6Report {
337 present: false,
338 global: vec![],
339 inbound_ok: None,
340 },
341 portmap: PortmapReport {
342 protocol: Some(portmap::MapProtocol::Pcp),
343 external: Some("100.87.3.2:4433".into()),
344 error: None,
345 lease_s: Some(3600),
346 },
347 candidates: vec![CandidateReport {
348 label: "host".into(),
349 ok: None,
350 }],
351 prober: None,
352 hint: Hint::LanOnly,
353 };
354 let v = serde_json::to_value(&r).unwrap();
355 assert_eq!(v["ipv4"]["nat"], "symmetric");
356 assert_eq!(v["ipv4"]["cgnat"], true);
357 assert_eq!(v["portmap"]["protocol"], "pcp");
358 assert_eq!(v["hint"], "lan-only");
359 assert!(v["prober"].is_null());
360 assert!(v["ipv6"]["inbound_ok"].is_null());
361 }
362}