feat: extend diagnostics and harden output
Some checks are pending
Sync ASN metadata / sync (push) Waiting to run

This commit is contained in:
spiritlhl 2026-07-22 10:30:57 +08:00
parent 46e20f680e
commit f0ba5fa410
8 changed files with 124 additions and 17 deletions

View File

@ -168,13 +168,16 @@ func validateASNMetadataManifest(data, snapshot []byte, count int, generatedAt t
func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL string) ([]byte, error) { func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil)
if err != nil { if err != nil {
return nil, err return nil, errors.New("create ASN metadata request failed")
} }
request.Header.Set("Accept", "application/json") request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/1") request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/1")
response, err := client.Do(request) response, err := client.Do(request)
if err != nil { if err != nil {
return nil, err if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, errors.New("ASN metadata request failed")
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
@ -183,7 +186,7 @@ func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL stri
const maximumSize = 4 << 20 const maximumSize = 4 << 20
data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1)) data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1))
if err != nil { if err != nil {
return nil, err return nil, errors.New("ASN metadata response read failed")
} }
if len(data) > maximumSize { if len(data) > maximumSize {
return nil, fmt.Errorf("ASN metadata exceeds %d bytes", maximumSize) return nil, fmt.Errorf("ASN metadata exceeds %d bytes", maximumSize)

View File

@ -53,19 +53,22 @@ func ResolveOriginASNWithConfig(parent context.Context, ip string, config Origin
} }
requestURL, err := addQuery(config.BaseURL, "resource", parsed.String()) requestURL, err := addQuery(config.BaseURL, "resource", parsed.String())
if err != nil { if err != nil {
return "", err return "", errors.New("invalid origin ASN source")
} }
ctx, cancel := context.WithTimeout(parent, config.Timeout) ctx, cancel := context.WithTimeout(parent, config.Timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil { if err != nil {
return "", err return "", errors.New("create origin ASN request failed")
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1") req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1")
response, err := config.Client.Do(req) response, err := config.Client.Do(req)
if err != nil { if err != nil {
return "", err if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
return "", errors.New("origin ASN request failed")
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests { if response.StatusCode == http.StatusTooManyRequests {
@ -76,7 +79,7 @@ func ResolveOriginASNWithConfig(parent context.Context, ip string, config Origin
} }
body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1)) body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1))
if err != nil { if err != nil {
return "", err return "", errors.New("origin ASN response read failed")
} }
if int64(len(body)) > config.MaxResponseSize { if int64(len(body)) > config.MaxResponseSize {
return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize) return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize)

47
bgptools/privacy_test.go Normal file
View File

@ -0,0 +1,47 @@
package bgptools
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
type privacyRoundTripper func(*http.Request) (*http.Response, error)
func (fn privacyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestRemoteFetchErrorsDoNotExposeSourceURL(t *testing.T) {
client := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) {
return nil, errors.New("dial " + request.URL.String())
})}
tests := []struct {
name string
run func() error
}{
{name: "ASN metadata", run: func() error {
_, err := fetchASNMetadata(context.Background(), client, "https://private.example/asn?token=secret")
return err
}},
{name: "RDAP", run: func() error {
_, err := QueryRDAP(context.Background(), "192.0.2.1", client, "https://private.example/rdap?token=secret")
return err
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.run()
if err == nil {
t.Fatal("expected request failure")
}
for _, forbidden := range []string{"private.example", "token=", "secret"} {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("error leaked %q: %v", forbidden, err)
}
}
})
}
}

View File

