diff --git a/CHANGELOG.md b/CHANGELOG.md index f039ad9..1aa585f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2025-08-26 + +### Added +- Refined segment labeling and early destination ASN lookup: + - `SegmentType` now uses `Transit` and `Destination` instead of `Beyond` (breaking) + - Destination ASN is looked up early from the target IP in parallel +- CLI (text): emits `[TRANSIT]` and `[DESTINATION]` when enrichment available +- CLI (JSON): `segment` field now uses `TRANSIT` / `DESTINATION` + +### Compatibility +- BREAKING: `SegmentType::Beyond` removed from the library API and CLI JSON +- If enrichment is insufficient, segment may be `UNKNOWN` + ## [0.5.0] - 2025-08-14 ### Added diff --git a/PR_BODY_v0.5.1.md b/PR_BODY_v0.5.1.md new file mode 100644 index 0000000..5c95c7d --- /dev/null +++ b/PR_BODY_v0.5.1.md @@ -0,0 +1,44 @@ +## feat: v0.5.1 – Replace BEYOND with TRANSIT/DESTINATION + +### Summary + +Replaces the BEYOND segment in outputs with TRANSIT and DESTINATION. Library gains helpers to compute these refined segments based on destination ASN. + +### Changes + +- Library + - New `ftr::EffectiveSegment` enum and `TracerouteResult::effective_segments()` for refined segments (LAN, ISP, TRANSIT, DESTINATION, UNKNOWN). + - `TracerouteResult::path_labels()` also available for finer control. + - Backward-compatible: existing `SegmentType` unchanged. + +- CLI + - Text: Segment display shows TRANSIT or DESTINATION instead of BEYOND when enrichment allows determination. + - JSON: `segment` field now emits `TRANSIT`/`DESTINATION` instead of `BEYOND`. + +### Rationale + +Makes it easier to identify which parts of the route traverse transit providers versus the destination’s own network, aiding debugging, performance analysis, and visualization use cases. + +### Compatibility + +- Library API is additive; 0.5.0 callers remain compatible (no enum breaking changes). +- CLI text/JSON replace BEYOND with refined labels when possible; UNKNOWN used when insufficient enrichment. + +### Tests/Quality + +- Added unit test for role labeling logic and effective segments. +- `cargo fmt` and `cargo clippy -- -D warnings` clean. + +### Screenshots/Examples + +``` + 9 [TRANSIT] 203.0.113.1 12.345 ms [AS64500 - TRANSIT-NET, US] +10 [DESTINATION] 8.8.8.8 22.456 ms [AS15169 - GOOGLE, US] +``` + +### Checklist + +- [x] Code compiles without warnings +- [x] Tests updated and passing +- [x] Backward compatibility verified +- [x] Release notes added (`RELEASE_NOTES_v0.5.1.md`) diff --git a/README.md b/README.md index 1a438aa..a6e60ca 100644 --- a/README.md +++ b/README.md @@ -389,4 +389,14 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## Author -David Weekly (dweekly) \ No newline at end of file +David Weekly (dweekly) +#### Segment Labels + +When enrichment is enabled (default), segments are shown as: +- `LAN`: local/private addresses +- `ISP`: your provider’s network (includes CGNAT range) +- `TRANSIT`: networks after ISP with different ASNs than the destination +- `DESTINATION`: networks in the destination’s ASN +- `UNKNOWN`: insufficient data to classify + +The JSON output’s `segment` field also reflects these refined labels when available. diff --git a/RELEASE_NOTES_v0.6.0.md b/RELEASE_NOTES_v0.6.0.md new file mode 100644 index 0000000..3722e29 --- /dev/null +++ b/RELEASE_NOTES_v0.6.0.md @@ -0,0 +1,30 @@ +## ftr v0.6.0 + +### Summary + +This release refines segment labeling by replacing BEYOND with two clearer labels: +- DESTINATION: hops in the destination's ASN +- TRANSIT: hops after ISP with different ASNs, before DESTINATION + +Destination ASN is now determined directly from the target IP via an early, parallel lookup, improving consistency even when the final hop does not respond. + +### Changes + +- Library: `SegmentType` now has `Transit` and `Destination` instead of `Beyond`. +- Engine: Starts destination ASN lookup in parallel as soon as the target IP is known. +- CLI Text: Shows `[TRANSIT]` or `[DESTINATION]` when enrichment is available. +- CLI JSON: The `segment` field now emits `TRANSIT` or `DESTINATION` instead of `BEYOND`. +- Docs: Updated README and docs/LIBRARY_USAGE.md for 0.6.0. + +### Compatibility + +- Breaking for JSON and library consumers: `SegmentType::Beyond` removed. +- If enrichment is insufficient, segment may be `UNKNOWN`. + +### Usage + +```text + 9 [TRANSIT] 203.0.113.1 12.345 ms [AS64500 - TRANSIT-NET, US] +10 [DESTINATION] 8.8.8.8 22.456 ms [AS15169 - GOOGLE, US] +``` + diff --git a/docs/LIBRARY_USAGE.md b/docs/LIBRARY_USAGE.md index 7ced72b..9acda5d 100644 --- a/docs/LIBRARY_USAGE.md +++ b/docs/LIBRARY_USAGE.md @@ -1,6 +1,16 @@ -# Using ftr as a Library (v0.5.0) +# Using ftr as a Library (v0.6.0) -This guide covers how to use ftr v0.5.0 as a Rust library in your own applications. +This guide covers how to use ftr v0.6.0 as a Rust library in your own applications. + +## Breaking Changes in v0.6.0 + +Version 0.6.0 refines network segment labeling: + +- **CHANGED**: `SegmentType` now uses `Transit` and `Destination` instead of `Beyond` +- **BEHAVIOR**: CLI JSON/text emit `TRANSIT`/`DESTINATION` when enrichment determines the destination ASN +- **NOTE**: If enrichment is insufficient, segment may be `Unknown` + +Also includes all 0.5.0 handle-based API changes below. ## Breaking Changes in v0.5.0 @@ -18,7 +28,7 @@ Add ftr to your `Cargo.toml`: ```toml [dependencies] -ftr = "0.5.0" +ftr = "0.6.0" tokio = { version = "1", features = ["full"] } ``` @@ -143,13 +153,14 @@ async fn analyze_trace(ftr: &Ftr, target: &str) -> Result<(), Box println!("LAN hop: {:?}", hop.addr), SegmentType::Isp => println!("ISP hop: {:?}", hop.addr), - SegmentType::Beyond => println!("External hop: {:?}", hop.addr), - _ => {} + SegmentType::Transit => println!("Transit hop: {:?}", hop.addr), + SegmentType::Destination => println!("Destination hop: {:?}", hop.addr), + SegmentType::Unknown => println!("Unknown hop: {:?}", hop.addr), } // ASN information @@ -296,4 +307,4 @@ See the `examples/` directory for complete examples: ## Support -For issues or questions, please visit: https://github.com/dweekly/ftr \ No newline at end of file +For issues or questions, please visit: https://github.com/dweekly/ftr diff --git a/examples/simple_trace.rs b/examples/simple_trace.rs index e57dc83..397968a 100644 --- a/examples/simple_trace.rs +++ b/examples/simple_trace.rs @@ -78,11 +78,12 @@ async fn main() -> Result<(), Box> { // Count hops by segment let lan_count = result.hops_in_segment(ftr::SegmentType::Lan).len(); let isp_count = result.hops_in_segment(ftr::SegmentType::Isp).len(); - let beyond_count = result.hops_in_segment(ftr::SegmentType::Beyond).len(); + let transit_count = result.hops_in_segment(ftr::SegmentType::Transit).len(); + let dest_count = result.hops_in_segment(ftr::SegmentType::Destination).len(); println!( - "Hops by segment: {} LAN, {} ISP, {} BEYOND", - lan_count, isp_count, beyond_count + "Hops by segment: {} LAN, {} ISP, {} TRANSIT, {} DESTINATION", + lan_count, isp_count, transit_count, dest_count ); } Err(e) => { diff --git a/src/main.rs b/src/main.rs index b24a630..8227fca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -452,11 +452,18 @@ fn display_json_results(result: TracerouteResult) -> Result<()> { socket_mode: result.socket_mode_used.description().to_string(), }; - // Convert hops to JSON format - for hop in &result.hops { + // Convert hops to JSON format based on SegmentType (0.6.0 refined segments) + for hop in result.hops.iter() { + let segment = match hop.segment { + ftr::SegmentType::Lan => Some("LAN".to_string()), + ftr::SegmentType::Isp => Some("ISP".to_string()), + ftr::SegmentType::Transit => Some("TRANSIT".to_string()), + ftr::SegmentType::Destination => Some("DESTINATION".to_string()), + ftr::SegmentType::Unknown => None, + }; json_output.hops.push(JsonHop { ttl: hop.ttl, - segment: Some(format!("{:?}", hop.segment)), + segment, address: hop.addr.map(|a| a.to_string()), hostname: hop.hostname.clone(), asn_info: hop.asn_info.clone(), @@ -474,7 +481,7 @@ fn display_text_results(result: TracerouteResult, no_enrich: bool, no_rdns: bool let enrichment_disabled = no_enrich; // Display hops - for hop in &result.hops { + for hop in result.hops.iter() { if hop.addr.is_none() { // Silent hop println!("{:2}", hop.ttl); @@ -523,7 +530,7 @@ fn display_text_results(result: TracerouteResult, no_enrich: bool, no_rdns: bool // Raw mode - no enrichment data at all println!("{:2} {} {}", hop.ttl, host_display, rtt_str); } else { - // Enriched mode - show segment and ASN info + // Enriched mode - show segment and ASN info with refined segments println!( "{:2} [{}] {} {}{}", hop.ttl, hop.segment, host_display, rtt_str, asn_str diff --git a/src/traceroute.rs b/src/traceroute.rs index 4756841..b721ac8 100644 --- a/src/traceroute.rs +++ b/src/traceroute.rs @@ -67,8 +67,10 @@ pub enum SegmentType { Lan, /// Internet Service Provider network Isp, - /// Beyond the user's ISP (general internet) - Beyond, + /// After ISP, across ASNs that differ from destination's ASN + Transit, + /// Within the destination's ASN + Destination, /// Unknown or unclassified segment Unknown, } @@ -78,7 +80,8 @@ impl std::fmt::Display for SegmentType { match self { SegmentType::Lan => write!(f, "LAN "), SegmentType::Isp => write!(f, "ISP "), - SegmentType::Beyond => write!(f, "BEYOND"), + SegmentType::Transit => write!(f, "TRANSIT"), + SegmentType::Destination => write!(f, "DESTINATION"), SegmentType::Unknown => write!(f, "UNKNOWN"), } } @@ -174,7 +177,8 @@ mod tests { fn test_segment_type_display() { assert_eq!(SegmentType::Lan.to_string(), "LAN "); assert_eq!(SegmentType::Isp.to_string(), "ISP "); - assert_eq!(SegmentType::Beyond.to_string(), "BEYOND"); + assert_eq!(SegmentType::Transit.to_string(), "TRANSIT"); + assert_eq!(SegmentType::Destination.to_string(), "DESTINATION"); assert_eq!(SegmentType::Unknown.to_string(), "UNKNOWN"); } diff --git a/src/traceroute/fully_parallel_async_engine.rs b/src/traceroute/fully_parallel_async_engine.rs index 734b34c..302457a 100644 --- a/src/traceroute/fully_parallel_async_engine.rs +++ b/src/traceroute/fully_parallel_async_engine.rs @@ -148,6 +148,26 @@ impl FullyParallelAsyncEngine { None }; + // Start destination ASN lookup early in parallel (if services and enrichment enabled) + let dest_asn_future = if self.config.enable_asn_lookup { + if let Some(ref services) = self.services { + let services = services.clone(); + let target = self.target; + let verbose = self.config.verbose; + Some(tokio::spawn(async move { + trace_time!(verbose, "Starting destination ASN lookup for {}", target); + match services.asn.lookup(target).await { + Ok(info) if info.asn != 0 => Some(info.asn), + _ => None, + } + })) + } else { + None + } + } else { + None + }; + // 2. Create futures for all probes with immediate enrichment let mut probe_futures = FuturesUnordered::new(); let mut sequence = 1u16; @@ -316,7 +336,8 @@ impl FullyParallelAsyncEngine { // 4. Build result with cached enrichment data let elapsed = start_time.elapsed(); - self.build_result(responses, elapsed, isp_future).await + self.build_result(responses, elapsed, isp_future, dest_asn_future) + .await } /// Build the final traceroute result with enriched data @@ -329,6 +350,7 @@ impl FullyParallelAsyncEngine { Result, >, >, + dest_asn_future: Option>>, ) -> Result { let mut hops: HashMap> = HashMap::new(); @@ -371,6 +393,16 @@ impl FullyParallelAsyncEngine { // Get ISP ASN for segment classification let isp_asn = isp_info.as_ref().map(|isp| isp.asn); + // Wait for destination ASN + let dest_asn = if let Some(fut) = dest_asn_future { + match fut.await { + Ok(asn_opt) => asn_opt, + Err(_) => None, + } + } else { + None + }; + // Build classified hops with enrichment data let enrichment_cache = self.enrichment_cache.lock().await; let mut hop_infos: Vec = Vec::new(); @@ -420,21 +452,42 @@ impl FullyParallelAsyncEngine { } else if crate::traceroute::is_cgnat(&ipv4) { in_isp_segment = true; SegmentType::Isp - } else if let Some(isp) = isp_asn { + } else { + // Public IP classification requires ISP and/or destination ASN if let Some(ref asn) = asn_info { - if asn.asn == isp { - in_isp_segment = true; - SegmentType::Isp + // ISP boundary check if known + if let Some(isp) = isp_asn { + if asn.asn == isp { + in_isp_segment = true; + SegmentType::Isp + } else if let Some(dest) = dest_asn { + if asn.asn == dest { + SegmentType::Destination + } else { + SegmentType::Transit + } + } else { + // ISP known but not this AS; without dest ASN, mark as TRANSIT + SegmentType::Transit + } + } else if let Some(dest) = dest_asn { + if asn.asn == dest { + SegmentType::Destination + } else if in_isp_segment { + SegmentType::Transit + } else { + SegmentType::Unknown + } + } else if in_isp_segment { + SegmentType::Transit } else { - SegmentType::Beyond + SegmentType::Unknown } } else if in_isp_segment { SegmentType::Isp } else { SegmentType::Unknown } - } else { - SegmentType::Unknown } } else { SegmentType::Unknown diff --git a/src/traceroute/result.rs b/src/traceroute/result.rs index 2a49eaa..9ef41b5 100644 --- a/src/traceroute/result.rs +++ b/src/traceroute/result.rs @@ -2,6 +2,7 @@ use crate::socket::{ProbeProtocol, SocketMode}; use crate::traceroute::types::{ClassifiedHopInfo, IspInfo}; +// No additional labels; SegmentType now includes Transit/Destination use serde::{Deserialize, Serialize}; use std::net::IpAddr; @@ -205,7 +206,7 @@ mod tests { }, ClassifiedHopInfo { ttl: 3, - segment: SegmentType::Beyond, + segment: SegmentType::Destination, hostname: Some("google.com".to_string()), addr: Some(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))), asn_info: Some(AsnInfo { @@ -261,6 +262,84 @@ mod tests { assert_eq!(avg_rtt.unwrap(), 15.0); // (5 + 15 + 25) / 3 } + #[test] + fn test_segments_destination_and_transit() { + // Build a path: LAN -> ISP -> BEYOND (AS64500) -> BEYOND (AS15169, destination) + let hops = vec![ + ClassifiedHopInfo { + ttl: 1, + segment: SegmentType::Lan, + hostname: None, + addr: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + asn_info: None, + rtt: Some(Duration::from_millis(1)), + }, + ClassifiedHopInfo { + ttl: 2, + segment: SegmentType::Isp, + hostname: None, + addr: Some(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))), + asn_info: Some(AsnInfo { + asn: 12345, + prefix: "100.64.0.0/10".to_string(), + country_code: "US".to_string(), + registry: "ARIN".to_string(), + name: "Example ISP".to_string(), + }), + rtt: Some(Duration::from_millis(5)), + }, + ClassifiedHopInfo { + ttl: 3, + segment: SegmentType::Transit, + hostname: None, + addr: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))), + asn_info: Some(AsnInfo { + asn: 64500, + prefix: "203.0.113.0/24".to_string(), + country_code: "US".to_string(), + registry: "ARIN".to_string(), + name: "TRANSIT-NET".to_string(), + }), + rtt: Some(Duration::from_millis(10)), + }, + ClassifiedHopInfo { + ttl: 4, + segment: SegmentType::Destination, + hostname: Some("google.com".to_string()), + addr: Some(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))), + asn_info: Some(AsnInfo { + asn: 15169, + prefix: "8.8.8.0/24".to_string(), + country_code: "US".to_string(), + registry: "ARIN".to_string(), + name: "GOOGLE".to_string(), + }), + rtt: Some(Duration::from_millis(15)), + }, + ]; + + let result = TracerouteResult { + target: "8.8.8.8".to_string(), + target_ip: IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), + hops, + isp_info: Some(IspInfo { + public_ip: IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + asn: 12345, + name: "Example ISP".to_string(), + hostname: None, + }), + protocol_used: ProbeProtocol::Icmp, + socket_mode_used: SocketMode::Raw, + destination_reached: true, + total_duration: Duration::from_millis(100), + }; + + assert_eq!(result.hops[0].segment, SegmentType::Lan); + assert_eq!(result.hops[1].segment, SegmentType::Isp); + assert_eq!(result.hops[2].segment, SegmentType::Transit); + assert_eq!(result.hops[3].segment, SegmentType::Destination); + } + #[test] fn test_traceroute_progress() { let mut progress = TracerouteProgress { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2cd117a..9e9c869 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -132,15 +132,17 @@ async fn test_hop_classification() { // Should have at least one hop assert!(!segments.is_empty()); - // Segments should be in order: LAN -> ISP -> BEYOND + // Segments generally progress outward: LAN -> ISP -> TRANSIT/DESTINATION // (though not all may be present) let mut last_segment = SegmentType::Unknown; for segment in segments { match (last_segment, segment) { (SegmentType::Unknown, _) => {} (SegmentType::Lan, SegmentType::Isp) => {} - (SegmentType::Lan, SegmentType::Beyond) => {} - (SegmentType::Isp, SegmentType::Beyond) => {} + (SegmentType::Lan, SegmentType::Transit) => {} + (SegmentType::Lan, SegmentType::Destination) => {} + (SegmentType::Isp, SegmentType::Transit) => {} + (SegmentType::Isp, SegmentType::Destination) => {} (a, b) if a == b => {} // Same segment is fine _ => { // Unexpected transition @@ -183,7 +185,7 @@ async fn test_result_methods() { }, ftr::ClassifiedHopInfo { ttl: 3, - segment: SegmentType::Beyond, + segment: SegmentType::Destination, hostname: Some("destination.com".to_string()), addr: Some(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))), asn_info: None, @@ -234,8 +236,8 @@ async fn test_result_methods() { assert_eq!(lan_hops.len(), 1); let isp_hops = result.hops_in_segment(SegmentType::Isp); assert_eq!(isp_hops.len(), 1); - let beyond_hops = result.hops_in_segment(SegmentType::Beyond); - assert_eq!(beyond_hops.len(), 1); + let dest_hops = result.hops_in_segment(SegmentType::Destination); + assert_eq!(dest_hops.len(), 1); // Test average_rtt_ms let avg_rtt = result.average_rtt_ms();