Skip to main content

seyd_nat/
gather.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Candidate gathering and the NatReport.
4
5use 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    /// `https://host:port/seyd`
14    pub url: String,
15    pub label: String,
16    pub priority: u32,
17    pub needs_probe: bool,
18    pub family: u8,
19}
20
21/// How hopeful the pilot should be. Drives its P2P deadline so a doomed
22/// attempt fails fast while a plausible one gets the full window.
23#[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    /// Skip discovery and advertise only this address (dev / manual forwarding).
35    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    /// Filled by the cloud prober, never by the agent.
47    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    /// Every advertised IP — goes into the certificate's SubjectAlternativeName.
85    pub san_ips: Vec<IpAddr>,
86    pub nat_report: NatReport,
87    pub p2p_hint: Hint,
88    /// Kept so the caller can renew the lease.
89    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
123/// Work out every address a pilot could reach us on.
124///
125/// Mirrors the prototype's `gather_candidates`: host candidates first (a LAN
126/// address wins instantly on a shared network; global IPv6 has no NAT at
127/// all), then an explicit router port mapping, then the STUN-reflexive
128/// address — suppressed under symmetric NAT, where it can only waste a slot
129/// in the pilot's race. `socks[0]` must be the IPv4 socket QUIC will use.
130pub 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    // Host candidates.
186    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), // firewall pinhole still helps
190            _ => {}
191        }
192    }
193
194    // Router port mapping — an installed mapping beats an inferred one.
195    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                    // Still keep it for lease renewal: it removes the inner NAT from the path.
221                    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    // Server-reflexive candidate.
232    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}