1use crate::nat_detection::{NatProfile, TransportMethod};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ExposureMode {
27 OutboundOnly,
28 Ipv4PublicDirect,
29 Ipv4CgnatBlocked,
30 Ipv6DirectFirewallRequired,
31 Ipv6DirectPinholeAvailable,
32 RelayRequired,
33 TunnelRequired,
34 DualStackAvailable,
35}
36
37impl ExposureMode {
38 pub fn as_str(&self) -> &'static str {
40 match self {
41 Self::OutboundOnly => "outbound_only",
42 Self::Ipv4PublicDirect => "ipv4_public_direct",
43 Self::Ipv4CgnatBlocked => "ipv4_cgnat_blocked",
44 Self::Ipv6DirectFirewallRequired => "ipv6_direct_firewall_required",
45 Self::Ipv6DirectPinholeAvailable => "ipv6_direct_pinhole_available",
46 Self::RelayRequired => "relay_required",
47 Self::TunnelRequired => "tunnel_required",
48 Self::DualStackAvailable => "dual_stack_available",
49 }
50 }
51}
52
53impl std::fmt::Display for ExposureMode {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(self.as_str())
56 }
57}
58
59#[derive(Debug, Clone)]
62pub struct Ipv4Qualification {
63 pub public_ip: Option<String>,
64 pub cgnat: bool,
65 pub upnp_mapped: bool,
66}
67
68#[derive(Debug, Clone)]
69pub struct Ipv6Qualification {
70 pub routable: bool,
71 pub pinhole_ok: bool,
72 pub address: Option<String>,
73}
74
75#[derive(Debug, Clone)]
76pub struct ExposureQualification {
77 pub public_endpoint: Option<String>,
78 pub transport_endpoint: Option<String>,
79}
80
81#[derive(Debug, Clone)]
83pub struct ServiceQualification {
84 pub exposure_mode: ExposureMode,
85 pub ipv4: Ipv4Qualification,
86 pub ipv6: Ipv6Qualification,
87 pub exposure: ExposureQualification,
88 pub recommendation: String,
89}
90
91pub fn qualify_service(profile: &NatProfile) -> ServiceQualification {
98 let ipv4 = build_ipv4(profile);
99 let ipv6 = build_ipv6(profile);
100 let exposure_mode = derive_exposure_mode(profile, &ipv4, &ipv6);
101 let recommendation = build_recommendation(&exposure_mode, profile);
102
103 ServiceQualification {
104 exposure: ExposureQualification {
105 public_endpoint: profile.public_endpoint.clone(),
106 transport_endpoint: profile.transport_endpoint.clone(),
107 },
108 exposure_mode,
109 ipv4,
110 ipv6,
111 recommendation,
112 }
113}
114
115pub async fn qualify_service_async(
117 opts: crate::nat_detection::DetectNatOptions,
118) -> ServiceQualification {
119 let profile = crate::nat_detection::detect_nat(opts).await;
120 qualify_service(&profile)
121}
122
123fn extract_ip(endpoint: &Option<String>) -> Option<String> {
126 let ep = endpoint.as_deref()?;
127 let re = regex::Regex::new(r"https?://([^:/]+)").ok()?;
128 re.captures(ep)?.get(1).map(|m| m.as_str().to_string())
129}
130
131fn build_ipv4(profile: &NatProfile) -> Ipv4Qualification {
132 let cgnat = profile.detection_log.iter().any(|l| {
133 let lo = l.to_lowercase();
134 lo.contains("cgnat") || lo.contains("ds-lite") || lo.contains("carrier-grade")
135 });
136 Ipv4Qualification {
137 public_ip: extract_ip(&profile.public_endpoint),
138 cgnat,
139 upnp_mapped: profile.transport_method == TransportMethod::UpnpMapped,
140 }
141}
142
143fn build_ipv6(profile: &NatProfile) -> Ipv6Qualification {
144 match &profile.ipv6 {
145 None => Ipv6Qualification {
146 routable: false,
147 pinhole_ok: false,
148 address: None,
149 },
150 Some(v6) => Ipv6Qualification {
151 routable: !v6.addresses.is_empty(),
152 pinhole_ok: v6.pinhole_active,
156 address: v6.addresses.first().cloned(),
157 },
158 }
159}
160
161fn derive_exposure_mode(
162 profile: &NatProfile,
163 ipv4: &Ipv4Qualification,
164 ipv6: &Ipv6Qualification,
165) -> ExposureMode {
166 if profile.tier == 3 {
167 return ExposureMode::RelayRequired;
168 }
169 if profile.tier == 2 || profile.transport_method == TransportMethod::ExternalTunnel {
170 return ExposureMode::TunnelRequired;
171 }
172 if profile.tier == 4 || profile.public_endpoint.is_none() {
173 return if ipv4.cgnat {
174 ExposureMode::Ipv4CgnatBlocked
175 } else {
176 ExposureMode::OutboundOnly
177 };
178 }
179 let ipv4_ok = profile
181 .public_endpoint
182 .as_deref()
183 .is_some_and(|ep| !ep.contains('['));
184 if ipv4_ok && ipv6.routable && ipv6.pinhole_ok {
185 return ExposureMode::DualStackAvailable;
186 }
187 if !ipv4_ok && ipv6.routable {
188 return if ipv6.pinhole_ok {
189 ExposureMode::Ipv6DirectPinholeAvailable
190 } else {
191 ExposureMode::Ipv6DirectFirewallRequired
192 };
193 }
194 ExposureMode::Ipv4PublicDirect
195}
196
197fn build_recommendation(mode: &ExposureMode, profile: &NatProfile) -> String {
198 let base = match mode {
199 ExposureMode::Ipv4PublicDirect => "Direct IPv4 connection available. No additional setup needed.",
200 ExposureMode::DualStackAvailable => "Dual-stack (IPv4 + IPv6) available. Consumers can reach you on either path.",
201 ExposureMode::Ipv6DirectPinholeAvailable => "IPv6 direct connection available with firewall pinhole. IPv4 unreachable.",
202 ExposureMode::Ipv6DirectFirewallRequired => "IPv6 address routable but firewall is blocking. Open the relevant port.",
203 ExposureMode::RelayRequired => "Behind CGNAT or strict firewall — use relay mode (iicp-node --relay-worker-endpoint).",
204 ExposureMode::TunnelRequired => "External tunnel detected (ngrok/Tailscale). Advertise the tunnel URL as public endpoint.",
205 ExposureMode::Ipv4CgnatBlocked => "Carrier-grade NAT detected. Relay mode is the recommended path.",
206 ExposureMode::OutboundOnly => "No inbound connectivity detected. Set --public-endpoint manually or use relay mode.",
207 };
208 match &profile.operator_guidance {
209 Some(g) => format!("{base} {g}"),
210 None => base.to_string(),
211 }
212}