backtrace/cmd/main.go
spiritlhl 16e551b631
Some checks failed
CI / test (push) Waiting to run
Sync ASN metadata / sync (push) Has been cancelled
feat(dns): prepare backtrace v0.0.21
2026-08-26 15:37:44 +08:00

318 lines
9.6 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/oneclickvirt/backtrace/bgptools"
backtrace "github.com/oneclickvirt/backtrace/bk"
"github.com/oneclickvirt/backtrace/model"
"github.com/oneclickvirt/backtrace/utils"
"github.com/oneclickvirt/basics/network/resolver"
. "github.com/oneclickvirt/defaultset"
)
type IpInfo struct {
Ip string `json:"ip"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
Org string `json:"org"`
}
type ConcurrentResults struct {
bgpResult string
backtraceResult string
bgpError error
// backtraceError error
}
type cliOptions struct {
showVersion bool
showIPInfo bool
help bool
ipv6 bool
jsonOutput bool
routeJSON bool
deep bool
routeTries int
specifiedIP string
dnsMode string
timeout time.Duration
}
var (
backtraceDNSConfigureFn = resolver.Configure
backtraceDNSShutdownFn = resolver.Shutdown
backtraceDNSBootstrapReachableFn = resolver.BootstrapReachable
)
func newBacktraceFlagSet(options *cliOptions) *flag.FlagSet {
set := flag.NewFlagSet("backtrace", flag.ContinueOnError)
set.BoolVar(&options.help, "h", false, "Show help information")
set.BoolVar(&options.showVersion, "v", false, "Show version")
set.BoolVar(&options.showIPInfo, "s", true, "Disabe show ip info")
set.BoolVar(&model.EnableLoger, "log", false, "Enable logging")
set.BoolVar(&options.ipv6, "ipv6", false, "Enable ipv6 testing")
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.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")
set.StringVar(&options.dnsMode, "dns-mode", "auto", "DNS mode (auto, system, doh, or dot)")
return set
}
func safeGo(wg *sync.WaitGroup, fn func()) {
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
}
}()
fn()
}()
}
func configureBacktraceResolver(ctx context.Context, mode resolver.Mode, preCheck *utils.NetCheckResult) resolver.Status {
requested := resolver.ParseMode(string(mode))
config := resolver.Config{Mode: requested}
status := resolver.Status{Requested: requested, Active: resolver.ModeUnavailable, Reason: "network unavailable"}
if preCheck == nil || preCheck.Connected || requested == resolver.ModeDoH || requested == resolver.ModeDoT {
return backtraceDNSConfigureFn(ctx, config)
}
if requested == resolver.ModeAuto {
if _, reachable := backtraceDNSBootstrapReachableFn(ctx, config); reachable {
return backtraceDNSConfigureFn(ctx, config)
}
status.Reason = "encrypted DNS endpoint unreachable"
}
backtraceDNSShutdownFn()
return status
}
func promoteEncryptedDNSConnectivity(preCheck *utils.NetCheckResult, status resolver.Status) {
if preCheck == nil || preCheck.Connected || (status.Active != resolver.ModeDoH && status.Active != resolver.ModeDoT) {
return
}
preCheck.Connected = true
switch status.Stack {
case "IPv4":
preCheck.HasIPv4 = true
case "IPv6":
preCheck.HasIPv6 = true
}
if status.Stack != "" {
preCheck.StackType = status.Stack
}
}
func main() {
var options cliOptions
backtraceFlag := newBacktraceFlagSet(&options)
if err := backtraceFlag.Parse(os.Args[1:]); err != nil {
os.Exit(2)
}
if !options.jsonOutput && !options.routeJSON {
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
}
if options.help {
fmt.Printf("Usage: %s [options]\n", os.Args[0])
backtraceFlag.PrintDefaults()
return
}
if options.showVersion {
fmt.Println(model.BackTraceVersion)
return
}
options.dnsMode = strings.ToLower(strings.TrimSpace(options.dnsMode))
if options.dnsMode != "auto" && options.dnsMode != "system" && options.dnsMode != "doh" && options.dnsMode != "dot" {
fmt.Fprintln(os.Stderr, "dns-mode must be auto, system, doh, or dot")
os.Exit(2)
}
if err := validateStructuredOptions(options); err != nil {
fmt.Fprintln(os.Stderr, sanitizeErrorText(err.Error()))
os.Exit(2)
}
var preCheck *utils.NetCheckResult
if !options.jsonOutput && !options.routeJSON {
check := utils.CheckPublicAccess(3 * time.Second)
preCheck = &check
}
status := configureBacktraceResolver(context.Background(), resolver.ParseMode(options.dnsMode), preCheck)
promoteEncryptedDNSConnectivity(preCheck, status)
if status.Active == resolver.ModeDoH || status.Active == resolver.ModeDoT {
defer backtraceDNSShutdownFn()
}
go func() {
resp, err := http.Get("https://hits.spiritlhl.net/backtrace.svg?action=hit&title=Hits&title_bg=%23555555&count_bg=%230eecf8&edge_flat=false")
if err == nil && resp != nil && resp.Body != nil {
resp.Body.Close()
}
}()
if options.jsonOutput {
config := bgptools.IPBGPReportConfig{
Timeout: options.timeout,
FetchGeofeed: options.deep,
EnableWHOISFallback: options.deep,
ResolveASN: func(ctx context.Context, ip string) (string, error) {
return bgptools.ResolveOriginASNWithConfig(ctx, ip, bgptools.OriginASNConfig{Timeout: options.timeout})
},
}
if err := writeStructuredReport(context.Background(), os.Stdout, options.specifiedIP, config, bgptools.QueryIPBGPReport); err != nil {
fmt.Fprintf(os.Stderr, "structured report failed: %s\n", sanitizeErrorText(err.Error()))
os.Exit(1)
}
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")
if err != nil {
fmt.Printf("get ip info err %s \n", sanitizeErrorText(err.Error()))
} else {
defer rsp.Body.Close()
err = json.NewDecoder(rsp.Body).Decode(&info)
if err != nil {
fmt.Printf("json decode err %s \n", sanitizeErrorText(err.Error()))
} else {
fmt.Println(Green("国家: ") + White(info.Country) + Green(" 城市: ") + White(info.City) +
Green(" 服务商: ") + Blue(info.Org))
}
}
}
if preCheck == nil || !preCheck.Connected {
fmt.Println(Red("PreCheck IP Type Failed"))
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
fmt.Println("Press Enter to exit...")
fmt.Scanln()
}
return
}
var useIPv6 bool
switch preCheck.StackType {
case "DualStack":
useIPv6 = options.ipv6
case "IPv4":
useIPv6 = false
case "IPv6":
useIPv6 = true
default:
fmt.Println(Red("PreCheck IP Type Failed"))
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
fmt.Println("Press Enter to exit...")
fmt.Scanln()
}
return
}
results := ConcurrentResults{}
var wg sync.WaitGroup
var targetIP string
if options.specifiedIP != "" {
targetIP = options.specifiedIP
} else if info.Ip != "" {
targetIP = info.Ip
}
if targetIP != "" {
wg.Add(1)
safeGo(&wg, func() {
for i := 0; i < 2; i++ {
result, err := bgptools.GetPoPInfo(targetIP)
results.bgpError = err
if err == nil && result.Result != "" {
results.bgpResult = result.Result
return
}
if i == 0 {
time.Sleep(3 * time.Second)
}
}
})
}
wg.Add(1)
safeGo(&wg, func() {
result := backtrace.BackTrace(useIPv6)
results.backtraceResult = result
})
wg.Wait()
if results.bgpResult != "" {
fmt.Print(indentLegacyOutput(results.bgpResult))
}
if results.backtraceResult != "" {
fmt.Printf("%s\n", indentLegacyOutput(results.backtraceResult))
}
fmt.Println(Yellow("准确线路自行查看详细路由,本测试结果仅作参考"))
fmt.Println(Yellow("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效"))
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
fmt.Println("Press Enter to exit...")
fmt.Scanln()
}
}
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
}
if options.specifiedIP == "" {
return fmt.Errorf("structured report requires -ip")
}
if options.timeout <= 0 {
return fmt.Errorf("structured report timeout must be positive")
}
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 {
return err
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
return encoder.Encode(report)
}