From 5369aa16f719808371da9784000d07e48820a75f Mon Sep 17 00:00:00 2001 From: spiritlhl <103393591+spiritLHLS@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:30:42 +0800 Subject: [PATCH] feat: strengthen three-carrier return route detection --- bk/backtrace.go | 101 +------ bk/ipv4_asn.go | 36 ++- bk/ipv4_asn_test.go | 31 ++ bk/ipv6_asn.go | 38 ++- bk/prefix/as10099.txt | 10 + bk/prefix/as10099.txt.manifest.json | 7 + bk/prefix/as4134.txt.manifest.json | 2 +- bk/prefix/as4809.txt | 3 + bk/prefix/as4809.txt.manifest.json | 6 +- bk/prefix/as4837.txt.manifest.json | 2 +- bk/prefix/as9808.txt.manifest.json | 2 +- bk/prefix_registry.go | 4 +- bk/route_classification.go | 238 +++++++++++++++ bk/route_classification_test.go | 74 +++++ bk/route_render.go | 40 +++ bk/route_report.go | 441 ++++++++++++++++++++++++++++ bk/route_report_test.go | 132 +++++++++ bk/trace_common.go | 11 +- bk/utils.go | 57 ++-- cmd/main.go | 38 ++- cmd/main_test.go | 45 ++- cmd/update-prefixes/main.go | 2 +- model/model.go | 3 +- 23 files changed, 1187 insertions(+), 136 deletions(-) create mode 100644 bk/ipv4_asn_test.go create mode 100644 bk/prefix/as10099.txt create mode 100644 bk/prefix/as10099.txt.manifest.json create mode 100644 bk/route_classification.go create mode 100644 bk/route_classification_test.go create mode 100644 bk/route_render.go create mode 100644 bk/route_report.go create mode 100644 bk/route_report_test.go diff --git a/bk/backtrace.go b/bk/backtrace.go index d337030..3de04cf 100644 --- a/bk/backtrace.go +++ b/bk/backtrace.go @@ -1,105 +1,10 @@ package backtrace import ( - "strings" - "time" - - "github.com/oneclickvirt/backtrace/model" + "context" ) -func safeTraceCall(fn func()) { - defer func() { - if r := recover(); r != nil { - } - }() - fn() -} - func BackTrace(enableIpv6 bool) string { - StartASNPrefixRefresh() - if model.CachedIcmpData == "" || model.ParsedIcmpTargets == nil || time.Since(model.CachedIcmpDataFetchTime) > time.Hour { - model.CachedIcmpData = getData(model.IcmpTargets) - model.CachedIcmpDataFetchTime = time.Now() - if model.CachedIcmpData != "" { - model.ParsedIcmpTargets = parseIcmpTargets(model.CachedIcmpData) - } - } - var builder strings.Builder - if enableIpv6 { - ipv4Count := len(model.Ipv4s) - ipv6Count := len(model.Ipv6s) - totalCount := ipv4Count + ipv6Count - var ( - s = make([]string, totalCount) - c = make(chan Result) - t = time.After(time.Second * 10) - ) - for i := range model.Ipv4s { - idx := i - go safeTraceCall(func() { - trace(c, idx) - }) - } - for i := range model.Ipv6s { - idx := i - go safeTraceCall(func() { - traceIPv6(c, idx, ipv4Count) - }) - } - loopIPv4v6: - for range s { - select { - case o := <-c: - s[o.i] = o.s - case <-t: - break loopIPv4v6 - } - } - // 收集 IPv4 结果 - for i := 0; i < ipv4Count; i++ { - if s[i] != "" { - builder.WriteString(s[i]) - builder.WriteString("\n") - } - } - // 收集 IPv6 结果 - for i := ipv4Count; i < totalCount; i++ { - if s[i] != "" { - builder.WriteString(s[i]) - builder.WriteString("\n") - } - } - } else { - ipCount := len(model.Ipv4s) - var ( - s = make([]string, ipCount) - c = make(chan Result) - t = time.After(time.Second * 10) - ) - for i := range model.Ipv4s { - idx := i - go safeTraceCall(func() { - trace(c, idx) - }) - } - loopIPv4: - for range s { - select { - case o := <-c: - s[o.i] = o.s - case <-t: - break loopIPv4 - } - } - // 收集结果 - for _, r := range s { - if r != "" { - builder.WriteString(r) - builder.WriteString("\n") - } - } - } - // 返回完整结果,去掉末尾的换行符 - result := builder.String() - return strings.TrimSuffix(result, "\n") + report := RunRouteReport(context.Background(), RouteReportConfig{EnableIPv6: enableIpv6}) + return RenderRouteReport(report) } diff --git a/bk/ipv4_asn.go b/bk/ipv4_asn.go index 8b35710..127255b 100644 --- a/bk/ipv4_asn.go +++ b/bk/ipv4_asn.go @@ -1,6 +1,7 @@ package backtrace import ( + "net/netip" "strings" ) @@ -8,6 +9,11 @@ func ipv4Asn(ip string) string { if strings.Contains(ip, ":") { return ipv6Asn(ip) } + address, err := netip.ParseAddr(strings.TrimSpace(ip)) + if err != nil || !address.Is4() { + return "" + } + octets := address.As4() switch { case strings.HasPrefix(ip, "59.43"): return "AS4809" @@ -15,17 +21,41 @@ func ipv4Asn(ip string) string { return "AS4134" case strings.HasPrefix(ip, "218.105") || strings.HasPrefix(ip, "210.51"): return "AS9929" + case strings.HasPrefix(ip, "202.77") || strings.HasPrefix(ip, "43.252") || strings.HasPrefix(ip, "61.14"): + return "AS10099" case strings.HasPrefix(ip, "219.158"): return "AS4837" - case strings.HasPrefix(ip, "223.120.19") || strings.HasPrefix(ip, "223.120.17") || strings.HasPrefix(ip, "223.120.16") || - strings.HasPrefix(ip, "223.120.140") || strings.HasPrefix(ip, "223.120.130") || strings.HasPrefix(ip, "223.120.131") || - strings.HasPrefix(ip, "223.120.141"): + case isCMIN2IPv4(octets): return "AS58807" case strings.HasPrefix(ip, "223.118") || strings.HasPrefix(ip, "223.119") || strings.HasPrefix(ip, "223.120") || strings.HasPrefix(ip, "223.121"): return "AS58453" + case strings.HasPrefix(ip, "221.183") || strings.HasPrefix(ip, "111.24"): + return "AS9808" case strings.HasPrefix(ip, "69.194") || strings.HasPrefix(ip, "203.22"): return "AS23764" default: return "" } } + +func isCMIN2IPv4(ip [4]byte) bool { + if ip[0] != 223 { + return false + } + if ip[1] == 118 && ip[2] == 32 { + return true + } + if ip[1] == 120 && ip[2] >= 128 { + return true + } + if ip[1] != 119 { + return false + } + third := ip[2] + return third == 8 || third == 9 || + (third >= 10 && third <= 15) || + (third >= 26 && third <= 29) || + (third >= 32 && third <= 37) || + third == 74 || third == 75 || third == 88 || third == 89 || + third == 100 || third == 252 || third == 253 +} diff --git a/bk/ipv4_asn_test.go b/bk/ipv4_asn_test.go new file mode 100644 index 0000000..985e06b --- /dev/null +++ b/bk/ipv4_asn_test.go @@ -0,0 +1,31 @@ +package backtrace + +import "testing" + +func TestIPv4ASNBackboneSignatures(t *testing.T) { + tests := map[string]string{ + "59.43.1.1": "AS4809", + "202.97.1.1": "AS4134", + "218.105.1.1": "AS9929", + "202.77.1.1": "AS10099", + "219.158.1.1": "AS4837", + "223.118.32.1": "AS58807", + "223.119.100.1": "AS58807", + "223.120.128.1": "AS58807", + "223.120.127.1": "AS58453", + "221.183.1.1": "AS9808", + "69.194.1.1": "AS23764", + "192.0.2.1": "", + } + for address, want := range tests { + if got := ipv4Asn(address); got != want { + t.Fatalf("ipv4Asn(%q) = %q, want %q", address, got, want) + } + } +} + +func TestIPv6ASNRecognizesCUGSnapshot(t *testing.T) { + if got := ipv6Asn("2401:8a00:1:12::1"); got != "AS10099" { + t.Fatalf("ipv6Asn(CUG) = %q, want AS10099", got) + } +} diff --git a/bk/ipv6_asn.go b/bk/ipv6_asn.go index 26af69c..69a6009 100644 --- a/bk/ipv6_asn.go +++ b/bk/ipv6_asn.go @@ -15,6 +15,9 @@ var as4134Data string //go:embed prefix/as9929.txt var as9929Data string +//go:embed prefix/as10099.txt +var as10099Data string + //go:embed prefix/as4837.txt var as4837Data string @@ -35,6 +38,7 @@ var asnPrefixes = map[string][]string{ "AS4809": strings.Split(as4809Data, "\n"), // 电信 CN2 GT/GIA "AS4134": strings.Split(as4134Data, "\n"), // 电信 163 骨干网 "AS9929": strings.Split(as9929Data, "\n"), // 联通 9929 优质国际线路 + "AS10099": strings.Split(as10099Data, "\n"), // 联通 CUG 国际网络 "AS4837": strings.Split(as4837Data, "\n"), // 联通 AS4837 普通国际线路 "AS58807": strings.Split(as58807Data, "\n"), // 移动 CMIN2 国际精品网 "AS9808": strings.Split(as9808Data, "\n"), // 移动 CMI(中国移动国际公司) @@ -45,6 +49,12 @@ var asnPrefixes = map[string][]string{ // 判断 IPv6 地址是否匹配 ASN 中的某个前缀 func ipv6Asn(ip string) string { ip = strings.ToLower(ip) + address, addressErr := netip.ParseAddr(ip) + if addressErr != nil || !address.Is6() { + return "" + } + bestASN := "" + bestSpecificity := -1 for asn, prefixes := range currentASNPrefixes() { for _, prefix := range prefixes { prefix = strings.TrimSpace(prefix) @@ -52,17 +62,35 @@ func ipv6Asn(ip string) string { continue } if strings.Contains(prefix, "/") { - address, addressErr := netip.ParseAddr(ip) network, networkErr := netip.ParsePrefix(prefix) - if addressErr == nil && networkErr == nil && network.Contains(address) { - return asn + if networkErr == nil && network.Contains(address) && + (network.Bits() > bestSpecificity || (network.Bits() == bestSpecificity && asn < bestASN)) { + bestASN = asn + bestSpecificity = network.Bits() } continue } if strings.HasPrefix(ip, prefix) { - return asn + specificity := prefixHexBits(prefix) + if specificity > bestSpecificity || (specificity == bestSpecificity && asn < bestASN) { + bestASN = asn + bestSpecificity = specificity + } } } } - return "" + return bestASN +} + +func prefixHexBits(prefix string) int { + bits := 0 + for _, value := range prefix { + switch { + case value >= '0' && value <= '9': + bits += 4 + case value >= 'a' && value <= 'f': + bits += 4 + } + } + return bits } diff --git a/bk/prefix/as10099.txt b/bk/prefix/as10099.txt new file mode 100644 index 0000000..8c41876 --- /dev/null +++ b/bk/prefix/as10099.txt @@ -0,0 +1,10 @@ +2401:8a00:13::/48 +2401:8a00:1:12::/64 +2401:8a00:1:14::/64 +2401:8a00:1:17::/64 +2401:8a00:1:2::/64 +2401:8a00:1:3::/64 +2401:8a00:1:7::/64 +2401:8a00:1:c::/64 +2401:8a00::/32 +2401:8a00:d::/48 diff --git a/bk/prefix/as10099.txt.manifest.json b/bk/prefix/as10099.txt.manifest.json new file mode 100644 index 0000000..d8a98fa --- /dev/null +++ b/bk/prefix/as10099.txt.manifest.json @@ -0,0 +1,7 @@ +{ + "schema": "backtrace.asn-prefixes/v1", + "file": "as10099.txt", + "count": 10, + "sha256": "7161aad3d31fb89f43234e03ed95c63c51e2ef5c4d49c9223126d01384fe7cba", + "generated_at": "2026-07-30T15:51:08Z" +} diff --git a/bk/prefix/as4134.txt.manifest.json b/bk/prefix/as4134.txt.manifest.json index 56ba6a5..51aa3c0 100644 --- a/bk/prefix/as4134.txt.manifest.json +++ b/bk/prefix/as4134.txt.manifest.json @@ -3,5 +3,5 @@ "file": "as4134.txt", "count": 629, "sha256": "00941f76c022566166b926670ee9358084c53465cbaaf75a3da7b73d997d1947", - "generated_at": "2026-07-23T02:56:31Z" + "generated_at": "2026-07-30T15:51:08Z" } diff --git a/bk/prefix/as4809.txt b/bk/prefix/as4809.txt index a409242..558fcf8 100644 --- a/bk/prefix/as4809.txt +++ b/bk/prefix/as4809.txt @@ -108,6 +108,7 @@ 240e:650:8000::/38 240e:659:f100::/48 240e:65f:100::/48 +240e:65f:800::/48 240e:65f:e000::/36 240e:669:b000::/40 240e:670:d000::/36 @@ -138,6 +139,8 @@ 240e:767:f000::/48 240e:787:7000::/48 240e:787:7001::/48 +240e:790:fe10::/48 +240e:790:fe20::/48 240e:790:ff00::/40 240e:7b6::/31 240e:965:822::/48 diff --git a/bk/prefix/as4809.txt.manifest.json b/bk/prefix/as4809.txt.manifest.json index 793cc2c..53f4f7f 100644 --- a/bk/prefix/as4809.txt.manifest.json +++ b/bk/prefix/as4809.txt.manifest.json @@ -1,7 +1,7 @@ { "schema": "backtrace.asn-prefixes/v1", "file": "as4809.txt", - "count": 185, - "sha256": "943e26cef20a0a7faba55c98825d89933a597b885ab01766ce2d1b0a58f9c8f0", - "generated_at": "2026-07-30T02:34:19Z" + "count": 188, + "sha256": "a43638fa67e5057d237988233101ef0bfd4f5557001325289e0846af642670ae", + "generated_at": "2026-07-30T15:51:08Z" } diff --git a/bk/prefix/as4837.txt.manifest.json b/bk/prefix/as4837.txt.manifest.json index 12046b5..6a103d8 100644 --- a/bk/prefix/as4837.txt.manifest.json +++ b/bk/prefix/as4837.txt.manifest.json @@ -3,5 +3,5 @@ "file": "as4837.txt", "count": 492, "sha256": "40ece28148fc843764a9b1cec48d07013d6e6fdc8697b601af93a45093e1908c", - "generated_at": "2026-07-29T02:49:32Z" + "generated_at": "2026-07-30T15:51:08Z" } diff --git a/bk/prefix/as9808.txt.manifest.json b/bk/prefix/as9808.txt.manifest.json index 422a8b3..eb27fee 100644 --- a/bk/prefix/as9808.txt.manifest.json +++ b/bk/prefix/as9808.txt.manifest.json @@ -3,5 +3,5 @@ "file": "as9808.txt", "count": 7171, "sha256": "ba623bf5d30f2c69340ffdd2c4d50bfc79506b00ebdbc8a2f0448b7ce21148e1", - "generated_at": "2026-07-30T02:34:19Z" + "generated_at": "2026-07-30T15:51:08Z" } diff --git a/bk/prefix_registry.go b/bk/prefix_registry.go index 0aebbfe..3b988f6 100644 --- a/bk/prefix_registry.go +++ b/bk/prefix_registry.go @@ -26,7 +26,7 @@ const ( ASNPrefixRegistryCDNBaseURL = "https://cdn.spiritlhl.net/" + ASNPrefixRegistryRawBaseURL ) -var knownPrefixASNs = []string{"AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"} +var knownPrefixASNs = []string{"AS10099", "AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"} var prefixFragmentPattern = regexp.MustCompile(`^[0-9a-fA-F:]+$`) type ASNPrefixRegistrySource struct { @@ -226,6 +226,8 @@ func validateEmbeddedPrefixManifests() error { switch asn { case "AS23764": snapshot = []byte(as23764Data) + case "AS10099": + snapshot = []byte(as10099Data) case "AS4134": snapshot = []byte(as4134Data) case "AS4809": diff --git a/bk/route_classification.go b/bk/route_classification.go new file mode 100644 index 0000000..b2dbade --- /dev/null +++ b/bk/route_classification.go @@ -0,0 +1,238 @@ +package backtrace + +import "strings" + +// RouteHopEvidence is the ordered ASN evidence observed at one responding hop. +// A hop can contain more than one ASN when repeated traces take different paths. +type RouteHopEvidence struct { + Distance int `json:"distance"` + ASNs []string `json:"asns,omitempty"` +} + +// RouteClassification is a conservative classification of a China-carrier +// return route. Code and Confidence are stable machine-readable values; Label +// preserves the compact legacy display used by backtrace and ecs. +type RouteClassification struct { + Code string `json:"code"` + Label string `json:"label"` + Confidence string `json:"confidence"` + Rank int `json:"rank"` + Evidence string `json:"evidence"` +} + +const ( + routeConfidenceConfirmed = "confirmed" + routeConfidenceMixed = "mixed" + routeConfidenceInconclusive = "inconclusive" +) + +// ClassifyReturnRoute classifies ordered return-route evidence. Premium-route +// claims require enough evidence to distinguish a backbone segment from a +// single destination-network delivery hop. +func ClassifyReturnRoute(carrier string, hops []RouteHopEvidence) RouteClassification { + carrier = normalizeCarrier(carrier) + switch carrier { + case "CT": + return classifyTelecom(hops) + case "CU": + return classifyUnicom(hops) + case "CM": + return classifyMobile(hops) + default: + return inconclusiveClassification("unknown_carrier", "线路证据不足", "unknown carrier") + } +} + +func classifyTelecom(hops []RouteHopEvidence) RouteClassification { + cn2Index, cn2Hops := routeASNPosition(hops, "AS4809") + ct163Index, ct163Hops := routeASNPosition(hops, "AS4134") + ctgIndex, _ := routeASNPosition(hops, "AS23764") + if cn2Index >= 0 { + if cn2Hops < 2 { + return RouteClassification{ + Code: "ct_cn2_mixed", Label: "电信CN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "only one AS4809 hop; CN2 GIA is not confirmed", + } + } + if ct163Index < 0 || (cn2Index < ct163Index && ct163Hops <= 1) { + return RouteClassification{ + Code: "ct_cn2_gia", Label: "电信CN2GIA [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 5, + Evidence: "at least two AS4809 hops precede at most one AS4134 delivery hop", + } + } + if cn2Index < ct163Index { + return RouteClassification{ + Code: "ct_cn2_mixed", Label: "电信CN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "AS4809 is followed by multiple AS4134 backbone hops", + } + } + return RouteClassification{ + Code: "ct_cn2_gt", Label: "电信CN2GT [优质线路]", Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "AS4134 appears before the AS4809 segment", + } + } + if ctgIndex >= 0 { + return RouteClassification{ + Code: "ct_ctgnet", Label: "电信CTGNET [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 4, + Evidence: "AS23764 is present", + } + } + if ct163Index >= 0 { + if ct163Hops <= 1 { + return inconclusiveClassification("ct_destination_only", "仅见电信目的网", "only one AS4134 hop") + } + return RouteClassification{ + Code: "ct_163", Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2, + Evidence: "multiple AS4134 hops are present without premium backbone evidence", + } + } + return inconclusiveClassification("ct_unknown", "未见电信骨干", "AS4809, AS23764, and AS4134 are absent") +} + +func classifyUnicom(hops []RouteHopEvidence) RouteClassification { + cu9929Index, _ := routeASNPosition(hops, "AS9929") + cugIndex, _ := routeASNPosition(hops, "AS10099") + cu4837Index, cu4837Hops := routeASNPosition(hops, "AS4837") + if cu9929Index >= 0 { + if cu4837Index >= 0 && cu4837Index < cu9929Index { + return RouteClassification{ + Code: "cu_9929_mixed", Label: "联通9929混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "AS4837 appears before the AS9929 segment", + } + } + return RouteClassification{ + Code: "cu_9929", Label: "联通9929 [优质线路]", Confidence: routeConfidenceConfirmed, Rank: 5, + Evidence: "AS9929 is present without an earlier AS4837 segment", + } + } + if cugIndex >= 0 { + return RouteClassification{ + Code: "cu_cug", Label: "联通CUG [优质线路]", Confidence: routeConfidenceConfirmed, Rank: 3, + Evidence: "AS10099 is present without AS9929", + } + } + if cu4837Index >= 0 { + if cu4837Hops <= 1 { + return inconclusiveClassification("cu_destination_only", "仅见联通目的网", "only one AS4837 hop") + } + return RouteClassification{ + Code: "cu_4837", Label: "联通4837 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2, + Evidence: "multiple AS4837 hops are present without premium backbone evidence", + } + } + return inconclusiveClassification("cu_unknown", "未见联通骨干", "AS9929, AS10099, and AS4837 are absent") +} + +func classifyMobile(hops []RouteHopEvidence) RouteClassification { + cmin2Index, _ := routeASNPosition(hops, "AS58807") + cmiIndex, _ := routeASNPosition(hops, "AS58453") + cmnetIndex, _ := routeASNPosition(hops, "AS9808") + if cmin2Index >= 0 { + if cmiIndex >= 0 && cmiIndex < cmin2Index { + return RouteClassification{ + Code: "cm_cmin2_mixed", Label: "移动CMIN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "AS58453 appears before the AS58807 segment", + } + } + return RouteClassification{ + Code: "cm_cmin2", Label: "移动CMIN2 [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 5, + Evidence: "AS58807 is present without an earlier AS58453 segment", + } + } + if cmiIndex >= 0 { + return RouteClassification{ + Code: "cm_cmi", Label: "移动CMI [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2, + Evidence: "AS58453 is present without CMIN2 evidence", + } + } + if cmnetIndex >= 0 { + return RouteClassification{ + Code: "cm_cmnet", Label: "移动CMNET [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2, + Evidence: "AS9808 is present without international premium backbone evidence", + } + } + return inconclusiveClassification("cm_unknown", "未见移动骨干", "AS58807, AS58453, and AS9808 are absent") +} + +// combineRouteClassifications keeps useful results when one attempt is +// inconclusive, but downgrades conflicting confirmed paths to a mixed result. +func combineRouteClassifications(carrier string, values []RouteClassification) RouteClassification { + known := make([]RouteClassification, 0, len(values)) + for _, value := range values { + if value.Confidence != routeConfidenceInconclusive { + known = append(known, value) + } + } + if len(known) == 0 { + if len(values) > 0 { + return values[0] + } + return inconclusiveClassification(strings.ToLower(normalizeCarrier(carrier))+"_unknown", "线路证据不足", "no classified route evidence") + } + best := known[0] + distinct := map[string]struct{}{best.Code: {}} + for _, value := range known[1:] { + distinct[value.Code] = struct{}{} + if value.Rank > best.Rank { + best = value + } + } + if len(distinct) == 1 { + return best + } + label := map[string]string{ + "CT": "电信动态混合 [优质线路]", + "CU": "联通动态混合 [优质线路]", + "CM": "移动动态混合 [优质线路]", + }[normalizeCarrier(carrier)] + if label == "" { + label = "动态混合线路" + } + return RouteClassification{ + Code: strings.ToLower(normalizeCarrier(carrier)) + "_dynamic_mixed", Label: label, + Confidence: routeConfidenceMixed, Rank: 3, + Evidence: "successful trace attempts observed conflicting backbone classes", + } +} + +func routeASNPosition(hops []RouteHopEvidence, target string) (int, int) { + first := -1 + count := 0 + for index, hop := range hops { + matched := false + for _, asn := range hop.ASNs { + if strings.EqualFold(strings.TrimSpace(asn), target) { + matched = true + break + } + } + if matched { + if first < 0 { + first = index + } + count++ + } + } + return first, count +} + +func normalizeCarrier(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + switch value { + case "CT", "TELECOM", "电信": + return "CT" + case "CU", "UNICOM", "联通": + return "CU" + case "CM", "CMCC", "MOBILE", "移动": + return "CM" + default: + return value + } +} + +func inconclusiveClassification(code, label, evidence string) RouteClassification { + return RouteClassification{ + Code: code, Label: label, Confidence: routeConfidenceInconclusive, + Rank: 0, Evidence: evidence, + } +} diff --git a/bk/route_classification_test.go b/bk/route_classification_test.go new file mode 100644 index 0000000..3ba5ca6 --- /dev/null +++ b/bk/route_classification_test.go @@ -0,0 +1,74 @@ +package backtrace + +import "testing" + +func routeFixture(asns ...string) []RouteHopEvidence { + hops := make([]RouteHopEvidence, 0, len(asns)) + for index, asn := range asns { + hops = append(hops, RouteHopEvidence{Distance: index + 1, ASNs: []string{asn}}) + } + return hops +} + +func TestClassifyTelecomRequiresOrderedRepeatedEvidence(t *testing.T) { + tests := []struct { + name string + asns []string + code string + rank int + }{ + {name: "single CN2 hop", asns: []string{"AS4809", "AS4134"}, code: "ct_cn2_mixed", rank: 3}, + {name: "GIA with delivery hop", asns: []string{"AS4809", "AS4809", "AS4134"}, code: "ct_cn2_gia", rank: 5}, + {name: "CN2 then 163 backbone", asns: []string{"AS4809", "AS4809", "AS4134", "AS4134"}, code: "ct_cn2_mixed", rank: 3}, + {name: "163 before CN2", asns: []string{"AS4134", "AS4809", "AS4809"}, code: "ct_cn2_gt", rank: 3}, + {name: "single destination hop", asns: []string{"AS4134"}, code: "ct_destination_only", rank: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ClassifyReturnRoute("CT", routeFixture(test.asns...)) + if result.Code != test.code || result.Rank != test.rank { + t.Fatalf("ClassifyReturnRoute() = %+v, want code=%s rank=%d", result, test.code, test.rank) + } + }) + } +} + +func TestClassifyUnicomAddsCUGAndProtectsDestinationHop(t *testing.T) { + tests := []struct { + asns []string + code string + }{ + {asns: []string{"AS9929", "AS9929", "AS4837"}, code: "cu_9929"}, + {asns: []string{"AS4837", "AS9929"}, code: "cu_9929_mixed"}, + {asns: []string{"AS10099"}, code: "cu_cug"}, + {asns: []string{"AS4837"}, code: "cu_destination_only"}, + } + for _, test := range tests { + result := ClassifyReturnRoute("CU", routeFixture(test.asns...)) + if result.Code != test.code { + t.Fatalf("ClassifyReturnRoute(%v) = %+v, want %s", test.asns, result, test.code) + } + } +} + +func TestClassifyMobileUsesOrderedCMIN2Evidence(t *testing.T) { + if result := ClassifyReturnRoute("CM", routeFixture("AS58807", "AS9808")); result.Code != "cm_cmin2" { + t.Fatalf("CMIN2 route = %+v", result) + } + if result := ClassifyReturnRoute("CM", routeFixture("AS58453", "AS58807")); result.Code != "cm_cmin2_mixed" { + t.Fatalf("mixed CMIN2 route = %+v", result) + } + if result := ClassifyReturnRoute("CM", routeFixture("AS9808")); result.Code != "cm_cmnet" { + t.Fatalf("CMNET route = %+v", result) + } +} + +func TestCombineRouteClassificationsDowngradesDynamicDisagreement(t *testing.T) { + result := combineRouteClassifications("CU", []RouteClassification{ + ClassifyReturnRoute("CU", routeFixture("AS9929")), + ClassifyReturnRoute("CU", routeFixture("AS4837", "AS4837")), + }) + if result.Code != "cu_dynamic_mixed" || result.Confidence != routeConfidenceMixed || result.Rank != 3 { + t.Fatalf("combined route = %+v", result) + } +} diff --git a/bk/route_render.go b/bk/route_render.go new file mode 100644 index 0000000..d73bc2a --- /dev/null +++ b/bk/route_render.go @@ -0,0 +1,40 @@ +package backtrace + +import ( + "fmt" + "strings" + + . "github.com/oneclickvirt/defaultset" +) + +// RenderRouteReport preserves the original compact one-target-per-line style. +// Detailed hop statistics remain available in RouteReport JSON instead of +// expanding the terminal section. +func RenderRouteReport(report RouteReport) string { + var builder strings.Builder + for _, target := range report.Targets { + label := target.Classification.Label + if label == "" { + label = "线路证据不足" + } + var rendered string + switch { + case target.Status != RouteProbeAvailable: + rendered = Red("检测不到回程路由节点的IP地址") + case target.Classification.Confidence == routeConfidenceInconclusive: + rendered = Red(label) + case target.Classification.Rank >= 4: + rendered = DarkGreen(label) + case target.Classification.Rank == 3: + rendered = Green(label) + default: + rendered = White(label) + } + addressWidth := 15 + if target.Target.IPVersion == "v6" { + addressWidth = 24 + } + builder.WriteString(fmt.Sprintf("%v %-*s %v\n", target.Target.Name, addressWidth, target.Target.Address, rendered)) + } + return strings.TrimSuffix(builder.String(), "\n") +} diff --git a/bk/route_report.go b/bk/route_report.go new file mode 100644 index 0000000..c68c5f5 --- /dev/null +++ b/bk/route_report.go @@ -0,0 +1,441 @@ +package backtrace + +import ( + "context" + "errors" + "math" + "net" + "sort" + "strings" + "sync" + "time" + + "github.com/oneclickvirt/backtrace/model" +) + +const RouteReportSchema = "backtrace.routes/v1" + +type RouteProbeStatus string + +const ( + RouteProbeAvailable RouteProbeStatus = "available" + RouteProbeUnavailable RouteProbeStatus = "unavailable" + RouteProbeTimeout RouteProbeStatus = "timeout" + RouteProbeCanceled RouteProbeStatus = "canceled" +) + +// RouteTarget identifies one carrier route without coupling the runner to the +// built-in target registry. This also makes offline fixture tests possible. +type RouteTarget struct { + Name string `json:"name"` + Address string `json:"address"` + IPVersion string `json:"ip_version"` + Carrier string `json:"carrier"` +} + +type RouteLatencyStats struct { + Samples int `json:"samples"` + MinMS float64 `json:"min_ms"` + AvgMS float64 `json:"avg_ms"` + P50MS float64 `json:"p50_ms"` + P95MS float64 `json:"p95_ms"` + MaxMS float64 `json:"max_ms"` + JitterMS float64 `json:"jitter_ms"` +} + +type RouteTargetReport struct { + Target RouteTarget `json:"target"` + Status RouteProbeStatus `json:"status"` + Protocol string `json:"protocol"` + Attempts int `json:"attempts"` + SuccessfulAttempts int `json:"successful_attempts"` + ValidHops int `json:"valid_hops"` + TargetReached bool `json:"target_reached"` + Fallback bool `json:"fallback"` + ObservedASNs []string `json:"observed_asns,omitempty"` + Classification RouteClassification `json:"classification"` + Latency RouteLatencyStats `json:"hop_rtt"` + Error string `json:"error,omitempty"` +} + +type RouteReport struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + DurationMS int64 `json:"duration_ms"` + Targets []RouteTargetReport `json:"targets"` +} + +type TraceFunc func(context.Context, net.IP) ([]*Hop, error) +type AlternativeTargetFunc func(RouteTarget) []string + +type RouteReportConfig struct { + EnableIPv6 bool + Attempts int + Timeout time.Duration + Targets []RouteTarget + Trace TraceFunc + AlternativeTarget AlternativeTargetFunc +} + +type routeAttempt struct { + hops []*Hop + targetIP net.IP + fallback bool + err error +} + +// RunRouteReport executes the built-in China carrier return-route matrix and +// returns deterministic structured results. ICMP traceroute response rates are +// route evidence only and must not be interpreted as application packet loss. +func RunRouteReport(ctx context.Context, config RouteReportConfig) RouteReport { + started := time.Now() + if ctx == nil { + ctx = context.Background() + } + if config.Attempts <= 0 { + config.Attempts = 3 + } + if config.Timeout <= 0 { + config.Timeout = 10 * time.Second + } + if config.Trace == nil { + config.Trace = TraceContext + } + usesDefaultAlternatives := config.AlternativeTarget == nil + if len(config.Targets) == 0 { + config.Targets = defaultRouteTargets(config.EnableIPv6) + } + runCtx, cancel := context.WithTimeout(ctx, config.Timeout) + defer cancel() + StartASNPrefixRefresh() + if usesDefaultAlternatives { + refreshDone := make(chan struct{}) + go func() { + refreshAlternativeTargets(runCtx) + close(refreshDone) + }() + config.AlternativeTarget = func(target RouteTarget) []string { + select { + case <-refreshDone: + return defaultAlternativeTargets(target) + case <-runCtx.Done(): + return nil + } + } + } + reports := make([]RouteTargetReport, len(config.Targets)) + type indexedReport struct { + index int + report RouteTargetReport + } + results := make(chan indexedReport, len(config.Targets)) + for index, target := range config.Targets { + go func(index int, target RouteTarget) { + results <- indexedReport{index: index, report: runRouteTarget(runCtx, target, config)} + }(index, target) + } + received := make([]bool, len(config.Targets)) + receivedCount := 0 + for receivedCount < len(config.Targets) { + select { + case result := <-results: + reports[result.index] = result.report + if !received[result.index] { + received[result.index] = true + receivedCount++ + } + case <-runCtx.Done(): + for index, target := range config.Targets { + if received[index] { + continue + } + reports[index] = canceledRouteTarget(target, config.Attempts, runCtx.Err()) + } + receivedCount = len(config.Targets) + } + } + return RouteReport{ + SchemaVersion: RouteReportSchema, + GeneratedAt: time.Now().UTC(), + DurationMS: time.Since(started).Milliseconds(), + Targets: reports, + } +} + +func runRouteTarget(ctx context.Context, target RouteTarget, config RouteReportConfig) RouteTargetReport { + report := RouteTargetReport{ + Target: target, Status: RouteProbeUnavailable, Protocol: "icmp", + Attempts: config.Attempts, + Classification: inconclusiveClassification(strings.ToLower(normalizeCarrier(target.Carrier))+"_unknown", "线路证据不足", "no responding trace attempts"), + } + attempts := make(chan routeAttempt, config.Attempts) + for attempt := 0; attempt < config.Attempts; attempt++ { + go func() { + attempts <- safeExecuteRouteAttempt(ctx, target, config.Trace, config.AlternativeTarget) + }() + } + successful := make([]routeAttempt, 0, config.Attempts) + for attempt := 0; attempt < config.Attempts; attempt++ { + select { + case result := <-attempts: + if result.err == nil && len(result.hops) > 0 { + successful = append(successful, result) + report.Fallback = report.Fallback || result.fallback + } + case <-ctx.Done(): + return canceledRouteTarget(target, config.Attempts, ctx.Err()) + } + } + if len(successful) == 0 { + return report + } + report.Status = RouteProbeAvailable + report.SuccessfulAttempts = len(successful) + allHops := make([][]*Hop, 0, len(successful)) + classifications := make([]RouteClassification, 0, len(successful)+1) + latencies := make([]float64, 0) + for _, attempt := range successful { + allHops = append(allHops, attempt.hops) + evidence := routeEvidenceFromHops(attempt.hops) + classifications = append(classifications, ClassifyReturnRoute(target.Carrier, evidence)) + latencies = append(latencies, routeRTTMilliseconds(attempt.hops)...) + if routeReachedTarget(attempt.hops, attempt.targetIP) { + report.TargetReached = true + } + } + merged := mergeHops(allHops) + mergedEvidence := routeEvidenceFromHops(merged) + classifications = append(classifications, ClassifyReturnRoute(target.Carrier, mergedEvidence)) + report.ValidHops = len(mergedEvidence) + report.ObservedASNs = uniqueRouteASNs(mergedEvidence) + report.Classification = combineRouteClassifications(target.Carrier, classifications) + report.Latency = calculateRouteLatency(latencies) + return report +} + +func safeExecuteRouteAttempt(ctx context.Context, target RouteTarget, trace TraceFunc, alternatives AlternativeTargetFunc) (result routeAttempt) { + defer func() { + if recover() != nil { + result = routeAttempt{err: errors.New("route probe failed")} + } + }() + return executeRouteAttempt(ctx, target, trace, alternatives) +} + +func executeRouteAttempt(ctx context.Context, target RouteTarget, trace TraceFunc, alternatives AlternativeTargetFunc) routeAttempt { + primary := net.ParseIP(strings.TrimSpace(target.Address)) + if primary == nil { + return routeAttempt{err: errors.New("invalid route target")} + } + hops, err := trace(ctx, primary) + if err == nil && len(hops) > 0 { + return routeAttempt{hops: hops, targetIP: primary} + } + for _, value := range alternatives(target) { + candidate := net.ParseIP(strings.TrimSpace(value)) + if candidate == nil || candidate.Equal(primary) { + continue + } + hops, err = trace(ctx, candidate) + if err == nil && len(hops) > 0 { + return routeAttempt{hops: hops, targetIP: candidate, fallback: true} + } + if ctx.Err() != nil { + return routeAttempt{err: ctx.Err()} + } + } + if err == nil { + err = errors.New("route returned no responding hops") + } + return routeAttempt{err: err} +} + +func canceledRouteTarget(target RouteTarget, attempts int, err error) RouteTargetReport { + status := RouteProbeTimeout + message := "route probe timed out" + if errors.Is(err, context.Canceled) { + status = RouteProbeCanceled + message = "route probe canceled" + } + return RouteTargetReport{ + Target: target, Status: status, Protocol: "icmp", Attempts: attempts, + Classification: inconclusiveClassification(strings.ToLower(normalizeCarrier(target.Carrier))+"_unknown", "线路证据不足", message), + Error: message, + } +} + +func defaultRouteTargets(enableIPv6 bool) []RouteTarget { + targets := make([]RouteTarget, 0, len(model.Ipv4s)+len(model.Ipv6s)) + for index, address := range model.Ipv4s { + targets = append(targets, RouteTarget{ + Name: model.Ipv4Names[index], Address: address, IPVersion: "v4", Carrier: carrierFromTargetName(model.Ipv4Names[index]), + }) + } + if enableIPv6 { + for index, address := range model.Ipv6s { + targets = append(targets, RouteTarget{ + Name: model.Ipv6Names[index], Address: address, IPVersion: "v6", Carrier: carrierFromTargetName(model.Ipv6Names[index]), + }) + } + } + return targets +} + +func carrierFromTargetName(name string) string { + switch { + case strings.Contains(name, "电信"): + return "CT" + case strings.Contains(name, "联通"): + return "CU" + case strings.Contains(name, "移动"): + return "CM" + default: + return "" + } +} + +func routeEvidenceFromHops(hops []*Hop) []RouteHopEvidence { + result := make([]RouteHopEvidence, 0, len(hops)) + for _, hop := range hops { + if hop == nil || len(hop.Nodes) == 0 { + continue + } + seen := make(map[string]struct{}) + asns := make([]string, 0, len(hop.Nodes)) + for _, node := range hop.Nodes { + if node == nil || node.IP == nil { + continue + } + asn := ipv4Asn(node.IP.String()) + if asn == "" { + continue + } + if _, exists := seen[asn]; exists { + continue + } + seen[asn] = struct{}{} + asns = append(asns, asn) + } + result = append(result, RouteHopEvidence{Distance: hop.Distance, ASNs: asns}) + } + return result +} + +func uniqueRouteASNs(hops []RouteHopEvidence) []string { + seen := make(map[string]struct{}) + result := make([]string, 0) + for _, hop := range hops { + for _, asn := range hop.ASNs { + if _, exists := seen[asn]; exists { + continue + } + seen[asn] = struct{}{} + result = append(result, asn) + } + } + return result +} + +func routeRTTMilliseconds(hops []*Hop) []float64 { + result := make([]float64, 0) + for _, hop := range hops { + if hop == nil { + continue + } + for _, node := range hop.Nodes { + if node == nil { + continue + } + for _, value := range node.RTT { + if value >= 0 { + result = append(result, float64(value)/float64(time.Millisecond)) + } + } + } + } + return result +} + +func calculateRouteLatency(values []float64) RouteLatencyStats { + if len(values) == 0 { + return RouteLatencyStats{} + } + ordered := append([]float64(nil), values...) + sort.Float64s(ordered) + total := 0.0 + for _, value := range values { + total += value + } + jitter := 0.0 + if len(values) > 1 { + for index := 1; index < len(values); index++ { + delta := values[index] - values[index-1] + if delta < 0 { + delta = -delta + } + jitter += delta + } + jitter /= float64(len(values) - 1) + } + return RouteLatencyStats{ + Samples: len(values), MinMS: ordered[0], AvgMS: total / float64(len(values)), + P50MS: ordered[percentileIndex(len(ordered), 0.50)], P95MS: ordered[percentileIndex(len(ordered), 0.95)], + MaxMS: ordered[len(ordered)-1], JitterMS: jitter, + } +} + +func percentileIndex(length int, percentile float64) int { + if length <= 1 { + return 0 + } + index := int(math.Ceil(float64(length)*percentile)) - 1 + if index < 0 { + return 0 + } + if index >= length { + return length - 1 + } + return index +} + +func routeReachedTarget(hops []*Hop, target net.IP) bool { + if target == nil { + return false + } + for _, hop := range hops { + if hop == nil { + continue + } + for _, node := range hop.Nodes { + if node != nil && node.IP != nil && node.IP.Equal(target) { + return true + } + } + } + return false +} + +var alternativeRefreshMu sync.Mutex + +func refreshAlternativeTargets(ctx context.Context) { + alternativeRefreshMu.Lock() + defer alternativeRefreshMu.Unlock() + if model.CachedIcmpData != "" && model.ParsedIcmpTargets != nil && time.Since(model.CachedIcmpDataFetchTime) <= time.Hour { + return + } + data := getDataContext(ctx, model.IcmpTargets) + if data == "" { + return + } + parsed := parseIcmpTargets(data) + if len(parsed) == 0 { + return + } + model.CachedIcmpData = data + model.ParsedIcmpTargets = parsed + model.CachedIcmpDataFetchTime = time.Now() +} + +func defaultAlternativeTargets(target RouteTarget) []string { + return tryAlternativeIPs(target.Name, target.IPVersion) +} diff --git a/bk/route_report_test.go b/bk/route_report_test.go new file mode 100644 index 0000000..fc00d65 --- /dev/null +++ b/bk/route_report_test.go @@ -0,0 +1,132 @@ +package backtrace + +import ( + "context" + "net" + "regexp" + "strings" + "testing" + "time" + + "github.com/oneclickvirt/backtrace/model" +) + +func testHop(distance int, ip string, rtt time.Duration) *Hop { + return &Hop{Distance: distance, Nodes: []*Node{{IP: net.ParseIP(ip), RTT: []time.Duration{rtt}}}} +} + +func TestRunRouteReportOfflineFixture(t *testing.T) { + report := RunRouteReport(context.Background(), RouteReportConfig{ + Attempts: 2, + Timeout: time.Second, + Targets: []RouteTarget{{Name: "测试电信v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CT"}}, + Trace: func(_ context.Context, _ net.IP) ([]*Hop, error) { + return []*Hop{ + testHop(1, "59.43.1.1", 10*time.Millisecond), + testHop(2, "59.43.2.2", 20*time.Millisecond), + testHop(3, "202.97.1.1", 30*time.Millisecond), + }, nil + }, + AlternativeTarget: func(RouteTarget) []string { return nil }, + }) + if report.SchemaVersion != RouteReportSchema || len(report.Targets) != 1 { + t.Fatalf("unexpected report: %+v", report) + } + target := report.Targets[0] + if target.Status != RouteProbeAvailable || target.SuccessfulAttempts != 2 || target.ValidHops != 3 { + t.Fatalf("unexpected target status: %+v", target) + } + if target.Classification.Code != "ct_cn2_gia" || target.Latency.Samples != 6 || target.Latency.P95MS != 30 { + t.Fatalf("unexpected route classification or latency: %+v", target) + } + if strings.Contains(RenderRouteReport(report), "P95") || !strings.Contains(RenderRouteReport(report), "电信CN2GIA") { + t.Fatalf("legacy rendering is not compact: %q", RenderRouteReport(report)) + } +} + +func TestRunRouteReportUsesAlternativeWithoutExposingItsAddress(t *testing.T) { + report := RunRouteReport(context.Background(), RouteReportConfig{ + Attempts: 1, + Timeout: time.Second, + Targets: []RouteTarget{{Name: "测试联通v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CU"}}, + Trace: func(_ context.Context, ip net.IP) ([]*Hop, error) { + if ip.Equal(net.ParseIP("198.51.100.2")) { + return []*Hop{testHop(1, "202.77.1.1", 12*time.Millisecond)}, nil + } + return nil, nil + }, + AlternativeTarget: func(RouteTarget) []string { return []string{"198.51.100.2"} }, + }) + target := report.Targets[0] + if !target.Fallback || target.Classification.Code != "cu_cug" { + t.Fatalf("alternative result = %+v", target) + } + if strings.Contains(RenderRouteReport(report), "198.51.100.2") { + t.Fatalf("legacy output exposed fallback target: %q", RenderRouteReport(report)) + } +} + +func TestRenderRouteReportPreservesLegacyAddressWidths(t *testing.T) { + report := RouteReport{Targets: []RouteTargetReport{ + { + Target: RouteTarget{Name: "北京电信v4", Address: "219.141.140.10", IPVersion: "v4", Carrier: "CT"}, + Status: RouteProbeAvailable, + Classification: RouteClassification{Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2}, + }, + { + Target: RouteTarget{Name: "北京电信v6", Address: "2400:89c0:1053:3::69", IPVersion: "v6", Carrier: "CT"}, + Status: RouteProbeAvailable, + Classification: RouteClassification{Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2}, + }, + }} + rendered := regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(RenderRouteReport(report), "") + if !strings.Contains(rendered, "北京电信v4 219.141.140.10 电信163") { + t.Fatalf("IPv4 legacy spacing changed: %q", rendered) + } + if !strings.Contains(rendered, "北京电信v6 2400:89c0:1053:3::69 电信163") { + t.Fatalf("IPv6 legacy spacing changed: %q", rendered) + } +} + +func TestRunRouteReportHonorsContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + report := RunRouteReport(ctx, RouteReportConfig{ + Attempts: 1, + Targets: []RouteTarget{{Name: "测试移动v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CM"}}, + Trace: func(ctx context.Context, _ net.IP) ([]*Hop, error) { return nil, ctx.Err() }, + AlternativeTarget: func(RouteTarget) []string { return nil }, + }) + if report.Targets[0].Status != RouteProbeCanceled { + t.Fatalf("canceled report = %+v", report.Targets[0]) + } +} + +func TestRefreshAlternativeTargetsHonorsCanceledContextAndKeepsValidCache(t *testing.T) { + alternativeRefreshMu.Lock() + oldData := model.CachedIcmpData + oldTargets := model.ParsedIcmpTargets + oldFetchedAt := model.CachedIcmpDataFetchTime + model.CachedIcmpData = `[{"province":"北京","isp":"电信","ip_version":"v4","ips":"192.0.2.10"}]` + model.ParsedIcmpTargets = []model.IcmpTarget{{Province: "北京", ISP: "电信", IPVersion: "v4", IPs: "192.0.2.10"}} + model.CachedIcmpDataFetchTime = time.Now().Add(-2 * time.Hour) + alternativeRefreshMu.Unlock() + t.Cleanup(func() { + alternativeRefreshMu.Lock() + defer alternativeRefreshMu.Unlock() + model.CachedIcmpData = oldData + model.ParsedIcmpTargets = oldTargets + model.CachedIcmpDataFetchTime = oldFetchedAt + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + refreshAlternativeTargets(ctx) + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("canceled refresh took %s", elapsed) + } + if model.CachedIcmpData == "" || len(model.ParsedIcmpTargets) != 1 { + t.Fatalf("failed refresh replaced valid cache: data=%q targets=%v", model.CachedIcmpData, model.ParsedIcmpTargets) + } +} diff --git a/bk/trace_common.go b/bk/trace_common.go index c12b9f7..4b13510 100644 --- a/bk/trace_common.go +++ b/bk/trace_common.go @@ -544,6 +544,15 @@ func (h *Hop) Add(r *Reply) *Node { // Trace is a simple traceroute tool using DefaultTracer. func Trace(ip net.IP) ([]*Hop, error) { + return TraceContext(context.Background(), ip) +} + +// TraceContext runs an ICMP traceroute that stops promptly when the caller's +// context is canceled. +func TraceContext(ctx context.Context, ip net.IP) ([]*Hop, error) { + if ctx == nil { + ctx = context.Background() + } hops := make([]*Hop, 0, DefaultTracer.MaxHops) touch := func(dist int) *Hop { for _, h := range hops { @@ -555,7 +564,7 @@ func Trace(ip net.IP) ([]*Hop, error) { hops = append(hops, h) return h } - err := DefaultTracer.Trace(context.Background(), ip, func(r *Reply) { + err := DefaultTracer.Trace(ctx, ip, func(r *Reply) { touch(r.Hops).Add(r) }) if err != nil && err != context.DeadlineExceeded { diff --git a/bk/utils.go b/bk/utils.go index 8fb4838..59a71f6 100644 --- a/bk/utils.go +++ b/bk/utils.go @@ -1,6 +1,7 @@ package backtrace import ( + "context" "encoding/json" "fmt" "io" @@ -35,32 +36,43 @@ func removeDuplicates(elements []string) []string { // checkCdn 检查CDN可用性,参考shell脚本的测试逻辑 func checkCdn(testUrl string) string { + return checkCdnContext(context.Background(), testUrl) +} + +func checkCdnContext(ctx context.Context, testUrl string) string { + if ctx == nil { + ctx = context.Background() + } client := req.C() client.SetTimeout(6 * time.Second) if model.EnableLoger { InitLogger() defer Logger.Sync() } - for _, cdnUrl := range model.CdnList { + for index, cdnUrl := range model.CdnList { url := cdnUrl + testUrl if model.EnableLoger { - Logger.Info(fmt.Sprintf("Testing CDN: %s", url)) + Logger.Info(fmt.Sprintf("Testing CDN source %d", index+1)) } - resp, err := client.R().Get(url) + resp, err := client.R().SetContext(ctx).Get(url) if err == nil && resp != nil && resp.Body != nil { b, err := io.ReadAll(resp.Body) resp.Body.Close() if err == nil && strings.Contains(string(b), "success") { if model.EnableLoger { - Logger.Info(fmt.Sprintf("CDN available: %s", cdnUrl)) + Logger.Info(fmt.Sprintf("CDN source %d available", index+1)) } return cdnUrl } } if model.EnableLoger { - Logger.Info(fmt.Sprintf("CDN test failed: %s, error: %v", cdnUrl, err)) + Logger.Info(fmt.Sprintf("CDN source %d unavailable", index+1)) + } + select { + case <-ctx.Done(): + return "" + case <-time.After(500 * time.Millisecond): } - time.Sleep(500 * time.Millisecond) } if model.EnableLoger { Logger.Info("No CDN available, using direct connection") @@ -70,12 +82,15 @@ func checkCdn(testUrl string) string { // getData 获取目标地址的文本内容 func getData(endpoint string) string { + return getDataContext(context.Background(), endpoint) +} + +func getDataContext(ctx context.Context, endpoint string) string { + if ctx == nil { + ctx = context.Background() + } client := req.C() client.SetTimeout(6 * time.Second) - client.R(). - SetRetryCount(2). - SetRetryBackoffInterval(1*time.Second, 5*time.Second). - SetRetryFixedInterval(2 * time.Second) if model.EnableLoger { InitLogger() defer Logger.Sync() @@ -83,15 +98,19 @@ func getData(endpoint string) string { // 先测试CDN可用性 testUrl := "https://raw.githubusercontent.com/spiritLHLS/ecs/main/back/test" - cdnUrl := checkCdn(testUrl) + cdnUrl := checkCdnContext(ctx, testUrl) // 如果有可用的CDN,使用CDN获取数据 if cdnUrl != "" { url := cdnUrl + endpoint if model.EnableLoger { - Logger.Info(fmt.Sprintf("Using CDN: %s", url)) + Logger.Info("Using validated CDN source") } - resp, err := client.R().Get(url) + resp, err := client.R(). + SetContext(ctx). + SetRetryCount(2). + SetRetryFixedInterval(2 * time.Second). + Get(url) if err == nil && resp != nil && resp.Body != nil { defer resp.Body.Close() b, err := io.ReadAll(resp.Body) @@ -103,15 +122,19 @@ func getData(endpoint string) string { } } if model.EnableLoger { - Logger.Info(fmt.Sprintf("CDN request failed: %v", err)) + Logger.Info("CDN data request failed") } } // CDN不可用,尝试直连 if model.EnableLoger { - Logger.Info(fmt.Sprintf("Trying direct connection: %s", endpoint)) + Logger.Info("Trying direct data source") } - resp, err := client.R().Get(endpoint) + resp, err := client.R(). + SetContext(ctx). + SetRetryCount(2). + SetRetryFixedInterval(2 * time.Second). + Get(endpoint) if err == nil && resp != nil && resp.Body != nil { defer resp.Body.Close() b, err := io.ReadAll(resp.Body) @@ -123,7 +146,7 @@ func getData(endpoint string) string { } } if model.EnableLoger { - Logger.Info(fmt.Sprintf("Direct connection failed: %v", err)) + Logger.Info("Direct data request failed") } return "" } diff --git a/cmd/main.go b/cmd/main.go index a53bad9..d3eb580 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,7 +40,9 @@ type cliOptions struct { help bool ipv6 bool jsonOutput bool + routeJSON bool deep bool + routeTries int specifiedIP string timeout time.Duration } @@ -55,8 +57,10 @@ func newBacktraceFlagSet(options *cliOptions) *flag.FlagSet { set.StringVar(&options.specifiedIP, "ip", "", "Specify IP address for bgptools") set.BoolVar(&options.jsonOutput, "json", false, "Output structured RDAP/BGP report as JSON") set.BoolVar(&options.jsonOutput, "structured", false, "Alias for -json") + set.BoolVar(&options.routeJSON, "route-json", false, "Output structured return-route report as JSON") set.BoolVar(&options.deep, "deep", false, "Fetch geofeed and enable WHOIS fallback") - set.DurationVar(&options.timeout, "timeout", 15*time.Second, "Structured report timeout") + set.IntVar(&options.routeTries, "route-attempts", 3, "Traceroute attempts per target (1-5)") + set.DurationVar(&options.timeout, "timeout", 15*time.Second, "Structured or route report timeout") return set } @@ -83,7 +87,7 @@ func main() { if err := backtraceFlag.Parse(os.Args[1:]); err != nil { os.Exit(2) } - if !options.jsonOutput { + if !options.jsonOutput && !options.routeJSON { fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace")) } if options.help { @@ -114,6 +118,18 @@ func main() { } return } + if options.routeJSON { + report := backtrace.RunRouteReport(context.Background(), backtrace.RouteReportConfig{ + EnableIPv6: options.ipv6, + Attempts: options.routeTries, + Timeout: options.timeout, + }) + if err := writeStructuredRouteReport(os.Stdout, report); err != nil { + fmt.Fprintf(os.Stderr, "route report failed: %s\n", sanitizeErrorText(err.Error())) + os.Exit(1) + } + return + } info := IpInfo{} if options.showIPInfo { rsp, err := http.Get("http://ipinfo.io") @@ -200,9 +216,21 @@ func main() { } func validateStructuredOptions(options cliOptions) error { + if options.jsonOutput && options.routeJSON { + return fmt.Errorf("-json and -route-json are mutually exclusive") + } if options.deep && !options.jsonOutput { return fmt.Errorf("-deep requires -json or -structured") } + if options.routeJSON { + if options.timeout <= 0 { + return fmt.Errorf("route report timeout must be positive") + } + if options.routeTries < 1 || options.routeTries > 5 { + return fmt.Errorf("route attempts must be between 1 and 5") + } + return nil + } if !options.jsonOutput { return nil } @@ -215,6 +243,12 @@ func validateStructuredOptions(options cliOptions) error { return nil } +func writeStructuredRouteReport(output io.Writer, report backtrace.RouteReport) error { + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + return encoder.Encode(report) +} + func writeStructuredReport(ctx context.Context, output io.Writer, ip string, config bgptools.IPBGPReportConfig, query func(context.Context, string, bgptools.IPBGPReportConfig) (*bgptools.IPBGPReport, error)) error { report, err := query(ctx, ip, config) if err != nil { diff --git a/cmd/main_test.go b/cmd/main_test.go index 8d27684..a9a8122 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/oneclickvirt/backtrace/bgptools" + backtrace "github.com/oneclickvirt/backtrace/bk" ) func TestBacktraceStructuredFlagParsing(t *testing.T) { @@ -26,11 +27,53 @@ func TestBacktraceDefaultsPreserveLegacyMode(t *testing.T) { if err := newBacktraceFlagSet(&options).Parse(nil); err != nil { t.Fatal(err) } - if !options.showIPInfo || options.jsonOutput || options.deep || options.ipv6 || options.specifiedIP != "" || options.timeout != 15*time.Second { + if !options.showIPInfo || options.jsonOutput || options.routeJSON || options.deep || options.ipv6 || options.specifiedIP != "" || options.timeout != 15*time.Second || options.routeTries != 3 { t.Fatalf("legacy defaults changed: %+v", options) } } +func TestBacktraceRouteStructuredFlagParsing(t *testing.T) { + var options cliOptions + set := newBacktraceFlagSet(&options) + if err := set.Parse([]string{"-route-json", "-ipv6", "-route-attempts", "4", "-timeout", "9s"}); err != nil { + t.Fatal(err) + } + if !options.routeJSON || !options.ipv6 || options.routeTries != 4 || options.timeout != 9*time.Second { + t.Fatalf("unexpected route options: %+v", options) + } + if err := validateStructuredOptions(options); err != nil { + t.Fatalf("valid route options rejected: %v", err) + } +} + +func TestValidateRouteStructuredOptions(t *testing.T) { + for _, options := range []cliOptions{ + {jsonOutput: true, routeJSON: true, timeout: time.Second, routeTries: 3}, + {routeJSON: true, timeout: 0, routeTries: 3}, + {routeJSON: true, timeout: time.Second, routeTries: 0}, + {routeJSON: true, timeout: time.Second, routeTries: 6}, + } { + if err := validateStructuredOptions(options); err == nil { + t.Fatalf("expected invalid route options: %+v", options) + } + } +} + +func TestWriteStructuredRouteReportKeepsStdoutJSONOnly(t *testing.T) { + var output bytes.Buffer + report := backtrace.RouteReport{SchemaVersion: backtrace.RouteReportSchema, Targets: []backtrace.RouteTargetReport{}} + if err := writeStructuredRouteReport(&output, report); err != nil { + t.Fatal(err) + } + var decoded backtrace.RouteReport + if err := json.Unmarshal(output.Bytes(), &decoded); err != nil { + t.Fatalf("stdout is not route JSON: %v (%q)", err, output.String()) + } + if decoded.SchemaVersion != backtrace.RouteReportSchema { + t.Fatalf("unexpected route report: %+v", decoded) + } +} + func TestValidateStructuredOptionsRequiresExplicitModeAndTarget(t *testing.T) { tests := []cliOptions{ {deep: true, timeout: time.Second}, diff --git a/cmd/update-prefixes/main.go b/cmd/update-prefixes/main.go index 8216d8f..0adbcf5 100644 --- a/cmd/update-prefixes/main.go +++ b/cmd/update-prefixes/main.go @@ -20,7 +20,7 @@ import ( "time" ) -var knownASNs = []string{"AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"} +var knownASNs = []string{"AS10099", "AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"} var errPrefixCountDrop = errors.New("prefix count dropped beyond protection threshold") type updateConfig struct { diff --git a/model/model.go b/model/model.go index 0ef4f74..133ba6c 100644 --- a/model/model.go +++ b/model/model.go @@ -2,7 +2,7 @@ package model import "time" -const BackTraceVersion = "v0.0.17" +const BackTraceVersion = "v0.0.18" var EnableLoger = false @@ -68,6 +68,7 @@ var ( "AS4809": "电信CN2 [优质线路]", "AS4134": "电信163 [普通线路]", "AS9929": "联通9929 [优质线路]", + "AS10099": "联通CUG [优质线路]", "AS4837": "联通4837 [普通线路]", "AS58807": "移动CMIN2 [精品线路]", "AS9808": "移动CMI [普通线路]",