mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-08-13 12:19:16 +08:00
feat: strengthen three-carrier return route detection
Some checks failed
Sync ASN metadata / sync (push) Has been cancelled
Some checks failed
Sync ASN metadata / sync (push) Has been cancelled
This commit is contained in:
parent
c350b4e1f8
commit
5369aa16f7
101
bk/backtrace.go
101
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
31
bk/ipv4_asn_test.go
Normal file
31
bk/ipv4_asn_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
10
bk/prefix/as10099.txt
Normal file
10
bk/prefix/as10099.txt
Normal 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
|
||||
7
bk/prefix/as10099.txt.manifest.json
Normal file
7
bk/prefix/as10099.txt.manifest.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as10099.txt",
|
||||
"count": 10,
|
||||
"sha256": "7161aad3d31fb89f43234e03ed95c63c51e2ef5c4d49c9223126d01384fe7cba",
|
||||
"generated_at": "2026-07-30T15:51:08Z"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
238
bk/route_classification.go
Normal file
238
bk/route_classification.go
Normal 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,
|
||||
}
|
||||
}
|
||||
74
bk/route_classification_test.go
Normal file
74
bk/route_classification_test.go
Normal 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
40
bk/route_render.go
Normal 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
441
bk/route_report.go
Normal 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
132
bk/route_report_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
57
bk/utils.go
57
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 ""
|
||||
}
|
||||
|
||||
38
cmd/main.go
38
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 {
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 [普通线路]",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user