Skip to main content

iicp_client/
qualify.rs

1//! ADR-043 §9/§11 — ServiceQualification: 8-category exposure enum + structured result.
2//!
3//! Maps a [`NatProfile`] to the canonical [`ExposureMode`] string and a
4//! [`ServiceQualification`] struct suitable for directory storage as
5//! `nodes.exposure_mode`.
6//!
7//! # Example
8//! ```no_run
9//! use iicp_client::qualify_service;
10//! use iicp_client::nat_detection::{detect_nat, DetectNatOptions};
11//!
12//! # #[tokio::main]
13//! # async fn main() {
14//! let profile = detect_nat(DetectNatOptions::default()).await;
15//! let sq = qualify_service(&profile);
16//! println!("{}", sq.exposure_mode);  // e.g. "ipv4_public_direct"
17//! # }
18//! ```
19
20use crate::nat_detection::{NatProfile, TransportMethod};
21
22// ── 8-category exposure enum ──────────────────────────────────────────────────
23
24/// ADR-043 §9 — canonical 8-category network exposure classification.
25#[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    /// Canonical string value stored in `nodes.exposure_mode`.
39    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// ── Result struct ─────────────────────────────────────────────────────────────
60
61#[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/// ADR-043 §11 — structured result of service qualification.
82#[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
91// ── Core mapping ──────────────────────────────────────────────────────────────
92
93/// Map a [`NatProfile`] to an ADR-043 [`ServiceQualification`].
94///
95/// Synchronous — call after awaiting [`detect_nat`].
96/// For a combined detect + qualify flow use [`qualify_service_async`].
97pub 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
115/// Run NAT detection and qualify the result in one async step.
116pub 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
123// ── Internal helpers ──────────────────────────────────────────────────────────
124
125fn 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_active = router accepted AddPinhole = port is open.
153            // pinhole_inbound_allowed is a global FRITZ!Box setting that returns
154            // false even when individual pinholes work; do not require it.
155            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    // tier 0 or 1 — IPv6 GUA endpoints contain '['; must not be mistaken for IPv4.
180    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}