feat: strengthen three-carrier return route detection
Some checks failed
Sync ASN metadata / sync (push) Has been cancelled

This commit is contained in:
spiritlhl 2026-07-31 00:30:42 +08:00
parent c350b4e1f8
commit 5369aa16f7
23 changed files with 1187 additions and 136 deletions

View File

@ -1,105 +1,10 @@
package backtrace package backtrace
import ( import (
"strings" "context"
"time"
"github.com/oneclickvirt/backtrace/model"
) )
func safeTraceCall(fn func()) {
defer func() {
if r := recover(); r != nil {
}
}()
fn()
}
func BackTrace(enableIpv6 bool) string { func BackTrace(enableIpv6 bool) string {
StartASNPrefixRefresh() report := RunRouteReport(context.Background(), RouteReportConfig{EnableIPv6: enableIpv6})
if model.CachedIcmpData == "" || model.ParsedIcmpTargets == nil || time.Since(model.CachedIcmpDataFetchTime) > time.Hour { return RenderRouteReport(report)
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")
} }

View File

@ -1,6 +1,7 @@
package backtrace package backtrace
import ( import (
"net/netip"
"strings" "strings"
) )
@ -8,6 +9,11 @@ func ipv4Asn(ip string) string {
if strings.Contains(ip, ":") { if strings.Contains(ip, ":") {
return ipv6Asn(ip) return ipv6Asn(ip)
} }
address, err := netip.ParseAddr(strings.TrimSpace(ip))
if err != nil || !address.Is4() {
return ""
}
octets := address.As4()
switch { switch {
case strings.HasPrefix(ip, "59.43"): case strings.HasPrefix(ip, "59.43"):
return "AS4809" return "AS4809"
@ -15,17 +21,41 @@ func ipv4Asn(ip string) string {
return "AS4134" return "AS4134"
case strings.HasPrefix(ip, "218.105") || strings.HasPrefix(ip, "210.51"): case strings.HasPrefix(ip, "218.105") || strings.HasPrefix(ip, "210.51"):
return "AS9929" 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"): case strings.HasPrefix(ip, "219.158"):
return "AS4837" return "AS4837"
case strings.HasPrefix(ip, "223.120.19") || strings.HasPrefix(ip, "223.120.17") || strings.HasPrefix(ip, "223.120.16") || case isCMIN2IPv4(octets):
strings.HasPrefix(ip, "223.120.140") || strings.HasPrefix(ip, "223.120.130") || strings.HasPrefix(ip, "223.120.131") ||
strings.HasPrefix(ip, "223.120.141"):
return "AS58807" return "AS58807"
case strings.HasPrefix(ip, "223.118") || strings.HasPrefix(ip, "223.119") || strings.HasPrefix(ip, "223.120") || strings.HasPrefix(ip, "223.121"): case strings.HasPrefix(ip, "223.118") || strings.HasPrefix(ip, "223.119") || strings.HasPrefix(ip, "223.120") || strings.HasPrefix(ip, "223.121"):
return "AS58453" 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"): case strings.HasPrefix(ip, "69.194") || strings.HasPrefix(ip, "203.22"):
return "AS23764" return "AS23764"
default: default:
return "" 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
}

31
bk/ipv4_asn_test.go Normal file
View File

@ -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)
}
}

View File

@ -15,6 +15,9 @@ var as4134Data string
//go:embed prefix/as9929.txt //go:embed prefix/as9929.txt
var as9929Data string var as9929Data string
//go:embed prefix/as10099.txt
var as10099Data string
//go:embed prefix/as4837.txt //go:embed prefix/as4837.txt
var as4837Data string var as4837Data string
@ -35,6 +38,7 @@ var asnPrefixes = map[string][]string{
"AS4809": strings.Split(as4809Data, "\n"), // 电信 CN2 GT/GIA "AS4809": strings.Split(as4809Data, "\n"), // 电信 CN2 GT/GIA
"AS4134": strings.Split(as4134Data, "\n"), // 电信 163 骨干网 "AS4134": strings.Split(as4134Data, "\n"), // 电信 163 骨干网
"AS9929": strings.Split(as9929Data, "\n"), // 联通 9929 优质国际线路 "AS9929": strings.Split(as9929Data, "\n"), // 联通 9929 优质国际线路
"AS10099": strings.Split(as10099Data, "\n"), // 联通 CUG 国际网络
"AS4837": strings.Split(as4837Data, "\n"), // 联通 AS4837 普通国际线路 "AS4837": strings.Split(as4837Data, "\n"), // 联通 AS4837 普通国际线路
"AS58807": strings.Split(as58807Data, "\n"), // 移动 CMIN2 国际精品网 "AS58807": strings.Split(as58807Data, "\n"), // 移动 CMIN2 国际精品网
"AS9808": strings.Split(as9808Data, "\n"), // 移动 CMI中国移动国际公司 "AS9808": strings.Split(as9808Data, "\n"), // 移动 CMI中国移动国际公司
@ -45,6 +49,12 @@ var asnPrefixes = map[string][]string{
// 判断 IPv6 地址是否匹配 ASN 中的某个前缀 // 判断 IPv6 地址是否匹配 ASN 中的某个前缀
func ipv6Asn(ip string) string { func ipv6Asn(ip string) string {
ip = strings.ToLower(ip) ip = strings.ToLower(ip)
address, addressErr := netip.ParseAddr(ip)
if addressErr != nil || !address.Is6() {
return ""
}
bestASN := ""
bestSpecificity := -1
for asn, prefixes := range currentASNPrefixes() { for asn, prefixes := range currentASNPrefixes() {
for _, prefix := range prefixes { for _, prefix := range prefixes {
prefix = strings.TrimSpace(prefix) prefix = strings.TrimSpace(prefix)
@ -52,17 +62,35 @@ func ipv6Asn(ip string) string {
continue continue
} }
if strings.Contains(prefix, "/") { if strings.Contains(prefix, "/") {
address, addressErr := netip.ParseAddr(ip)
network, networkErr := netip.ParsePrefix(prefix) network, networkErr := netip.ParsePrefix(prefix)
if addressErr == nil && networkErr == nil && network.Contains(address) { if networkErr == nil && network.Contains(address) &&
return asn (network.Bits() > bestSpecificity || (network.Bits() == bestSpecificity && asn < bestASN)) {
bestASN = asn
bestSpecificity = network.Bits()
} }
continue continue
} }
if strings.HasPrefix(ip, prefix) { 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
} }

10
bk/prefix/as10099.txt Normal file
View File

@ -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

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as10099.txt",
"count": 10,
"sha256": "7161aad3d31fb89f43234e03ed95c63c51e2ef5c4d49c9223126d01384fe7cba",
"generated_at": "2026-07-30T15:51:08Z"
}

View File

@ -3,5 +3,5 @@
"file": "as4134.txt", "file": "as4134.txt",
"count": 629, "count": 629,
"sha256": "00941f76c022566166b926670ee9358084c53465cbaaf75a3da7b73d997d1947", "sha256": "00941f76c022566166b926670ee9358084c53465cbaaf75a3da7b73d997d1947",
"generated_at": "2026-07-23T02:56:31Z" "generated_at": "2026-07-30T15:51:08Z"
} }

View File

@ -108,6 +108,7 @@
240e:650:8000::/38 240e:650:8000::/38
240e:659:f100::/48 240e:659:f100::/48
240e:65f:100::/48 240e:65f:100::/48
240e:65f:800::/48
240e:65f:e000::/36 240e:65f:e000::/36
240e:669:b000::/40 240e:669:b000::/40
240e:670:d000::/36 240e:670:d000::/36
@ -138,6 +139,8 @@
240e:767:f000::/48 240e:767:f000::/48
240e:787:7000::/48 240e:787:7000::/48
240e:787:7001::/48 240e:787:7001::/48
240e:790:fe10::/48
240e:790:fe20::/48
240e:790:ff00::/40 240e:790:ff00::/40
240e:7b6::/31 240e:7b6::/31
240e:965:822::/48 240e:965:822::/48

View File

@ -1,7 +1,7 @@
{ {
"schema": "backtrace.asn-prefixes/v1", "schema": "backtrace.asn-prefixes/v1",
"file": "as4809.txt", "file": "as4809.txt",
"count": 185, "count": 188,
"sha256": "943e26cef20a0a7faba55c98825d89933a597b885ab01766ce2d1b0a58f9c8f0", "sha256": "a43638fa67e5057d237988233101ef0bfd4f5557001325289e0846af642670ae",
"generated_at": "2026-07-30T02:34:19Z" "generated_at": "2026-07-30T15:51:08Z"
} }

View File

@ -3,5 +3,5 @@
"file": "as4837.txt", "file": "as4837.txt",
"count": 492, "count": 492,
"sha256": "40ece28148fc843764a9b1cec48d07013d6e6fdc8697b601af93a45093e1908c", "sha256": "40ece28148fc843764a9b1cec48d07013d6e6fdc8697b601af93a45093e1908c",
"generated_at": "2026-07-29T02:49:32Z" "generated_at": "2026-07-30T15:51:08Z"
} }

View File

@ -3,5 +3,5 @@
"file": "as9808.txt", "file": "as9808.txt",
"count": 7171, "count": 7171,
"sha256": "ba623bf5d30f2c69340ffdd2c4d50bfc79506b00ebdbc8a2f0448b7ce21148e1", "sha256": "ba623bf5d30f2c69340ffdd2c4d50bfc79506b00ebdbc8a2f0448b7ce21148e1",
"generated_at": "2026-07-30T02:34:19Z" "generated_at": "2026-07-30T15:51:08Z"
} }