@ -60,13 +60,16 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri
requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String() requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil { if err != nil {
return RDAPRecord{}, err return RDAPRecord{}, errors.New("create RDAP request failed")
} }
req.Header.Set("Accept", "application/rdap+json, application/json") req.Header.Set("Accept", "application/rdap+json, application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1") req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1")
response, err := client.Do(req) response, err := client.Do(req)
if err != nil { if err != nil {
return RDAPRecord{}, err if ctxErr := ctx.Err(); ctxErr != nil {
return RDAPRecord{}, ctxErr
}
return RDAPRecord{}, errors.New("RDAP request failed")
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests { if response.StatusCode == http.StatusTooManyRequests {
@ -77,7 +80,7 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri
} }
data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1)) data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
if err != nil { if err != nil {
return RDAPRecord{}, err return RDAPRecord{}, errors.New("RDAP response read failed")
} }
if len(data) > 4<<20 { if len(data) > 4<<20 {
return RDAPRecord{}, ErrRDAPResponseTooLarge return RDAPRecord{}, ErrRDAPResponseTooLarge

View File

@ -96,7 +96,7 @@ func main() {
return return
} }
if err := validateStructuredOptions(options); err != nil { if err := validateStructuredOptions(options); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, sanitizeErrorText(err.Error()))
os.Exit(2) os.Exit(2)
} }
if options.jsonOutput { if options.jsonOutput {
@ -109,7 +109,7 @@ func main() {
}, },
} }
if err := writeStructuredReport(context.Background(), os.Stdout, options.specifiedIP, config, bgptools.QueryIPBGPReport); err != nil { if err := writeStructuredReport(context.Background(), os.Stdout, options.specifiedIP, config, bgptools.QueryIPBGPReport); err != nil {
fmt.Fprintf(os.Stderr, "structured report failed: %v\n", err) fmt.Fprintf(os.Stderr, "structured report failed: %s\n", sanitizeErrorText(err.Error()))
os.Exit(1) os.Exit(1)
} }
return return
@ -118,12 +118,12 @@ func main() {
if options.showIPInfo { if options.showIPInfo {
rsp, err := http.Get("http://ipinfo.io") rsp, err := http.Get("http://ipinfo.io")
if err != nil { if err != nil {
fmt.Printf("get ip info err %v \n", err.Error()) fmt.Printf("get ip info err %s \n", sanitizeErrorText(err.Error()))
} else { } else {
defer rsp.Body.Close() defer rsp.Body.Close()
err = json.NewDecoder(rsp.Body).Decode(&info) err = json.NewDecoder(rsp.Body).Decode(&info)
if err != nil { if err != nil {
fmt.Printf("json decode err %v \n", err.Error()) fmt.Printf("json decode err %s \n", sanitizeErrorText(err.Error()))
} else { } else {
fmt.Println(Green("国家: ") + White(info.Country) + Green(" 城市: ") + White(info.City) + fmt.Println(Green("国家: ") + White(info.Country) + Green(" 城市: ") + White(info.City) +
Green(" 服务商: ") + Blue(info.Org)) Green(" 服务商: ") + Blue(info.Org))
@ -186,10 +186,10 @@ func main() {
}) })
wg.Wait() wg.Wait()
if results.bgpResult != "" { if results.bgpResult != "" {
fmt.Print(results.bgpResult) fmt.Print(indentLegacyOutput(results.bgpResult))
} }
if results.backtraceResult != "" { if results.backtraceResult != "" {
fmt.Printf("%s\n", results.backtraceResult) fmt.Printf("%s\n", indentLegacyOutput(results.backtraceResult))
} }
fmt.Println(Yellow("准确线路自行查看详细路由,本测试结果仅作参考")) fmt.Println(Yellow("准确线路自行查看详细路由,本测试结果仅作参考"))
fmt.Println(Yellow("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效")) fmt.Println(Yellow("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效"))

27
cmd/output.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"regexp"
"strings"
)
var privateErrorPattern = regexp.MustCompile("(?i)(https?://)[^\\s]+")
var privateTokenPattern = regexp.MustCompile("(?i)(^|[?&\\s])(token|api[_-]?key|secret|authorization|password|key)=([^&\\s]+)")
var privatePathPattern = regexp.MustCompile("(?:[A-Za-z]:\\\\|/)[^\\s]+")
func indentLegacyOutput(value string) string {
lines := strings.Split(value, "\n")
for index, line := range lines {
if line != "" && line[0] != ' ' && line[0] != '\t' {
lines[index] = " " + line
}
}
return strings.Join(lines, "\n")
}
// sanitizeErrorText keeps remote-source and credential details out of user-visible errors.
func sanitizeErrorText(value string) string {
value = privateErrorPattern.ReplaceAllString(value, "<remote>")
value = privateTokenPattern.ReplaceAllString(value, "$1$2=<redacted>")
return privatePathPattern.ReplaceAllString(value, "<path>")
}

24
cmd/output_test.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
"strings"
"testing"
)
func TestIndentLegacyOutputLeavesOneLeadingCell(t *testing.T) {
got := indentLegacyOutput("alpha\n beta\n\tgamma\n")
want := " alpha\n beta\n\tgamma\n"
if got != want {
t.Fatalf("indentLegacyOutput() = %q, want %q", got, want)
}
}
func TestSanitizeErrorTextRedactsRemoteSecretsAndPaths(t *testing.T) {
input := "load https://private.example/repo/data?token=secret from /private/cache failed: api_key=hidden"
got := sanitizeErrorText(input)
for _, forbidden := range []string{"private.example", "secret", "hidden", "/private/cache"} {
if strings.Contains(got, forbidden) {
t.Fatalf("sanitizeErrorText() leaked %q in %q", forbidden, got)
}
}
}

View File

@ -2,7 +2,7 @@ package model
import "time" import "time"
const BackTraceVersion = "v0.0.15" const BackTraceVersion = "v0.0.16"
var EnableLoger = false var EnableLoger = false