1use 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
32pub 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 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 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
124fn 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}