View File

@ -26,7 +26,7 @@ const (
ASNPrefixRegistryCDNBaseURL = "https://cdn.spiritlhl.net/" + ASNPrefixRegistryRawBaseURL 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:]+$`) var prefixFragmentPattern = regexp.MustCompile(`^[0-9a-fA-F:]+$`)
type ASNPrefixRegistrySource struct { type ASNPrefixRegistrySource struct {
@ -226,6 +226,8 @@ func validateEmbeddedPrefixManifests() error {
switch asn { switch asn {
case "AS23764": case "AS23764":
snapshot = []byte(as23764Data) snapshot = []byte(as23764Data)
case "AS10099":
snapshot = []byte(as10099Data)
case "AS4134": case "AS4134":
snapshot = []byte(as4134Data) snapshot = []byte(as4134Data)
case "AS4809": case "AS4809":

238
bk/route_classification.go Normal file
View File

@ -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,
}
}

View File

@ -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)
}
}

40
bk/route_render.go Normal file
View File

@ -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")
}

441
bk/route_report.go Normal file
View File

@ -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)
}

132
bk/route_report_test.go Normal file
View File

@ -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)
}
}

View File

@ -544,6 +544,15 @@ func (h *Hop) Add(r *Reply) *Node {
// Trace is a simple traceroute tool using DefaultTracer. // Trace is a simple traceroute tool using DefaultTracer.
func Trace(ip net.IP) ([]*Hop, error) { 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) hops := make([]*Hop, 0, DefaultTracer.MaxHops)
touch := func(dist int) *Hop { touch := func(dist int) *Hop {
for _, h := range hops { for _, h := range hops {
@ -555,7 +564,7 @@ func Trace(ip net.IP) ([]*Hop, error) {
hops = append(hops, h) hops = append(hops, h)
return 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) touch(r.Hops).Add(r)
}) })
if err != nil && err != context.DeadlineExceeded { if err != nil && err != context.DeadlineExceeded {

View File

@ -1,6 +1,7 @@
package backtrace package backtrace
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -35,32 +36,43 @@ func removeDuplicates(elements []string) []string {
// checkCdn 检查CDN可用性参考shell脚本的测试逻辑 // checkCdn 检查CDN可用性参考shell脚本的测试逻辑
func checkCdn(testUrl string) string { 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 := req.C()
client.SetTimeout(6 * time.Second) client.SetTimeout(6 * time.Second)
if model.EnableLoger { if model.EnableLoger {
InitLogger() InitLogger()
defer Logger.Sync() defer Logger.Sync()
} }
for _, cdnUrl := range model.CdnList { for index, cdnUrl := range model.CdnList {
url := cdnUrl + testUrl url := cdnUrl + testUrl
if model.EnableLoger { 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 { if err == nil && resp != nil && resp.Body != nil {
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
if err == nil && strings.Contains(string(b), "success") { if err == nil && strings.Contains(string(b), "success") {
if model.EnableLoger { if model.EnableLoger {
Logger.Info(fmt.Sprintf("CDN available: %s", cdnUrl)) Logger.Info(fmt.Sprintf("CDN source %d available", index+1))
} }
return cdnUrl return cdnUrl
} }
} }
if model.EnableLoger { 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 { if model.EnableLoger {
Logger.Info("No CDN available, using direct connection") Logger.Info("No CDN available, using direct connection")
@ -70,12 +82,15 @@ func checkCdn(testUrl string) string {
// getData 获取目标地址的文本内容 // getData 获取目标地址的文本内容
func getData(endpoint string) string { 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 := req.C()
client.SetTimeout(6 * time.Second) client.SetTimeout(6 * time.Second)
client.R().
SetRetryCount(2).
SetRetryBackoffInterval(1*time.Second, 5*time.Second).
SetRetryFixedInterval(2 * time.Second)
if model.EnableLoger { if model.EnableLoger {
InitLogger() InitLogger()
defer Logger.Sync() defer Logger.Sync()
@ -83,15 +98,19 @@ func getData(endpoint string) string {
// 先测试CDN可用性 // 先测试CDN可用性
testUrl := "https://raw.githubusercontent.com/spiritLHLS/ecs/main/back/test" testUrl := "https://raw.githubusercontent.com/spiritLHLS/ecs/main/back/test"
cdnUrl := checkCdn(testUrl) cdnUrl := checkCdnContext(ctx, testUrl)
// 如果有可用的CDN使用CDN获取数据 // 如果有可用的CDN使用CDN获取数据
if cdnUrl != "" { if cdnUrl != "" {
url := cdnUrl + endpoint url := cdnUrl + endpoint
if model.EnableLoger { 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 { if err == nil && resp != nil && resp.Body != nil {
defer resp.Body.Close() defer resp.Body.Close()
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
@ -103,15 +122,19 @@ func getData(endpoint string) string {
} }
} }
if model.EnableLoger { if model.EnableLoger {
Logger.Info(fmt.Sprintf("CDN request failed: %v", err)) Logger.Info("CDN data request failed")
} }
} }
// CDN不可用尝试直连 // CDN不可用尝试直连
if model.EnableLoger { 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 { if err == nil && resp != nil && resp.Body != nil {
defer resp.Body.Close() defer resp.Body.Close()
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
@ -123,7 +146,7 @@ func getData(endpoint string) string {
} }
} }
if model.EnableLoger { if model.EnableLoger {
Logger.Info(fmt.Sprintf("Direct connection failed: %v", err)) Logger.Info("Direct data request failed")
} }
return "" return ""
} }

View File

@ -40,7 +40,9 @@ type cliOptions struct {
help bool help bool
ipv6 bool ipv6 bool
jsonOutput bool jsonOutput bool
routeJSON bool
deep bool deep bool
routeTries int
specifiedIP string specifiedIP string
timeout time.Duration timeout time.Duration
} }
@ -55,8 +57,10 @@ func newBacktraceFlagSet(options *cliOptions) *flag.FlagSet {
set.StringVar(&options.specifiedIP, "ip", "", "Specify IP address for bgptools") 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, "json", false, "Output structured RDAP/BGP report as JSON")
set.BoolVar(&options.jsonOutput, "structured", false, "Alias for -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.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 return set
} }
@ -83,7 +87,7 @@ func main() {
if err := backtraceFlag.Parse(os.Args[1:]); err != nil { if err := backtraceFlag.Parse(os.Args[1:]); err != nil {
os.Exit(2) os.Exit(2)
} }
if !options.jsonOutput { if !options.jsonOutput && !options.routeJSON {
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace")) fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
} }
if options.help { if options.help {
@ -114,6 +118,18 @@ func main() {
} }
return 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{} info := IpInfo{}
if options.showIPInfo { if options.showIPInfo {
rsp, err := http.Get("http://ipinfo.io") rsp, err := http.Get("http://ipinfo.io")
@ -200,9 +216,21 @@ func main() {
} }
func validateStructuredOptions(options cliOptions) error { 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 { if options.deep && !options.jsonOutput {
return fmt.Errorf("-deep requires -json or -structured") 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 { if !options.jsonOutput {
return nil return nil
} }
@ -215,6 +243,12 @@ func validateStructuredOptions(options cliOptions) error {
return nil 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 { 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) report, err := query(ctx, ip, config)
if err != nil { if err != nil {

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/oneclickvirt/backtrace/bgptools" "github.com/oneclickvirt/backtrace/bgptools"
backtrace "github.com/oneclickvirt/backtrace/bk"
) )
func TestBacktraceStructuredFlagParsing(t *testing.T) { func TestBacktraceStructuredFlagParsing(t *testing.T) {
@ -26,11 +27,53 @@ func TestBacktraceDefaultsPreserveLegacyMode(t *testing.T) {
if err := newBacktraceFlagSet(&options).Parse(nil); err != nil { if err := newBacktraceFlagSet(&options).Parse(nil); err != nil {
t.Fatal(err) 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) 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) { func TestValidateStructuredOptionsRequiresExplicitModeAndTarget(t *testing.T) {
tests := []cliOptions{ tests := []cliOptions{
{deep: true, timeout: time.Second}, {deep: true, timeout: time.Second},

View File

@ -20,7 +20,7 @@ import (
"time" "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") var errPrefixCountDrop = errors.New("prefix count dropped beyond protection threshold")
type updateConfig struct { type updateConfig struct {

View File

@ -2,7 +2,7 @@ package model
import "time" import "time"
const BackTraceVersion = "v0.0.17" const BackTraceVersion = "v0.0.18"
var EnableLoger = false var EnableLoger = false
@ -68,6 +68,7 @@ var (
"AS4809": "电信CN2 [优质线路]", "AS4809": "电信CN2 [优质线路]",
"AS4134": "电信163 [普通线路]", "AS4134": "电信163 [普通线路]",
"AS9929": "联通9929 [优质线路]", "AS9929": "联通9929 [优质线路]",
"AS10099": "联通CUG [优质线路]",
"AS4837": "联通4837 [普通线路]", "AS4837": "联通4837 [普通线路]",
"AS58807": "移动CMIN2 [精品线路]", "AS58807": "移动CMIN2 [精品线路]",
"AS9808": "移动CMI [普通线路]", "AS9808": "移动CMI [普通线路]",