Skip to main content

multiprobe/
classifier.rs

1//! Path classification based on multi-protocol probe results
2//!
3//! Implements Protocol Differential Analysis for network path fingerprinting.
4
5use crate::types::{PathClassification, ProbeResult, Protocol};
6
7/// Protocol Differential Score - quantifies behavioral differences across protocols
8#[derive(Debug, Clone, Default)]
9pub struct ProtocolDifferentialScore {
10    /// ICMP vs TCP differential (0.0 = identical, 1.0 = completely different)
11    pub icmp_tcp_diff: f64,
12    /// ICMP vs UDP differential
13    pub icmp_udp_diff: f64,
14    /// TCP vs UDP differential
15    pub tcp_udp_diff: f64,
16    /// Overall path consistency score (0.0 = inconsistent, 1.0 = consistent)
17    pub consistency: f64,
18    /// Latency variance across protocols
19    pub latency_variance_ms: f64,
20}
21
22impl ProtocolDifferentialScore {
23    /// Calculate the Protocol Differential Score from probe results
24    pub fn calculate(results: &[ProbeResult]) -> Self {
25        let icmp_success = results.iter()
26            .filter(|r| matches!(r.protocol, Protocol::Icmp))
27            .map(|r| if r.success { 1.0 } else { 0.0 })
28            .next();
29
30        let tcp_success_rate = Self::success_rate(results, |p| matches!(p, Protocol::Tcp(_)));
31        let udp_success_rate = Self::success_rate(results, |p| matches!(p, Protocol::Udp(_)));
32
33        let icmp_tcp_diff = match icmp_success {
34            Some(icmp) if tcp_success_rate.is_some() => {
35                (icmp - tcp_success_rate.unwrap()).abs()
36            }
37            _ => 0.0,
38        };
39
40        let icmp_udp_diff = match icmp_success {
41            Some(icmp) if udp_success_rate.is_some() => {
42                (icmp - udp_success_rate.unwrap()).abs()
43            }
44            _ => 0.0,
45        };
46
47        let tcp_udp_diff = match (tcp_success_rate, udp_success_rate) {
48            (Some(tcp), Some(udp)) => (tcp - udp).abs(),
49            _ => 0.0,
50        };
51
52        // Calculate consistency: 1.0 if all protocols behave the same
53        let total_diff = icmp_tcp_diff + icmp_udp_diff + tcp_udp_diff;
54        let consistency = 1.0 - (total_diff / 3.0).min(1.0);
55
56        // Calculate latency variance
57        let latencies: Vec<f64> = results.iter()
58            .filter(|r| r.success)
59            .map(|r| r.timing.total_ms())
60            .collect();
61
62        let latency_variance_ms = if latencies.len() >= 2 {
63            let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
64            let variance = latencies.iter()
65                .map(|l| (l - mean).powi(2))
66                .sum::<f64>() / latencies.len() as f64;
67            variance.sqrt()
68        } else {
69            0.0
70        };
71
72        Self {
73            icmp_tcp_diff,
74            icmp_udp_diff,
75            tcp_udp_diff,
76            consistency,
77            latency_variance_ms,
78        }
79    }
80
81    fn success_rate<F>(results: &[ProbeResult], filter: F) -> Option<f64>
82    where
83        F: Fn(&Protocol) -> bool,
84    {
85        let matching: Vec<_> = results.iter()
86            .filter(|r| filter(&r.protocol))
87            .collect();
88
89        if matching.is_empty() {
90            None
91        } else {
92            let success_count = matching.iter().filter(|r| r.success).count();
93            Some(success_count as f64 / matching.len() as f64)
94        }
95    }
96
97    /// Interpret the score as a network behavior type
98    pub fn interpret(&self) -> NetworkBehavior {
99        if self.consistency > 0.9
100            && self.icmp_tcp_diff < 0.1 && self.tcp_udp_diff < 0.1 {
101                return NetworkBehavior::Direct;
102            }
103
104        if self.icmp_tcp_diff > 0.5 && self.icmp_udp_diff > 0.5 {
105            return NetworkBehavior::IcmpFiltering;
106        }
107
108        if self.tcp_udp_diff > 0.5 {
109            return NetworkBehavior::ProtocolSpecificFiltering;
110        }
111
112        if self.latency_variance_ms > 50.0 {
113            return NetworkBehavior::AsymmetricRouting;
114        }
115
116        NetworkBehavior::Unknown
117    }
118}
119
120/// Detected network behavior based on protocol analysis
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum NetworkBehavior {
123    /// Direct path - all protocols behave consistently
124    Direct,
125    /// ICMP is filtered while TCP/UDP pass
126    IcmpFiltering,
127    /// Protocol-specific filtering (TCP vs UDP treated differently)
128    ProtocolSpecificFiltering,
129    /// Asymmetric routing detected (high latency variance)
130    AsymmetricRouting,
131    /// Unable to determine
132    Unknown,
133}
134
135impl std::fmt::Display for NetworkBehavior {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            NetworkBehavior::Direct => write!(f, "Direct path"),
139            NetworkBehavior::IcmpFiltering => write!(f, "ICMP filtering"),
140            NetworkBehavior::ProtocolSpecificFiltering => write!(f, "Protocol-specific filtering"),
141            NetworkBehavior::AsymmetricRouting => write!(f, "Asymmetric routing"),
142            NetworkBehavior::Unknown => write!(f, "Unknown"),
143        }
144    }
145}
146
147/// Classifier for network path behavior based on probe results
148pub struct Classifier;
149
150impl Classifier {
151    /// Classify a path based on multi-protocol probe results
152    pub fn classify(results: &[ProbeResult]) -> PathClassification {
153        if results.is_empty() {
154            return PathClassification::Unknown;
155        }
156
157        let icmp_results: Vec<_> = results.iter()
158            .filter(|r| matches!(r.protocol, Protocol::Icmp))
159            .collect();
160        let icmp_success = icmp_results.iter().any(|r| r.success);
161        let has_icmp = !icmp_results.is_empty();
162
163        let tcp_results: Vec<_> = results.iter()
164            .filter(|r| matches!(r.protocol, Protocol::Tcp(_)))
165            .collect();
166
167        let udp_results: Vec<_> = results.iter()
168            .filter(|r| matches!(r.protocol, Protocol::Udp(_)))
169            .collect();
170
171        let tcp_success_count = tcp_results.iter().filter(|r| r.success).count();
172        let udp_success_count = udp_results.iter().filter(|r| r.success).count();
173
174        let any_tcp = !tcp_results.is_empty();
175        let any_udp = !udp_results.is_empty();
176        let any_tcp_success = tcp_success_count > 0;
177        let any_udp_success = udp_success_count > 0;
178
179        // All protocols succeed
180        if icmp_success && (!any_tcp || any_tcp_success) && (!any_udp || any_udp_success) {
181            let all_tcp_success = tcp_results.iter().all(|r| r.success);
182            let all_udp_success = udp_results.iter().all(|r| r.success);
183
184            if all_tcp_success && all_udp_success {
185                return PathClassification::Open;
186            }
187
188            let open_ports: Vec<u16> = results.iter()
189                .filter(|r| r.success)
190                .filter_map(|r| r.protocol.port())
191                .collect();
192
193            let closed_ports: Vec<u16> = results.iter()
194                .filter(|r| !r.success)
195                .filter_map(|r| r.protocol.port())
196                .collect();
197
198            if !open_ports.is_empty() && !closed_ports.is_empty() {
199                return PathClassification::SelectiveFirewall { open_ports, closed_ports };
200            }
201        }
202
203        // No ICMP probed, but TCP/UDP work - classify based on available data
204        if !has_icmp && (any_tcp_success || any_udp_success) {
205            let all_tcp_success = tcp_results.is_empty() || tcp_results.iter().all(|r| r.success);
206            let all_udp_success = udp_results.is_empty() || udp_results.iter().all(|r| r.success);
207
208            if all_tcp_success && all_udp_success {
209                // Can't determine ICMP status, but TCP/UDP are open
210                return PathClassification::IcmpFiltered; // Assume ICMP filtered if not tested
211            }
212        }
213
214        // ICMP blocked but TCP/UDP open
215        if has_icmp && !icmp_success && (any_tcp_success || any_udp_success) {
216            return PathClassification::IcmpFiltered;
217        }
218
219        // ICMP succeeds but TCP fails
220        if icmp_success && any_tcp && !any_tcp_success {
221            return PathClassification::TcpFiltered;
222        }
223
224        // All fail
225        let all_fail = (!has_icmp || !icmp_success) && !any_tcp_success && !any_udp_success;
226        if all_fail && (has_icmp || any_tcp || any_udp) {
227            return PathClassification::Blocked;
228        }
229
230        // Check for NAT indicators
231        let ttls: Vec<u8> = results.iter()
232            .filter_map(|r| r.ttl)
233            .collect();
234
235        if ttls.len() >= 2 {
236            let min_ttl = *ttls.iter().min().unwrap();
237            let max_ttl = *ttls.iter().max().unwrap();
238
239            if max_ttl - min_ttl > 5 {
240                return PathClassification::NatDetected;
241            }
242        }
243
244        PathClassification::Unknown
245    }
246
247    /// Calculate Protocol Differential Score
248    pub fn differential_score(results: &[ProbeResult]) -> ProtocolDifferentialScore {
249        ProtocolDifferentialScore::calculate(results)
250    }
251
252    /// Generate a human-readable fingerprint string
253    pub fn fingerprint(results: &[ProbeResult]) -> String {
254        let mut parts = Vec::new();
255
256        // ICMP status
257        let icmp = results.iter()
258            .find(|r| matches!(r.protocol, Protocol::Icmp));
259        if let Some(r) = icmp {
260            parts.push(format!("ICMP:{}", if r.success { "open" } else { "filtered" }));
261        }
262
263        // TCP ports
264        let tcp_open: Vec<_> = results.iter()
265            .filter(|r| matches!(r.protocol, Protocol::Tcp(_)) && r.success)
266            .filter_map(|r| r.protocol.port())
267            .collect();
268        let tcp_closed: Vec<_> = results.iter()
269            .filter(|r| matches!(r.protocol, Protocol::Tcp(_)) && !r.success)
270            .filter_map(|r| r.protocol.port())
271            .collect();
272
273        if !tcp_open.is_empty() {
274            parts.push(format!("TCP-open:{tcp_open:?}"));
275        }
276        if !tcp_closed.is_empty() {
277            parts.push(format!("TCP-closed:{tcp_closed:?}"));
278        }
279
280        // UDP ports
281        let udp_open: Vec<_> = results.iter()
282            .filter(|r| matches!(r.protocol, Protocol::Udp(_)) && r.success)
283            .filter_map(|r| r.protocol.port())
284            .collect();
285        let udp_closed: Vec<_> = results.iter()
286            .filter(|r| matches!(r.protocol, Protocol::Udp(_)) && !r.success)
287            .filter_map(|r| r.protocol.port())
288            .collect();
289
290        if !udp_open.is_empty() {
291            parts.push(format!("UDP-open:{udp_open:?}"));
292        }
293        if !udp_closed.is_empty() {
294            parts.push(format!("UDP-closed:{udp_closed:?}"));
295        }
296
297        if parts.is_empty() {
298            "Unknown".to_string()
299        } else {
300            parts.join(" | ")
301        }
302    }
303
304    /// Generate a compact fingerprint hash for comparison
305    pub fn fingerprint_hash(results: &[ProbeResult]) -> u64 {
306        use std::collections::hash_map::DefaultHasher;
307        use std::hash::{Hash, Hasher};
308
309        let mut hasher = DefaultHasher::new();
310
311        // Sort results by protocol for consistent hashing
312        let mut sorted: Vec<_> = results.iter().collect();
313        sorted.sort_by_key(|r| format!("{}", r.protocol));
314
315        for result in sorted {
316            result.protocol.to_string().hash(&mut hasher);
317            result.success.hash(&mut hasher);
318        }
319
320        hasher.finish()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::types::TimingBreakdown;
328    use std::net::IpAddr;
329    use std::time::Duration;
330
331    fn make_result(protocol: Protocol, success: bool) -> ProbeResult {
332        let ip: IpAddr = "1.2.3.4".parse().unwrap();
333        let timing = TimingBreakdown::new(None, Duration::from_millis(10), Duration::from_millis(20));
334
335        if success {
336            ProbeResult::success("test".to_string(), ip, protocol, timing)
337        } else {
338            ProbeResult::failure("test".to_string(), ip, protocol, "failed".to_string(), timing)
339        }
340    }
341
342    #[test]
343    fn test_classify_open() {
344        let results = vec![
345            make_result(Protocol::Icmp, true),
346            make_result(Protocol::Tcp(80), true),
347            make_result(Protocol::Tcp(443), true),
348        ];
349
350        assert_eq!(Classifier::classify(&results), PathClassification::Open);
351    }
352
353    #[test]
354    fn test_classify_icmp_filtered() {
355        let results = vec![
356            make_result(Protocol::Icmp, false),
357            make_result(Protocol::Tcp(443), true),
358        ];
359
360        assert_eq!(Classifier::classify(&results), PathClassification::IcmpFiltered);
361    }
362
363    #[test]
364    fn test_classify_blocked() {
365        let results = vec![
366            make_result(Protocol::Icmp, false),
367            make_result(Protocol::Tcp(80), false),
368            make_result(Protocol::Tcp(443), false),
369        ];
370
371        assert_eq!(Classifier::classify(&results), PathClassification::Blocked);
372    }
373
374    #[test]
375    fn test_fingerprint() {
376        let results = vec![
377            make_result(Protocol::Icmp, false),
378            make_result(Protocol::Tcp(80), false),
379            make_result(Protocol::Tcp(443), true),
380        ];
381
382        let fp = Classifier::fingerprint(&results);
383        assert!(fp.contains("ICMP:filtered"));
384        assert!(fp.contains("TCP-open:[443]"));
385        assert!(fp.contains("TCP-closed:[80]"));
386    }
387
388    #[test]
389    fn test_differential_score_consistent() {
390        let results = vec![
391            make_result(Protocol::Icmp, true),
392            make_result(Protocol::Tcp(80), true),
393            make_result(Protocol::Tcp(443), true),
394            make_result(Protocol::Udp(53), true),
395        ];
396
397        let score = Classifier::differential_score(&results);
398        assert!(score.consistency > 0.9);
399        assert_eq!(score.interpret(), NetworkBehavior::Direct);
400    }
401
402    #[test]
403    fn test_differential_score_icmp_filtered() {
404        let results = vec![
405            make_result(Protocol::Icmp, false),
406            make_result(Protocol::Tcp(80), true),
407            make_result(Protocol::Tcp(443), true),
408            make_result(Protocol::Udp(53), true),
409        ];
410
411        let score = Classifier::differential_score(&results);
412        assert!(score.icmp_tcp_diff > 0.5);
413        assert_eq!(score.interpret(), NetworkBehavior::IcmpFiltering);
414    }
415
416    #[test]
417    fn test_fingerprint_hash_consistency() {
418        let results1 = vec![
419            make_result(Protocol::Tcp(80), true),
420            make_result(Protocol::Tcp(443), true),
421        ];
422
423        let results2 = vec![
424            make_result(Protocol::Tcp(443), true),
425            make_result(Protocol::Tcp(80), true),
426        ];
427
428        // Same results in different order should produce same hash
429        assert_eq!(
430            Classifier::fingerprint_hash(&results1),
431            Classifier::fingerprint_hash(&results2)
432        );
433    }
434}