mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-07-23 11:20:13 +08:00
feat: refresh ASN prefixes with bounded Go loader
This commit is contained in:
parent
dd4fdcdaea
commit
a32a1067d3
33
.github/workflows/build.yml
vendored
33
.github/workflows/build.yml
vendored
@ -3,11 +3,18 @@ name: 创建IPv6检测的前缀
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * 0'
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: backtrace-ipv6-prefix-sync
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
fetch-ipv6-prefixes:
|
fetch-ipv6-prefixes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -17,26 +24,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: 设置 Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: 获取并处理多个ASN的IPv6前缀
|
- name: 获取并处理多个ASN的IPv6前缀
|
||||||
run: |
|
run: |
|
||||||
mkdir -p bk/prefix/
|
go run ./cmd/update-prefixes -output-dir bk/prefix
|
||||||
for asn in AS4809 AS4134 AS9929 AS4837 AS58807 AS9808 AS58453 AS23764; do
|
|
||||||
echo "处理 $asn..."
|
|
||||||
curl -s -A "Mozilla/5.0" \
|
|
||||||
"https://bgp.he.net/$asn" > "${asn}.html"
|
|
||||||
grep -oE '[0-9a-f:]+::/[0-9]+' "${asn}.html" | sort -u > tmp_prefixes.txt
|
|
||||||
{
|
|
||||||
while read prefix; do
|
|
||||||
ip_part=$(echo "$prefix" | cut -d/ -f1)
|
|
||||||
prefix_len=$(echo "$prefix" | cut -d/ -f2)
|
|
||||||
keep_segments=$((prefix_len / 16))
|
|
||||||
segments=$(echo "$ip_part" | tr ':' '\n' | grep -v '^$')
|
|
||||||
kept=$(echo "$segments" | head -n "$keep_segments" | tr '\n' ':' | sed 's/:$//')
|
|
||||||
echo "$kept"
|
|
||||||
done < tmp_prefixes.txt
|
|
||||||
} | sort -u > "bk/prefix/${asn,,}.txt"
|
|
||||||
rm -f "${asn}.html" tmp_prefixes.txt
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: 提交更新到仓库
|
- name: 提交更新到仓库
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@ -16,6 +16,7 @@ func safeTraceCall(fn func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BackTrace(enableIpv6 bool) string {
|
func BackTrace(enableIpv6 bool) string {
|
||||||
|
StartASNPrefixRefresh()
|
||||||
if model.CachedIcmpData == "" || model.ParsedIcmpTargets == nil || time.Since(model.CachedIcmpDataFetchTime) > time.Hour {
|
if model.CachedIcmpData == "" || model.ParsedIcmpTargets == nil || time.Since(model.CachedIcmpDataFetchTime) > time.Hour {
|
||||||
model.CachedIcmpData = getData(model.IcmpTargets)
|
model.CachedIcmpData = getData(model.IcmpTargets)
|
||||||
model.CachedIcmpDataFetchTime = time.Now()
|
model.CachedIcmpDataFetchTime = time.Now()
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
package backtrace
|
package backtrace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed prefix/as4809.txt
|
//go:embed prefix/as4809.txt
|
||||||
@ -44,12 +45,20 @@ 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)
|
||||||
for asn, prefixes := range asnPrefixes {
|
for asn, prefixes := range currentASNPrefixes() {
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.Contains(prefix, "/") {
|
||||||
|
address, addressErr := netip.ParseAddr(ip)
|
||||||
|
network, networkErr := netip.ParsePrefix(prefix)
|
||||||
|
if addressErr == nil && networkErr == nil && network.Contains(address) {
|
||||||
|
return asn
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.HasPrefix(ip, prefix) {
|
if strings.HasPrefix(ip, prefix) {
|
||||||
return asn
|
return asn
|
||||||
}
|
}
|
||||||
|
|||||||
194
bk/prefix_registry.go
Normal file
194
bk/prefix_registry.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package backtrace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ASNPrefixRegistryRawBaseURL = "https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bk/prefix"
|
||||||
|
ASNPrefixRegistryCDNBaseURL = "https://cdn.spiritlhl.net/" + ASNPrefixRegistryRawBaseURL
|
||||||
|
)
|
||||||
|
|
||||||
|
var knownPrefixASNs = []string{"AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"}
|
||||||
|
var prefixFragmentPattern = regexp.MustCompile(`^[0-9a-fA-F:]+$`)
|
||||||
|
|
||||||
|
type ASNPrefixRegistrySource struct {
|
||||||
|
Name string
|
||||||
|
Base string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ASNPrefixRegistryLoadResult struct {
|
||||||
|
Prefixes map[string][]string
|
||||||
|
Source string
|
||||||
|
Fallback bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeASNPrefixes atomic.Value // stores map[string][]string; maps are immutable after publication
|
||||||
|
var refreshASNPrefixesOnce sync.Once
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
activeASNPrefixes.Store(cloneASNPrefixes(asnPrefixes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultASNPrefixRegistrySources() []ASNPrefixRegistrySource {
|
||||||
|
return []ASNPrefixRegistrySource{
|
||||||
|
{Name: "cdn", Base: ASNPrefixRegistryCDNBaseURL},
|
||||||
|
{Name: "raw", Base: ASNPrefixRegistryRawBaseURL},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadASNPrefixRegistry(ctx context.Context, client *http.Client, sources []ASNPrefixRegistrySource) (ASNPrefixRegistryLoadResult, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 6 * time.Second}
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for index, source := range sources {
|
||||||
|
prefixes, err := fetchASNPrefixSource(ctx, client, source.Base)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("load %s ASN prefixes: %w", source.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ASNPrefixRegistryLoadResult{Prefixes: prefixes, Source: source.Name, Fallback: index > 0}, nil
|
||||||
|
}
|
||||||
|
embedded := cloneASNPrefixes(asnPrefixes)
|
||||||
|
if len(embedded) > 0 {
|
||||||
|
return ASNPrefixRegistryLoadResult{Prefixes: embedded, Source: "embedded", Fallback: true}, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("no ASN prefix registry sources configured")
|
||||||
|
}
|
||||||
|
return ASNPrefixRegistryLoadResult{}, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshASNPrefixRegistry(ctx context.Context, client *http.Client, sources []ASNPrefixRegistrySource) (ASNPrefixRegistryLoadResult, error) {
|
||||||
|
loaded, err := LoadASNPrefixRegistry(ctx, client, sources)
|
||||||
|
if err != nil {
|
||||||
|
return ASNPrefixRegistryLoadResult{}, err
|
||||||
|
}
|
||||||
|
activeASNPrefixes.Store(cloneASNPrefixes(loaded.Prefixes))
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartASNPrefixRefresh performs one bounded background refresh. It never
|
||||||
|
// delays legacy tracing and the embedded map remains active on any error.
|
||||||
|
func StartASNPrefixRefresh() {
|
||||||
|
refreshASNPrefixesOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = RefreshASNPrefixRegistry(ctx, nil, DefaultASNPrefixRegistrySources())
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchASNPrefixSource(ctx context.Context, client *http.Client, base string) (map[string][]string, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimRight(base, "/"))
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||||
|
return nil, errors.New("invalid ASN prefix source")
|
||||||
|
}
|
||||||
|
type prefixResult struct {
|
||||||
|
asn string
|
||||||
|
values []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan prefixResult, len(knownPrefixASNs))
|
||||||
|
for _, asn := range knownPrefixASNs {
|
||||||
|
go func(asn string) {
|
||||||
|
endpoint := strings.TrimRight(base, "/") + "/" + strings.ToLower(asn) + ".txt"
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
results <- prefixResult{asn: asn, err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "text/plain")
|
||||||
|
request.Header.Set("User-Agent", "oneclickvirt-backtrace/asn-prefix-registry-v1")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
results <- prefixResult{asn: asn, err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
results <- prefixResult{asn: asn, err: readErr}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
results <- prefixResult{asn: asn, err: fmt.Errorf("HTTP %d", response.StatusCode)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values, parseErr := parseASNPrefixLines(data)
|
||||||
|
results <- prefixResult{asn: asn, values: values, err: parseErr}
|
||||||
|
}(asn)
|
||||||
|
}
|
||||||
|
result := make(map[string][]string, len(knownPrefixASNs))
|
||||||
|
for range knownPrefixASNs {
|
||||||
|
loaded := <-results
|
||||||
|
if loaded.err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", loaded.asn, loaded.err)
|
||||||
|
}
|
||||||
|
result[loaded.asn] = loaded.values
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseASNPrefixLines(data []byte) ([]string, error) {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
values := make([]string, 0)
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "/") {
|
||||||
|
prefix, err := netip.ParsePrefix(value)
|
||||||
|
if err != nil || !prefix.Addr().Is6() {
|
||||||
|
return nil, fmt.Errorf("invalid IPv6 prefix %q", value)
|
||||||
|
}
|
||||||
|
} else if !prefixFragmentPattern.MatchString(value) {
|
||||||
|
return nil, fmt.Errorf("invalid prefix fragment %q", value)
|
||||||
|
}
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
if _, exists := seen[value]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, errors.New("prefix file is empty")
|
||||||
|
}
|
||||||
|
sort.Strings(values)
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneASNPrefixes(input map[string][]string) map[string][]string {
|
||||||
|
result := make(map[string][]string, len(input))
|
||||||
|
for asn, values := range input {
|
||||||
|
result[asn] = append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentASNPrefixes() map[string][]string {
|
||||||
|
if value := activeASNPrefixes.Load(); value != nil {
|
||||||
|
return value.(map[string][]string)
|
||||||
|
}
|
||||||
|
return asnPrefixes
|
||||||
|
}
|
||||||
38
bk/prefix_registry_test.go
Normal file
38
bk/prefix_registry_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package backtrace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseASNPrefixLinesNormalizesAndDeduplicates(t *testing.T) {
|
||||||
|
values, err := parseASNPrefixLines([]byte("2409:8000\n2409:8000\n2401:db8::/32\n"))
|
||||||
|
if err != nil || len(values) != 2 || values[0] != "2401:db8::/32" {
|
||||||
|
t.Fatalf("unexpected prefixes: %#v, %v", values, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadASNPrefixRegistryUsesRawAfterCDNFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path == "/bad/as23764.txt" {
|
||||||
|
http.Error(writer, "bad", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte("2409:8000\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
sources := []ASNPrefixRegistrySource{{Name: "cdn", Base: server.URL + "/bad"}, {Name: "raw", Base: server.URL + "/good"}}
|
||||||
|
loaded, err := LoadASNPrefixRegistry(context.Background(), server.Client(), sources)
|
||||||
|
if err != nil || loaded.Source != "raw" || !loaded.Fallback || len(loaded.Prefixes) != len(knownPrefixASNs) {
|
||||||
|
t.Fatalf("unexpected load: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadASNPrefixRegistryEmbeddedFallback(t *testing.T) {
|
||||||
|
loaded, err := LoadASNPrefixRegistry(context.Background(), nil, nil)
|
||||||
|
if err != nil || loaded.Source != "embedded" || !loaded.Fallback || len(loaded.Prefixes) == 0 {
|
||||||
|
t.Fatalf("unexpected embedded load: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
cmd/main.go
102
cmd/main.go
@ -1,9 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@ -32,6 +34,32 @@ type ConcurrentResults struct {
|
|||||||
// backtraceError error
|
// backtraceError error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cliOptions struct {
|
||||||
|
showVersion bool
|
||||||
|
showIPInfo bool
|
||||||
|
help bool
|
||||||
|
ipv6 bool
|
||||||
|
jsonOutput bool
|
||||||
|
deep bool
|
||||||
|
specifiedIP string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
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.deep, "deep", false, "Fetch geofeed and enable WHOIS fallback")
|
||||||
|
set.DurationVar(&options.timeout, "timeout", 15*time.Second, "Structured report timeout")
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
func safeGo(wg *sync.WaitGroup, fn func()) {
|
func safeGo(wg *sync.WaitGroup, fn func()) {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@ -50,28 +78,44 @@ func main() {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
var options cliOptions
|
||||||
|
backtraceFlag := newBacktraceFlagSet(&options)
|
||||||
|
if err := backtraceFlag.Parse(os.Args[1:]); err != nil {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if !options.jsonOutput {
|
||||||
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
|
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
|
||||||
var showVersion, showIpInfo, help, ipv6 bool
|
}
|
||||||
var specifiedIP string
|
if options.help {
|
||||||
backtraceFlag := flag.NewFlagSet("backtrace", flag.ContinueOnError)
|
|
||||||
backtraceFlag.BoolVar(&help, "h", false, "Show help information")
|
|
||||||
backtraceFlag.BoolVar(&showVersion, "v", false, "Show version")
|
|
||||||
backtraceFlag.BoolVar(&showIpInfo, "s", true, "Disabe show ip info")
|
|
||||||
backtraceFlag.BoolVar(&model.EnableLoger, "log", false, "Enable logging")
|
|
||||||
backtraceFlag.BoolVar(&ipv6, "ipv6", false, "Enable ipv6 testing")
|
|
||||||
backtraceFlag.StringVar(&specifiedIP, "ip", "", "Specify IP address for bgptools")
|
|
||||||
backtraceFlag.Parse(os.Args[1:])
|
|
||||||
if help {
|
|
||||||
fmt.Printf("Usage: %s [options]\n", os.Args[0])
|
fmt.Printf("Usage: %s [options]\n", os.Args[0])
|
||||||
backtraceFlag.PrintDefaults()
|
backtraceFlag.PrintDefaults()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if showVersion {
|
if options.showVersion {
|
||||||
fmt.Println(model.BackTraceVersion)
|
fmt.Println(model.BackTraceVersion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateStructuredOptions(options); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
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: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
info := IpInfo{}
|
info := IpInfo{}
|
||||||
if 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 %v \n", err.Error())
|
||||||
@ -98,7 +142,7 @@ func main() {
|
|||||||
var useIPv6 bool
|
var useIPv6 bool
|
||||||
switch preCheck.StackType {
|
switch preCheck.StackType {
|
||||||
case "DualStack":
|
case "DualStack":
|
||||||
useIPv6 = ipv6
|
useIPv6 = options.ipv6
|
||||||
case "IPv4":
|
case "IPv4":
|
||||||
useIPv6 = false
|
useIPv6 = false
|
||||||
case "IPv6":
|
case "IPv6":
|
||||||
@ -114,8 +158,8 @@ func main() {
|
|||||||
results := ConcurrentResults{}
|
results := ConcurrentResults{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var targetIP string
|
var targetIP string
|
||||||
if specifiedIP != "" {
|
if options.specifiedIP != "" {
|
||||||
targetIP = specifiedIP
|
targetIP = options.specifiedIP
|
||||||
} else if info.Ip != "" {
|
} else if info.Ip != "" {
|
||||||
targetIP = info.Ip
|
targetIP = info.Ip
|
||||||
}
|
}
|
||||||
@ -154,3 +198,29 @@ func main() {
|
|||||||
fmt.Scanln()
|
fmt.Scanln()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateStructuredOptions(options cliOptions) error {
|
||||||
|
if options.deep && !options.jsonOutput {
|
||||||
|
return fmt.Errorf("-deep requires -json or -structured")
|
||||||
|
}
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|||||||
65
cmd/main_test.go
Normal file
65
cmd/main_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oneclickvirt/backtrace/bgptools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBacktraceStructuredFlagParsing(t *testing.T) {
|
||||||
|
var options cliOptions
|
||||||
|
set := newBacktraceFlagSet(&options)
|
||||||
|
if err := set.Parse([]string{"-structured", "-deep", "-ip", "192.0.2.1", "-timeout", "900ms"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !options.jsonOutput || !options.deep || options.specifiedIP != "192.0.2.1" || options.timeout != 900*time.Millisecond {
|
||||||
|
t.Fatalf("unexpected options: %+v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBacktraceDefaultsPreserveLegacyMode(t *testing.T) {
|
||||||
|
var options cliOptions
|
||||||
|
if err := newBacktraceFlagSet(&options).Parse(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !options.showIPInfo || options.jsonOutput || options.deep || options.ipv6 || options.specifiedIP != "" || options.timeout != 15*time.Second {
|
||||||
|
t.Fatalf("legacy defaults changed: %+v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStructuredOptionsRequiresExplicitModeAndTarget(t *testing.T) {
|
||||||
|
tests := []cliOptions{
|
||||||
|
{deep: true, timeout: time.Second},
|
||||||
|
{jsonOutput: true, timeout: time.Second},
|
||||||
|
{jsonOutput: true, specifiedIP: "192.0.2.1"},
|
||||||
|
}
|
||||||
|
for _, options := range tests {
|
||||||
|
if err := validateStructuredOptions(options); err == nil {
|
||||||
|
t.Fatalf("expected invalid structured options: %+v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateStructuredOptions(cliOptions{jsonOutput: true, deep: true, specifiedIP: "192.0.2.1", timeout: time.Second}); err != nil {
|
||||||
|
t.Fatalf("valid structured options rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStructuredReportKeepsStdoutJSONOnly(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := writeStructuredReport(context.Background(), &output, "192.0.2.1", bgptools.IPBGPReportConfig{Timeout: time.Second}, func(_ context.Context, ip string, _ bgptools.IPBGPReportConfig) (*bgptools.IPBGPReport, error) {
|
||||||
|
return &bgptools.IPBGPReport{IP: ip, Status: bgptools.ReportAvailable, Sources: []bgptools.IPBGPSourceStatus{}}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var report bgptools.IPBGPReport
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
|
||||||
|
t.Fatalf("stdout is not JSON: %v (%q)", err, output.String())
|
||||||
|
}
|
||||||
|
if report.IP != "192.0.2.1" {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
186
cmd/update-prefixes/main.go
Normal file
186
cmd/update-prefixes/main.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var knownASNs = []string{"AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"}
|
||||||
|
var errPrefixCountDrop = errors.New("prefix count dropped beyond protection threshold")
|
||||||
|
|
||||||
|
type updateConfig struct {
|
||||||
|
SourceBase string
|
||||||
|
OutputDir string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := updateConfig{}
|
||||||
|
flag.StringVar(&config.SourceBase, "source-base", "https://stat.ripe.net/data/announced-prefixes/data.json", "upstream announced-prefixes API")
|
||||||
|
flag.StringVar(&config.OutputDir, "output-dir", "bk/prefix", "prefix snapshot directory")
|
||||||
|
flag.DurationVar(&config.Timeout, "timeout", 30*time.Second, "upstream request timeout")
|
||||||
|
flag.Parse()
|
||||||
|
if err := updatePrefixes(context.Background(), http.DefaultClient, config); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePrefixes(ctx context.Context, client *http.Client, config updateConfig) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
if config.SourceBase == "" || config.OutputDir == "" || config.Timeout <= 0 {
|
||||||
|
return errors.New("source-base, output-dir, and timeout must be valid")
|
||||||
|
}
|
||||||
|
valuesByASN := make(map[string][]string, len(knownASNs))
|
||||||
|
for _, asn := range knownASNs {
|
||||||
|
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
|
||||||
|
endpoint, err := url.Parse(config.SourceBase)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("parse source URL: %w", err)
|
||||||
|
}
|
||||||
|
query := endpoint.Query()
|
||||||
|
query.Set("resource", asn)
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("User-Agent", "oneclickvirt-backtrace-prefix-sync/1")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("fetch %s: %w", asn, err)
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(response.Body, 8<<20))
|
||||||
|
response.Body.Close()
|
||||||
|
cancel()
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("fetch %s: HTTP %d", asn, response.StatusCode)
|
||||||
|
}
|
||||||
|
values, err := extractIPv6Prefixes(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse %s: %w", asn, err)
|
||||||
|
}
|
||||||
|
valuesByASN[asn] = values
|
||||||
|
}
|
||||||
|
for _, asn := range knownASNs {
|
||||||
|
if err := writePrefixFile(filepath.Join(config.OutputDir, strings.ToLower(asn)+".txt"), valuesByASN[asn]); err != nil {
|
||||||
|
if errors.Is(err, errPrefixCountDrop) {
|
||||||
|
fmt.Fprintf(os.Stderr, "skip %s: %v\n", asn, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIPv6Prefixes(data []byte) ([]string, error) {
|
||||||
|
var response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
Prefixes []struct {
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
} `json:"prefixes"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode announced-prefixes response: %w", err)
|
||||||
|
}
|
||||||
|
if response.Status != "ok" || response.Data.Resource == "" || response.Data.Prefixes == nil {
|
||||||
|
return nil, errors.New("announced-prefixes response is missing required fields")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, record := range response.Data.Prefixes {
|
||||||
|
prefix, err := netip.ParsePrefix(strings.TrimSpace(record.Prefix))
|
||||||
|
if err != nil || !prefix.Addr().Is6() || prefix.Bits() < 16 || prefix.Bits() > 128 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[prefix.String()] = struct{}{}
|
||||||
|
}
|
||||||
|
values := make([]string, 0, len(seen))
|
||||||
|
for value := range seen {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
sort.Strings(values)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, errors.New("no IPv6 prefixes found")
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePrefixFile(output string, values []string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidate := []byte(strings.Join(values, "\n") + "\n")
|
||||||
|
current, readErr := os.ReadFile(output)
|
||||||
|
if readErr == nil {
|
||||||
|
currentCount := countNonemptyLines(current)
|
||||||
|
if currentCount > 0 && len(values)*100 < currentCount*65 {
|
||||||
|
return fmt.Errorf("%w: prefix count dropped from %d to %d for %s", errPrefixCountDrop, currentCount, len(values), filepath.Base(output))
|
||||||
|
}
|
||||||
|
if bytes.Equal(current, candidate) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(filepath.Dir(output), ".prefix-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporaryName := temporary.Name()
|
||||||
|
defer os.Remove(temporaryName)
|
||||||
|
if err := temporary.Chmod(0o644); err != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(candidate); err != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temporaryName, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countNonemptyLines(data []byte) int {
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
32
cmd/update-prefixes/main_test.go
Normal file
32
cmd/update-prefixes/main_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractIPv6Prefixes(t *testing.T) {
|
||||||
|
values, err := extractIPv6Prefixes([]byte(`{"status":"ok","data":{"resource":"64500","prefixes":[{"prefix":"2409:8000::/32"},{"prefix":"2409:8000::/32"},{"prefix":"192.0.2.0/24"}]}}`))
|
||||||
|
if err != nil || len(values) != 1 || values[0] != "2409:8000::/32" {
|
||||||
|
t.Fatalf("unexpected prefixes: %#v, %v", values, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPv6PrefixesRejectsSchemaDrift(t *testing.T) {
|
||||||
|
if _, err := extractIPv6Prefixes([]byte(`{"status":"ok","data":{"resource":"64500"}}`)); err == nil {
|
||||||
|
t.Fatal("missing prefixes unexpectedly accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWritePrefixFileProtectsLargeCountDrop(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "as64500.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("2001:db8:1::/48\n2001:db8:2::/48\n2001:db8:3::/48\n2001:db8:4::/48\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := writePrefixFile(path, []string{"2001:db8:1::/48"})
|
||||||
|
if !errors.Is(err, errPrefixCountDrop) {
|
||||||
|
t.Fatalf("count drop error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const BackTraceVersion = "v0.0.11"
|
const BackTraceVersion = "v0.0.12"
|
||||||
|
|
||||||
var EnableLoger = false
|
var EnableLoger = false
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user