Skip to main content

nd_300/diagnostics/
nat.rs

1//! NAT topology analysis — deep diagnostic.
2//!
3//! Pure post-processing over data other modules already collected (gateway
4//! IP, public IP, traceroute hops): detects double NAT (two private-range
5//! hops at the head of the path in different subnets) and carrier-grade NAT
6//! (any hop or the public IP inside 100.64.0.0/10 — port forwarding and
7//! hosting are impossible behind CGNAT, high technician value). Sync by
8//! design: it derives from other modules' outputs after they complete.
9
10use serde::Serialize;
11
12use super::public_ip::PublicIpInfo;
13use super::route_path::Hop;
14
15#[derive(Debug, Clone, Serialize)]
16pub struct NatAnalysis {
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub gateway_ip: Option<String>,
19    pub gateway_is_private: bool,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub hop2_ip: Option<String>,
22    pub hop2_is_private: bool,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub public_ip: Option<String>,
25    pub cgnat_detected: bool,
26    pub double_nat_suspected: bool,
27    pub nat_layers_estimate: u8,
28    pub assessment: String,
29    pub level: String,
30}
31
32/// Analyze NAT topology. Returns `None` (section omitted) when there is
33/// neither a gateway nor any traceroute data to reason from.
34pub fn analyze(
35    gateway_ip: Option<&str>,
36    public_ip: Option<&PublicIpInfo>,
37    hops: Option<&[Hop]>,
38) -> Option<NatAnalysis> {
39    if gateway_ip.is_none() && hops.is_none_or(|h| h.is_empty()) {
40        return None;
41    }
42
43    let gateway_is_private = gateway_ip.is_some_and(is_rfc1918_or_cgnat);
44
45    // Hop 2 = the device just past the local router. Private there (in a
46    // different subnet from the gateway) usually means a second NAT layer —
47    // an ISP modem/router in front of the user's own router.
48    let hop2 = hops.and_then(|h| {
49        h.iter()
50            .find(|hop| hop.number == 2)
51            .and_then(|hop| hop.ip.clone())
52    });
53    let hop2_is_private = hop2.as_deref().is_some_and(is_rfc1918_or_cgnat);
54
55    let public_addr = public_ip.map(|p| p.ip.clone());
56
57    // CGNAT: the public-facing address (or any path hop) is in 100.64/10.
58    let cgnat_detected = public_addr.as_deref().is_some_and(is_cgnat)
59        || hops.is_some_and(|h| h.iter().filter_map(|hop| hop.ip.as_deref()).any(is_cgnat));
60
61    let double_nat_suspected = gateway_is_private
62        && hop2_is_private
63        && !same_slash24(gateway_ip.unwrap_or(""), hop2.as_deref().unwrap_or(""));
64
65    let nat_layers_estimate =
66        u8::from(gateway_is_private) + u8::from(double_nat_suspected) + u8::from(cgnat_detected);
67
68    let (assessment, level) = if cgnat_detected {
69        (
70            "Carrier-grade NAT detected — inbound connections, port forwarding, and hosting won't work; contact the ISP for a public IP if needed",
71            "warn",
72        )
73    } else if double_nat_suspected {
74        (
75            "Double NAT suspected — two private networks stacked; consider bridging the ISP modem",
76            "warn",
77        )
78    } else if gateway_is_private {
79        ("Single NAT — the normal home/office setup", "ok")
80    } else {
81        (
82            "Gateway has a public address — no NAT (rare; verify the firewall is intentional)",
83            "warn",
84        )
85    };
86
87    Some(NatAnalysis {
88        gateway_ip: gateway_ip.map(|s| s.to_string()),
89        gateway_is_private,
90        hop2_ip: hop2,
91        hop2_is_private,
92        public_ip: public_addr,
93        cgnat_detected,
94        double_nat_suspected,
95        nat_layers_estimate,
96        assessment: assessment.to_string(),
97        level: level.to_string(),
98    })
99}
100
101fn is_rfc1918_or_cgnat(ip: &str) -> bool {
102    match ip.parse::<std::net::IpAddr>() {
103        Ok(std::net::IpAddr::V4(v4)) => v4.is_private() || v4.is_link_local() || is_cgnat_v4(&v4),
104        Ok(std::net::IpAddr::V6(v6)) => {
105            let seg = v6.segments()[0];
106            (seg & 0xfe00) == 0xfc00 || (seg & 0xffc0) == 0xfe80
107        }
108        Err(_) => false,
109    }
110}
111
112fn is_cgnat(ip: &str) -> bool {
113    match ip.parse::<std::net::IpAddr>() {
114        Ok(std::net::IpAddr::V4(v4)) => is_cgnat_v4(&v4),
115        _ => false,
116    }
117}
118
119fn is_cgnat_v4(v4: &std::net::Ipv4Addr) -> bool {
120    let o = v4.octets();
121    o[0] == 100 && (64..128).contains(&o[1])
122}
123
124/// Same /24 — used to tell "router seen twice" apart from "two NAT layers".
125fn same_slash24(a: &str, b: &str) -> bool {
126    let parse =
127        |s: &str| -> Option<[u8; 4]> { s.parse::<std::net::Ipv4Addr>().ok().map(|v| v.octets()) };
128    match (parse(a), parse(b)) {
129        (Some(x), Some(y)) => x[0] == y[0] && x[1] == y[1] && x[2] == y[2],
130        _ => false,
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn hop(number: u32, ip: &str) -> Hop {
139        Hop {
140            number,
141            ip: Some(ip.to_string()),
142            avg_ms: Some(1.0),
143            timed_out: false,
144        }
145    }
146
147    fn public(ip: &str) -> PublicIpInfo {
148        PublicIpInfo {
149            ip: ip.to_string(),
150            lookup_time_ms: 10.0,
151            behind_nat: true,
152            city: None,
153            region: None,
154            country: None,
155            isp: None,
156            org: None,
157        }
158    }
159
160    #[test]
161    fn single_nat_is_ok() {
162        let hops = [hop(1, "192.168.1.1"), hop(2, "68.86.92.25")];
163        let n = analyze(
164            Some("192.168.1.1"),
165            Some(&public("203.0.113.5")),
166            Some(&hops),
167        )
168        .unwrap();
169        assert!(!n.double_nat_suspected);
170        assert!(!n.cgnat_detected);
171        assert_eq!(n.nat_layers_estimate, 1);
172        assert_eq!(n.level, "ok");
173    }
174
175    #[test]
176    fn double_nat_detected_across_subnets() {
177        let hops = [hop(1, "192.168.1.1"), hop(2, "10.0.0.1")];
178        let n = analyze(
179            Some("192.168.1.1"),
180            Some(&public("203.0.113.5")),
181            Some(&hops),
182        )
183        .unwrap();
184        assert!(n.double_nat_suspected);
185        assert_eq!(n.level, "warn");
186        assert_eq!(n.nat_layers_estimate, 2);
187    }
188
189    #[test]
190    fn same_subnet_hop2_is_not_double_nat() {
191        let hops = [hop(1, "192.168.1.1"), hop(2, "192.168.1.254")];
192        let n = analyze(Some("192.168.1.1"), None, Some(&hops)).unwrap();
193        assert!(!n.double_nat_suspected);
194    }
195
196    #[test]
197    fn cgnat_detected_from_public_ip() {
198        let n = analyze(Some("192.168.1.1"), Some(&public("100.72.13.4")), None).unwrap();
199        assert!(n.cgnat_detected);
200        assert_eq!(n.level, "warn");
201        assert!(n.assessment.contains("Carrier-grade"));
202    }
203
204    #[test]
205    fn cgnat_detected_from_path_hop() {
206        let hops = [hop(1, "192.168.1.1"), hop(2, "100.64.0.1")];
207        let n = analyze(
208            Some("192.168.1.1"),
209            Some(&public("203.0.113.5")),
210            Some(&hops),
211        )
212        .unwrap();
213        assert!(n.cgnat_detected);
214    }
215
216    #[test]
217    fn no_inputs_returns_none() {
218        assert!(analyze(None, None, None).is_none());
219        assert!(analyze(None, None, Some(&[])).is_none());
220    }
221
222    #[test]
223    fn public_gateway_flagged() {
224        let n = analyze(Some("203.0.113.1"), None, Some(&[hop(1, "203.0.113.1")])).unwrap();
225        assert!(!n.gateway_is_private);
226        assert_eq!(n.level, "warn");
227    }
228}