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
|
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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
|
//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
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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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.
|
// 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 {
|
||||||
|
|||||||
57
bk/utils.go
57
bk/utils.go
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
38
cmd/main.go
38
cmd/main.go
@ -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 {
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 [普通线路]",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user