Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot]
a5f09cf874 chore: 更新多个 ASN 的 IPv6 前缀 2026-07-23 02:56:32 +00:00
spiritlhl
881d397bbc fix: redact structured source details
Some checks are pending
Sync ASN metadata / sync (push) Waiting to run
2026-07-22 12:51:44 +08:00
spiritlhl
f0ba5fa410 feat: extend diagnostics and harden output
Some checks are pending
Sync ASN metadata / sync (push) Waiting to run
2026-07-22 10:30:57 +08:00
spiritlhl
46e20f680e ci: validate ASN sync changes on push
Some checks are pending
Sync ASN metadata / sync (push) Waiting to run
2026-07-22 01:58:21 +08:00
spiritlhl
765774cb06 feat: validate ASN metadata manifests 2026-07-22 01:52:11 +08:00
spiritlhl
72a3731059 feat: own ASN metadata synchronization 2026-07-21 23:36:43 +08:00
spiritlhl
577871a7df feat: validate synchronized ASN prefix snapshots 2026-07-21 03:18:31 +08:00
spiritlhl
a32a1067d3 feat: refresh ASN prefixes with bounded Go loader 2026-07-21 00:42:30 +08:00
spiritlhl
dd4fdcdaea feat: add RDAP WHOIS and network relationship diagnostics 2026-07-20 18:41:53 +08:00
github-actions[bot]
42997dab69 chore: 更新多个 ASN 的 IPv6 前缀 2026-07-19 02:13:19 +00:00
github-actions[bot]
1d4b03f932 chore: 更新多个 ASN 的 IPv6 前缀
Some checks failed
创建IPv6检测的前缀 / fetch-ipv6-prefixes (push) Has been cancelled
2026-07-12 02:21:18 +00:00
github-actions[bot]
cb24e20f69 chore: 更新多个 ASN 的 IPv6 前缀
Some checks failed
创建IPv6检测的前缀 / fetch-ipv6-prefixes (push) Has been cancelled
2026-07-05 02:39:59 +00:00
48 changed files with 12962 additions and 2999 deletions

View File

@ -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,32 +24,20 @@ 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: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add bk/prefix/*.txt git add bk/prefix/*.txt bk/prefix/*.txt.manifest.json
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "无变更,跳过提交。" echo "无变更,跳过提交。"
else else

View File

@ -34,7 +34,7 @@ jobs:
run: | run: |
git config --global user.name 'github-actions' git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com' git config --global user.email 'github-actions@github.com'
TAG="v0.0.10-$(date +'%Y%m%d%H%M%S')" TAG="v0.0.11-$(date +'%Y%m%d%H%M%S')"
git tag $TAG git tag $TAG
git push origin $TAG git push origin $TAG
echo "TAG=$TAG" >> $GITHUB_ENV echo "TAG=$TAG" >> $GITHUB_ENV

51
.github/workflows/sync-asn-metadata.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Sync ASN metadata
on:
schedule:
- cron: "29 3 * * *"
workflow_dispatch:
push:
branches:
- main
paths:
- ".github/workflows/sync-asn-metadata.yml"
- "cmd/update-asn-metadata/**"
- "bgptools/data/**"
- "bgptools/asn_metadata.go"
- "bgptools/asn_metadata_test.go"
- "model/model.go"
- "go.mod"
- "go.sum"
concurrency:
group: backtrace-asn-metadata-sync
cancel-in-progress: false
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Update snapshot
run: go run ./cmd/update-asn-metadata
- name: Validate updater and runtime loader
run: go test -race ./cmd/update-asn-metadata ./bgptools
- name: Vet updater and runtime loader
run: go vet ./cmd/update-asn-metadata ./bgptools
- name: Commit semantic changes
run: |
if git diff --quiet -- bgptools/data/bgp-asn-map.json bgptools/data/bgp-asn-map.manifest.json; then
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add bgptools/data/bgp-asn-map.json bgptools/data/bgp-asn-map.manifest.json
git commit -m "chore(data): update ASN metadata"
git push

View File

@ -1,30 +1,72 @@
package backtrace package backtrace
import ( import (
"errors"
"fmt"
"net" "net"
) )
// GeneratePrefixList 生成指定前缀的IP地址列表 const defaultPrefixListLimit = 1 << 16
var (
ErrInvalidPrefix = errors.New("invalid IPv4 prefix")
ErrIPv6PrefixUnsupported = errors.New("IPv6 prefix generation is unsupported")
ErrPrefixListTooLarge = errors.New("prefix list exceeds limit")
)
// GeneratePrefixList generates the legacy list of /24 address prefixes. The
// old API cannot return an error, so invalid, IPv6, and over-sized inputs
// return nil. Callers that need to distinguish these cases should use
// GeneratePrefixListWithLimit.
func GeneratePrefixList(prefix string) []string { func GeneratePrefixList(prefix string) []string {
// 解析CIDR表示法的IP地址 result, err := GeneratePrefixListWithLimit(prefix, defaultPrefixListLimit)
_, ipNet, err := net.ParseCIDR(prefix)
if err != nil { if err != nil {
return nil return nil
} }
// 获取IP地址的32位整数表示 return result
ip := ipNet.IP.To4() }
start := binaryIPToInt(ip)
maskSize, _ := ipNet.Mask.Size() // GeneratePrefixListWithLimit generates one entry for every /24-sized block
end := start | (1<<(32-maskSize) - 1) // covered by an IPv4 CIDR. Prefixes narrower than /24 are rejected once the
// 生成IP地址列表 // number of blocks exceeds maxEntries; this keeps /0 and similarly broad
var prefixList []string // input from allocating or iterating unbounded data. A prefix longer than
for i := start; i <= end; i++ { // /24 still produces the containing block, preserving the legacy behaviour.
if (i-start)%256 == 0 { func GeneratePrefixListWithLimit(prefix string, maxEntries int) ([]string, error) {
tempText := intToBinaryIP(i).String() if maxEntries <= 0 {
prefixList = append(prefixList, tempText[:len(tempText)-2]) maxEntries = defaultPrefixListLimit
}
} }
return prefixList _, ipNet, err := net.ParseCIDR(prefix)
if err != nil {
return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix)
}
maskSize, bits := ipNet.Mask.Size()
if bits == 128 {
return nil, fmt.Errorf("%w: %q", ErrIPv6PrefixUnsupported, prefix)
}
if bits != 32 || maskSize < 0 || maskSize > 32 || ipNet.IP.To4() == nil {
return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix)
}
// A /24 block is the output unit used by the original implementation. A
// /25-/32 prefix therefore has one containing block, while a /0 would need
// 2^24 entries and is rejected before allocation.
blocks := uint64(1)
if maskSize < 24 {
blocks = uint64(1) << uint(24-maskSize)
}
if blocks > uint64(maxEntries) {
return nil, fmt.Errorf("%w: %d entries (limit %d)", ErrPrefixListTooLarge, blocks, maxEntries)
}
start := binaryIPToInt(ipNet.IP.To4()) &^ uint32(255)
result := make([]string, 0, int(blocks))
for index := uint64(0); index < blocks; index++ {
// The arithmetic is bounded by 2^24 blocks, so this multiplication and
// addition cannot overflow uint32 for a valid IPv4 network.
value := start + uint32(index*256)
result = append(result, fmt.Sprintf("%d.%d.%d", byte(value>>24), byte(value>>16), byte(value>>8)))
}
return result, nil
} }
// 将IP地址转换为32位整数 // 将IP地址转换为32位整数

57
back/generate_test.go Normal file
View File

@ -0,0 +1,57 @@
package backtrace
import (
"errors"
"reflect"
"testing"
)
func TestGeneratePrefixListCompatibility(t *testing.T) {
got := GeneratePrefixList("192.0.2.0/23")
want := []string{"192.0.2", "192.0.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("GeneratePrefixList() = %v, want %v", got, want)
}
got = GeneratePrefixList("192.0.2.128/25")
want = []string{"192.0.2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("GeneratePrefixList(/25) = %v, want %v", got, want)
}
}
func TestGeneratePrefixListRejectsUnsafeInputs(t *testing.T) {
tests := []struct {
name string
prefix string
want error
}{
{name: "empty", prefix: "", want: ErrInvalidPrefix},
{name: "invalid", prefix: "not-a-prefix", want: ErrInvalidPrefix},
{name: "IPv6", prefix: "2001:db8::/32", want: ErrIPv6PrefixUnsupported},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := GeneratePrefixList(test.prefix); got != nil {
t.Fatalf("legacy API returned %v, want nil", got)
}
if _, err := GeneratePrefixListWithLimit(test.prefix, 16); !errors.Is(err, test.want) {
t.Fatalf("GeneratePrefixListWithLimit() error = %v, want %v", err, test.want)
}
})
}
}
func TestGeneratePrefixListLimitsBroadPrefixesBeforeAllocation(t *testing.T) {
if _, err := GeneratePrefixListWithLimit("0.0.0.0/0", 1024); !errors.Is(err, ErrPrefixListTooLarge) {
t.Fatalf("GeneratePrefixListWithLimit(/0) error = %v, want %v", err, ErrPrefixListTooLarge)
}
got, err := GeneratePrefixListWithLimit("10.0.0.0/16", 256)
if err != nil {
t.Fatal(err)
}
if len(got) != 256 || got[0] != "10.0.0" || got[255] != "10.0.255" {
t.Fatalf("unexpected bounded result: len=%d first=%q last=%q", len(got), got[0], got[len(got)-1])
}
}

229
bgptools/asn_metadata.go Normal file
View File

@ -0,0 +1,229 @@
package bgptools
import (
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
const (
ASNMetadataSchema = "backtrace.asn-metadata/v1"
ASNMetadataManifestSchema = "backtrace.asn-metadata-manifest/v1"
ASNMetadataMinimum = 50
)
var asnMetadataSnapshotURLs = []string{
"https://cdn.spiritlhl.net/https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bgptools/data/bgp-asn-map.json",
"https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bgptools/data/bgp-asn-map.json",
}
//go:embed data/bgp-asn-map.json
var embeddedASNMetadata []byte
//go:embed data/bgp-asn-map.manifest.json
var embeddedASNMetadataManifest []byte
type ASNMetadata struct {
ASN uint32 `json:"asn"`
Name string `json:"name"`
}
type ASNMetadataSource struct {
Schema string `json:"schema"`
Count int `json:"count"`
GeneratedAt time.Time `json:"generated_at,omitempty"`
Source string `json:"source"`
Fallback bool `json:"fallback"`
}
type asnMetadataDocument struct {
Schema string `json:"schema"`
GeneratedAt time.Time `json:"generated_at"`
Entries []ASNMetadata `json:"entries"`
}
type asnMetadataManifest struct {
Schema string `json:"schema"`
File string `json:"file"`
Count int `json:"count"`
SHA256 string `json:"sha256"`
GeneratedAt time.Time `json:"generated_at"`
}
// LoadASNMetadata prefers the component's validated remote snapshot and
// falls back to the compile-time snapshot. Upstream registry URLs and parsing
// rules are intentionally not exposed to callers.
func LoadASNMetadata(ctx context.Context, client *http.Client) ([]ASNMetadata, ASNMetadataSource, error) {
return loadASNMetadata(ctx, client, asnMetadataSnapshotURLs, embeddedASNMetadata, embeddedASNMetadataManifest, ASNMetadataMinimum)
}
// EmbeddedASNMetadata returns the validated compile-time snapshot without
// performing network access.
func EmbeddedASNMetadata() ([]ASNMetadata, ASNMetadataSource, error) {
entries, generatedAt, err := parseASNMetadataDocument(embeddedASNMetadata, ASNMetadataMinimum)
if err != nil {
return nil, ASNMetadataSource{}, err
}
if err := validateASNMetadataManifest(embeddedASNMetadataManifest, embeddedASNMetadata, len(entries), generatedAt); err != nil {
return nil, ASNMetadataSource{}, fmt.Errorf("validate embedded ASN metadata manifest: %w", err)
}
return entries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(entries), GeneratedAt: generatedAt, Source: "embedded", Fallback: true}, nil
}
// LookupASNMetadata resolves one ASN from the current component registry.
func LookupASNMetadata(ctx context.Context, client *http.Client, asn string) (ASNMetadata, ASNMetadataSource, bool, error) {
normalized, err := normalizeASN(asn)
if err != nil {
return ASNMetadata{}, ASNMetadataSource{}, false, err
}
number, _ := strconv.ParseUint(normalized, 10, 32)
entries, source, err := LoadASNMetadata(ctx, client)
if err != nil {
return ASNMetadata{}, ASNMetadataSource{}, false, err
}
index := sort.Search(len(entries), func(index int) bool { return entries[index].ASN >= uint32(number) })
if index >= len(entries) || entries[index].ASN != uint32(number) {
return ASNMetadata{}, source, false, nil
}
return entries[index], source, true, nil
}
func loadASNMetadata(ctx context.Context, client *http.Client, urls []string, embedded, embeddedManifest []byte, minimum int) ([]ASNMetadata, ASNMetadataSource, error) {
if ctx == nil {
ctx = context.Background()
}
embeddedEntries, embeddedAt, err := parseASNMetadataDocument(embedded, minimum)
if err != nil {
return nil, ASNMetadataSource{}, fmt.Errorf("invalid embedded ASN metadata: %w", err)
}
if err := validateASNMetadataManifest(embeddedManifest, embedded, len(embeddedEntries), embeddedAt); err != nil {
return nil, ASNMetadataSource{}, fmt.Errorf("invalid embedded ASN metadata manifest: %w", err)
}
if client == nil {
client = &http.Client{Timeout: 6 * time.Second}
}
minimumRemote := max(minimum, len(embeddedEntries)*65/100)
for index, snapshotURL := range urls {
manifest, fetchErr := fetchASNMetadata(ctx, client, asnMetadataManifestURL(snapshotURL))
if fetchErr != nil {
continue
}
data, fetchErr := fetchASNMetadata(ctx, client, snapshotURL)
if fetchErr != nil {
continue
}
entries, generatedAt, parseErr := parseASNMetadataDocument(data, minimumRemote)
if parseErr == nil && validateASNMetadataManifest(manifest, data, len(entries), generatedAt) == nil {
return entries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(entries), GeneratedAt: generatedAt, Source: metadataRemoteSource(index), Fallback: index > 0}, nil
}
}
return embeddedEntries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(embeddedEntries), GeneratedAt: embeddedAt, Source: "embedded", Fallback: true}, nil
}
func metadataRemoteSource(index int) string {
if index == 0 {
return "cdn"
}
if index == 1 {
return "raw"
}
return "remote"
}
func asnMetadataManifestURL(snapshotURL string) string {
return strings.TrimSuffix(snapshotURL, ".json") + ".manifest.json"
}
func validateASNMetadataManifest(data, snapshot []byte, count int, generatedAt time.Time) error {
var manifest asnMetadataManifest
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("manifest contains trailing JSON")
}
if manifest.Schema != ASNMetadataManifestSchema || manifest.File != "bgp-asn-map.json" || manifest.Count != count || manifest.GeneratedAt.IsZero() || !manifest.GeneratedAt.Equal(generatedAt) {
return errors.New("manifest schema, file, count, or generated_at is invalid")
}
hash := sha256.Sum256(snapshot)
if !strings.EqualFold(manifest.SHA256, hex.EncodeToString(hash[:])) {
return errors.New("manifest SHA-256 does not match snapshot")
}
return nil
}
func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil)
if err != nil {
return nil, errors.New("create ASN metadata request failed")
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/1")
response, err := client.Do(request)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, errors.New("ASN metadata request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
}
const maximumSize = 4 << 20
data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1))
if err != nil {
return nil, errors.New("ASN metadata response read failed")
}
if len(data) > maximumSize {
return nil, fmt.Errorf("ASN metadata exceeds %d bytes", maximumSize)
}
return data, nil
}
func parseASNMetadataDocument(data []byte, minimum int) ([]ASNMetadata, time.Time, error) {
var document asnMetadataDocument
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return nil, time.Time{}, err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return nil, time.Time{}, errors.New("ASN metadata contains trailing JSON")
}
if document.Schema != ASNMetadataSchema || document.GeneratedAt.IsZero() {
return nil, time.Time{}, errors.New("ASN metadata schema or generated_at is invalid")
}
seen := make(map[uint32]struct{}, len(document.Entries))
entries := make([]ASNMetadata, 0, len(document.Entries))
for _, entry := range document.Entries {
entry.Name = strings.TrimSpace(entry.Name)
if entry.ASN == 0 || entry.Name == "" {
return nil, time.Time{}, errors.New("ASN metadata contains an invalid entry")
}
if _, exists := seen[entry.ASN]; exists {
return nil, time.Time{}, fmt.Errorf("ASN metadata contains duplicate AS%d", entry.ASN)
}
seen[entry.ASN] = struct{}{}
entries = append(entries, entry)
}
if len(entries) < minimum {
return nil, time.Time{}, fmt.Errorf("ASN metadata count %d is below minimum %d", len(entries), minimum)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ASN < entries[j].ASN })
return entries, document.GeneratedAt, nil
}

View File

@ -0,0 +1,83 @@
package bgptools
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLoadASNMetadataPrefersValidatedRemote(t *testing.T) {
payload := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":2,"name":"Two"},{"asn":1,"name":"One"}]}`
manifest := manifestFor([]byte(payload), "bgp-asn-map.json", 2)
if err := validateASNMetadataManifest(manifest, []byte(payload), 2, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if strings.HasSuffix(request.URL.Path, ".manifest.json") {
_, _ = w.Write(manifest)
return
}
_, _ = w.Write([]byte(payload))
}))
defer server.Close()
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/bgp-asn-map.json"}, []byte(payload), manifest, 1)
if err != nil {
t.Fatal(err)
}
if source.Source != "cdn" || source.Fallback || source.Count != 2 || entries[0].ASN != 1 {
t.Fatalf("unexpected result: %#v %#v", entries, source)
}
}
func TestLoadASNMetadataFallsBackOnSchemaFailure(t *testing.T) {
embedded := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"}]}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"schema":"wrong"}`)) }))
defer server.Close()
manifest := manifestFor([]byte(embedded), "bgp-asn-map.json", 1)
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/bgp-asn-map.json"}, []byte(embedded), manifest, 1)
if err != nil {
t.Fatal(err)
}
if source.Source != "embedded" || !source.Fallback || len(entries) != 1 {
t.Fatalf("unexpected fallback: %#v %#v", entries, source)
}
}
func TestLoadASNMetadataFallsBackFromCDNToRaw(t *testing.T) {
payload := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"}]}`)
validManifest := manifestFor(payload, "bgp-asn-map.json", 1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/cdn/bgp-asn-map.manifest.json" {
_, _ = writer.Write([]byte(`{"schema":"backtrace.asn-metadata-manifest/v1","file":"bgp-asn-map.json","count":1,"sha256":"bad","generated_at":"2026-07-20T00:00:00Z"}`))
return
}
if request.URL.Path == "/raw/bgp-asn-map.manifest.json" {
_, _ = writer.Write(validManifest)
return
}
_, _ = writer.Write(payload)
}))
defer server.Close()
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/cdn/bgp-asn-map.json", server.URL + "/raw/bgp-asn-map.json"}, payload, validManifest, 1)
if err != nil || len(entries) != 1 || source.Source != "raw" || !source.Fallback {
t.Fatalf("unexpected raw fallback: entries=%#v source=%#v err=%v", entries, source, err)
}
}
func manifestFor(snapshot []byte, file string, count int) []byte {
hash := sha256.Sum256(snapshot)
return []byte(fmt.Sprintf(`{"schema":"backtrace.asn-metadata-manifest/v1","file":"%s","count":%d,"sha256":"%s","generated_at":"2026-07-20T00:00:00Z"}`, file, count, hex.EncodeToString(hash[:])))
}
func TestParseASNMetadataRejectsDuplicateAndDrop(t *testing.T) {
payload := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":1,"name":"Again"}]}`)
if _, _, err := parseASNMetadataDocument(payload, 1); err == nil {
t.Fatal("duplicate ASN unexpectedly accepted")
}
}

View File

@ -0,0 +1,342 @@
{
"schema": "backtrace.asn-metadata/v1",
"generated_at": "2026-07-21T15:33:49.459211Z",
"entries": [
{
"asn": 174,
"name": "Cogent"
},
{
"asn": 701,
"name": "Verizon"
},
{
"asn": 702,
"name": "Verizon"
},
{
"asn": 1239,
"name": "Sprint"
},
{
"asn": 1273,
"name": "Vodafone"
},
{
"asn": 1299,
"name": "Arelion"
},
{
"asn": 1729,
"name": "Telia"
},
{
"asn": 2497,
"name": "IIJ"
},
{
"asn": 2516,
"name": "KDDI"
},
{
"asn": 2914,
"name": "NTT"
},
{
"asn": 3209,
"name": "Vodafone"
},
{
"asn": 3257,
"name": "GTT"
},
{
"asn": 3258,
"name": "xTom"
},
{
"asn": 3301,
"name": "Telia"
},
{
"asn": 3308,
"name": "Telia"
},
{
"asn": 3320,
"name": "DTAG"
},
{
"asn": 3356,
"name": "Lumen"
},
{
"asn": 3462,
"name": "Hinet"
},
{
"asn": 3491,
"name": "PCCW"
},
{
"asn": 3786,
"name": "LG"
},
{
"asn": 4445,
"name": "Vodafone"
},
{
"asn": 4609,
"name": "CTM"
},
{
"asn": 4637,
"name": "Telstra"
},
{
"asn": 4657,
"name": "StarHub"
},
{
"asn": 4766,
"name": "KT"
},
{
"asn": 4788,
"name": "TMNet"
},
{
"asn": 5378,
"name": "Vodafone"
},
{
"asn": 5511,
"name": "Orange"
},
{
"asn": 6233,
"name": "xTom"
},
{
"asn": 6453,
"name": "TATA"
},
{
"asn": 6461,
"name": "Zayo"
},
{
"asn": 6762,
"name": "Sparkle"
},
{
"asn": 6830,
"name": "Liberty"
},
{
"asn": 6939,
"name": "HE"
},
{
"asn": 7018,
"name": "AT\u0026T"
},
{
"asn": 7473,
"name": "SingTel"
},
{
"asn": 7922,
"name": "Comcast"
},
{
"asn": 7979,
"name": "Servers"
},
{
"asn": 8075,
"name": "MS"
},
{
"asn": 8888,
"name": "xTom"
},
{
"asn": 9002,
"name": "RETN"
},
{
"asn": 9269,
"name": "HKBN"
},
{
"asn": 9304,
"name": "HGC"
},
{
"asn": 9505,
"name": "CHT"
},
{
"asn": 9644,
"name": "SK"
},
{
"asn": 9886,
"name": "CTCSCI"
},
{
"asn": 9924,
"name": "TFN"
},
{
"asn": 12389,
"name": "RT_RU"
},
{
"asn": 12876,
"name": "Scaleway"
},
{
"asn": 12956,
"name": "Telxius"
},
{
"asn": 13335,
"name": "CF"
},
{
"asn": 15169,
"name": "Google"
},
{
"asn": 15802,
"name": "DU"
},
{
"asn": 16276,
"name": "OVH"
},
{
"asn": 16509,
"name": "Amazon"
},
{
"asn": 17676,
"name": "Softbank"
},
{
"asn": 18403,
"name": "FPT"
},
{
"asn": 20473,
"name": "Vultr"
},
{
"asn": 20485,
"name": "TTK_RU"
},
{
"asn": 23961,
"name": "Misaka"
},
{
"asn": 24940,
"name": "Hetzner"
},
{
"asn": 25820,
"name": "IT7"
},
{
"asn": 30844,
"name": "Liquid"
},
{
"asn": 31898,
"name": "Oracle"
},
{
"asn": 37700,
"name": "JINX"
},
{
"asn": 38731,
"name": "CHT"
},
{
"asn": 45102,
"name": "Alibaba"
},
{
"asn": 45899,
"name": "VNPT"
},
{
"asn": 49304,
"name": "SAKURA"
},
{
"asn": 51847,
"name": "Nearoute"
},
{
"asn": 58652,
"name": "Level3"
},
{
"asn": 60068,
"name": "Datacamp"
},
{
"asn": 63150,
"name": "Bage"
},
{
"asn": 132203,
"name": "Tencent"
},
{
"asn": 133613,
"name": "MTel"
},
{
"asn": 136907,
"name": "Huawei"
},
{
"asn": 137409,
"name": "GSL"
},
{
"asn": 140096,
"name": "JINX"
},
{
"asn": 152672,
"name": "Aiyun"
},
{
"asn": 202662,
"name": "Hytron"
},
{
"asn": 209242,
"name": "CF"
},
{
"asn": 211392,
"name": "Dream"
},
{
"asn": 214996,
"name": "netcup"
},
{
"asn": 216382,
"name": "Layer"
}
]
}

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-metadata-manifest/v1",
"file": "bgp-asn-map.json",
"count": 84,
"sha256": "f681dd78d55c27d4615d2e705de9bde24d111e8897d37a489b91e263f27f1a66",
"generated_at": "2026-07-21T15:33:49.459211Z"
}

129
bgptools/origin.go Normal file
View File

@ -0,0 +1,129 @@
package bgptools
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
)
const defaultOriginASNURL = "https://stat.ripe.net/data/network-info/data.json"
var (
ErrOriginASNMissing = errors.New("origin ASN response has no usable ASN")
ErrOriginASNRateLimited = errors.New("origin ASN resolver rate limited")
)
type OriginASNConfig struct {
Client *http.Client
BaseURL string
Timeout time.Duration
MaxResponseSize int64
}
func ResolveOriginASN(ctx context.Context, ip string) (string, error) {
return ResolveOriginASNWithConfig(ctx, ip, OriginASNConfig{})
}
func ResolveOriginASNWithConfig(parent context.Context, ip string, config OriginASNConfig) (string, error) {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return "", ErrInvalidIPAddress
}
if parent == nil {
parent = context.Background()
}
if config.Timeout <= 0 {
config.Timeout = 8 * time.Second
}
if config.MaxResponseSize <= 0 {
config.MaxResponseSize = 1 << 20
}
if config.Client == nil {
config.Client = &http.Client{}
}
if strings.TrimSpace(config.BaseURL) == "" {
config.BaseURL = defaultOriginASNURL
}
requestURL, err := addQuery(config.BaseURL, "resource", parsed.String())
if err != nil {
return "", errors.New("invalid origin ASN source")
}
ctx, cancel := context.WithTimeout(parent, config.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return "", errors.New("create origin ASN request failed")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1")
response, err := config.Client.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
return "", errors.New("origin ASN request failed")
}
defer response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests {
return "", ErrOriginASNRateLimited
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("origin ASN HTTP %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1))
if err != nil {
return "", errors.New("origin ASN response read failed")
}
if int64(len(body)) > config.MaxResponseSize {
return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize)
}
var payload struct {
Status string `json:"status"`
Data *struct {
ASNs []json.RawMessage `json:"asns"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
if status := strings.TrimSpace(payload.Status); status != "" && !strings.EqualFold(status, "ok") {
return "", fmt.Errorf("origin ASN response status %q", status)
}
if payload.Data == nil || len(payload.Data.ASNs) == 0 {
return "", ErrOriginASNMissing
}
for _, raw := range payload.Data.ASNs {
value, err := originASNValue(raw)
if err != nil {
continue
}
if normalized, err := normalizeASN(value); err == nil {
return normalized, nil
}
}
return "", ErrOriginASNMissing
}
func originASNValue(raw json.RawMessage) (string, error) {
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text, nil
}
var number json.Number
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
if err := decoder.Decode(&number); err != nil {
return "", err
}
if _, err := strconv.ParseUint(number.String(), 10, 32); err != nil {
return "", err
}
return number.String(), nil
}

58
bgptools/origin_test.go Normal file
View File

@ -0,0 +1,58 @@
package bgptools
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestResolveOriginASNOfflineFixture(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("resource"); got != "192.0.2.1" {
t.Errorf("resource = %q", got)
}
_, _ = w.Write([]byte(`{"status":"ok","data":{"asns":["AS64500",64501],"prefix":"192.0.2.0/24"}}`))
}))
defer server.Close()
asn, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{
Client: server.Client(), BaseURL: server.URL, Timeout: time.Second,
})
if err != nil || asn != "64500" {
t.Fatalf("ResolveOriginASNWithConfig() = %q, %v", asn, err)
}
}
func TestResolveOriginASNRejectsMissingAndDriftedData(t *testing.T) {
for _, payload := range []string{
`{"status":"ok","data":{"asns":[]}}`,
`{"status":"ok","data":{"asns":"64500"}}`,
`{"status":"ok","data":{"asns":["not-an-asn"]}}`,
`{"status":"error","data":{"asns":[64500]}}`,
} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(payload))
}))
_, err := ResolveOriginASNWithConfig(context.Background(), "2001:db8::1", OriginASNConfig{
Client: server.Client(), BaseURL: server.URL, Timeout: time.Second,
})
server.Close()
if err == nil {
t.Fatalf("payload %s unexpectedly succeeded", payload)
}
}
}
func TestResolveOriginASNReportsRateLimit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer server.Close()
_, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{Client: server.Client(), BaseURL: server.URL})
if !errors.Is(err, ErrOriginASNRateLimited) {
t.Fatalf("rate limit error = %v", err)
}
}

97
bgptools/privacy_test.go Normal file
View File

@ -0,0 +1,97 @@
package bgptools
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type privacyRoundTripper func(*http.Request) (*http.Response, error)
func (fn privacyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestStructuredReportErrorsDoNotExposeRemoteURLs(t *testing.T) {
var rdap *httptest.Server
rdap = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintf(w, `{"handle":"TEST","links":[{"rel":"geofeed","href":"https://private.example/geofeed?token=secret"}]}`)
}))
defer rdap.Close()
failing := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) {
return nil, errors.New("dial " + request.URL.String())
})}
report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{
RDAPClient: rdap.Client(),
RDAPBaseURL: rdap.URL,
FetchGeofeed: true,
GeofeedClient: failing,
ResolveASN: func(context.Context, string) (string, error) {
return "", errors.New("resolver https://private.example/asn?key=secret")
},
})
if err != nil {
t.Fatal(err)
}
for _, source := range report.Sources {
for _, field := range []string{source.Source, source.Error} {
for _, forbidden := range []string{"private.example", "token=", "key=", "secret"} {
if strings.Contains(field, forbidden) {
t.Fatalf("structured source leaked %q: %+v", forbidden, source)
}
}
}
}
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Error != "request_failed" {
t.Fatalf("geofeed error was not stabilized: %+v", report.Geofeeds)
}
encoded, err := json.Marshal(report)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"token=", "key=", "secret", "userinfo@"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("structured report leaked %q: %s", forbidden, encoded)
}
}
if report.RDAP == nil || len(report.RDAP.GeofeedURLs) != 1 || report.RDAP.GeofeedURLs[0] != "https://private.example/geofeed" || report.Geofeeds[0].URL != "https://private.example/geofeed" {
t.Fatalf("geofeed URL was not sanitized consistently: %+v", report)
}
}
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)
}
}
})
}
}

211
bgptools/rdap.go Normal file
View File

@ -0,0 +1,211 @@
package bgptools
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sort"
"strings"
"time"
)
var (
ErrInvalidIPAddress = errors.New("invalid IP address")
ErrRDAPRateLimited = errors.New("rdap rate limited")
ErrRDAPResponseTooLarge = errors.New("rdap response too large")
)
type RDAPEntity struct {
Handle string `json:"handle,omitempty"`
Roles []string `json:"roles,omitempty"`
}
type RDAPRecord struct {
IP string `json:"ip"`
Handle string `json:"handle,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Country string `json:"country,omitempty"`
StartAddress string `json:"start_address,omitempty"`
EndAddress string `json:"end_address,omitempty"`
ParentHandle string `json:"parent_handle,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
RegistrationDate *time.Time `json:"registration_date,omitempty"`
LastChangedDate *time.Time `json:"last_changed_date,omitempty"`
GeofeedURLs []string `json:"geofeed_urls,omitempty"`
Entities []RDAPEntity `json:"entities,omitempty"`
Port43 string `json:"port43,omitempty"`
Status []string `json:"status,omitempty"`
Source string `json:"source"`
}
func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL string) (RDAPRecord, error) {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return RDAPRecord{}, ErrInvalidIPAddress
}
if ctx == nil {
ctx = context.Background()
}
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
if strings.TrimSpace(baseURL) == "" {
baseURL = "https://rdap.org/ip/"
}
requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return RDAPRecord{}, errors.New("create RDAP request failed")
}
req.Header.Set("Accept", "application/rdap+json, application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1")
response, err := client.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return RDAPRecord{}, ctxErr
}
return RDAPRecord{}, errors.New("RDAP request failed")
}
defer response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests {
return RDAPRecord{}, ErrRDAPRateLimited
}
if response.StatusCode != http.StatusOK {
return RDAPRecord{}, fmt.Errorf("rdap HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
if err != nil {
return RDAPRecord{}, errors.New("RDAP response read failed")
}
if len(data) > 4<<20 {
return RDAPRecord{}, ErrRDAPResponseTooLarge
}
source := requestURL
if response.Request != nil && response.Request.URL != nil {
source = response.Request.URL.String()
}
return parseRDAPRecord(parsed.String(), source, data)
}
func parseRDAPRecord(ip, source string, data []byte) (RDAPRecord, error) {
var payload struct {
Handle string `json:"handle"`
Name string `json:"name"`
Type string `json:"type"`
Country string `json:"country"`
StartAddress string `json:"startAddress"`
EndAddress string `json:"endAddress"`
ParentHandle string `json:"parentHandle"`
Port43 string `json:"port43"`
Status []string `json:"status"`
Events []struct {
Action string `json:"eventAction"`
Date time.Time `json:"eventDate"`
} `json:"events"`
Links []struct {
Rel string `json:"rel"`
Href string `json:"href"`
Type string `json:"type"`
} `json:"links"`
Entities []struct {
Handle string `json:"handle"`
Roles []string `json:"roles"`
} `json:"entities"`
CIDRs []struct {
V4Prefix string `json:"v4prefix"`
V6Prefix string `json:"v6prefix"`
Length *int `json:"length"`
} `json:"cidr0_cidrs"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return RDAPRecord{}, err
}
record := RDAPRecord{
IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type,
Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress,
ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: "rdap",
}
for _, event := range payload.Events {
switch strings.ToLower(event.Action) {
case "registration":
if !event.Date.IsZero() && (record.RegistrationDate == nil || event.Date.Before(*record.RegistrationDate)) {
value := event.Date
record.RegistrationDate = &value
}
case "last changed", "last update of rdap database":
if !event.Date.IsZero() && (record.LastChangedDate == nil || event.Date.After(*record.LastChangedDate)) {
value := event.Date
record.LastChangedDate = &value
}
}
}
for _, link := range payload.Links {
if strings.EqualFold(link.Rel, "geofeed") || strings.Contains(strings.ToLower(link.Type), "geofeed") {
if href := strings.TrimSpace(link.Href); href != "" {
record.GeofeedURLs = append(record.GeofeedURLs, href)
}
}
}
for _, entity := range payload.Entities {
record.Entities = append(record.Entities, RDAPEntity{Handle: entity.Handle, Roles: entity.Roles})
}
for _, cidr := range payload.CIDRs {
prefix := cidr.V4Prefix
if prefix == "" {
prefix = cidr.V6Prefix
}
if cidr.Length == nil {
continue
}
if normalized, ok := normalizeRDAPPrefix(ip, prefix, *cidr.Length); ok {
record.Prefixes = append(record.Prefixes, normalized)
}
}
record.Prefixes = uniqueSortedStrings(record.Prefixes)
record.GeofeedURLs = uniqueSortedStrings(record.GeofeedURLs)
return record, nil
}
func normalizeRDAPPrefix(targetIP, prefix string, length int) (string, bool) {
target := net.ParseIP(strings.TrimSpace(targetIP))
parsed := net.ParseIP(strings.TrimSpace(prefix))
if target == nil || parsed == nil || (target.To4() == nil) != (parsed.To4() == nil) {
return "", false
}
bits := 128
if parsed.To4() != nil {
bits = 32
}
if length < 0 || length > bits {
return "", false
}
_, network, err := net.ParseCIDR(fmt.Sprintf("%s/%d", parsed.String(), length))
if err != nil {
return "", false
}
if !network.Contains(target) {
return "", false
}
return network.String(), true
}
func uniqueSortedStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
seen[value] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
}

48
bgptools/rdap_test.go Normal file
View File

@ -0,0 +1,48 @@
package bgptools
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQueryRDAPParsesRegistrationAndGeofeed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rdap+json")
_, _ = w.Write([]byte(`{"handle":"NET-TEST","name":"TEST-NET","country":"US","startAddress":"192.0.2.0","endAddress":"192.0.2.255","cidr0_cidrs":[{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":"https://example.test/geofeed.csv"}],"entities":[{"handle":"EXAMPLE","roles":["registrant"]}]}`))
}))
defer server.Close()
record, err := QueryRDAP(context.Background(), "192.0.2.1", server.Client(), server.URL)
if err != nil {
t.Fatal(err)
}
if record.Handle != "NET-TEST" || record.RegistrationDate == nil || len(record.GeofeedURLs) != 1 || len(record.Entities) != 1 || len(record.Prefixes) != 1 || record.Prefixes[0] != "192.0.2.0/24" {
t.Fatalf("unexpected record: %+v", record)
}
}
func TestQueryRDAPRejectsInvalidIP(t *testing.T) {
if _, err := QueryRDAP(context.Background(), "invalid", nil, ""); err == nil {
t.Fatal("expected invalid IP error")
}
}
func TestQueryRDAPCanonicalizesAndFiltersPrefixes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := strings.TrimPrefix(r.URL.Path, "/"); got != "2001:db8::1" {
t.Errorf("RDAP path IP = %q", got)
}
_, _ = w.Write([]byte(`{"port43":"whois.ripe.net","cidr0_cidrs":[{"v6prefix":"2001:db8::99","length":32},{"v6prefix":"2001:db8::"},{"v6prefix":"2001:db9::","length":32},{"v6prefix":"not-an-ip","length":32},{"v6prefix":"2001:db8::","length":129},{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration"}]}`))
}))
defer server.Close()
record, err := QueryRDAP(nil, "2001:db8::1", server.Client(), server.URL)
if err != nil {
t.Fatal(err)
}
if len(record.Prefixes) != 1 || record.Prefixes[0] != "2001:db8::/32" {
t.Fatalf("unexpected filtered prefixes: %+v", record.Prefixes)
}
}

489
bgptools/relationships.go Normal file
View File

@ -0,0 +1,489 @@
package bgptools
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
// RelationshipKind is the relationship of a neighbouring network to the
// target ASN. RIPEstat does not always publish a direction, in which case a
// neighbour is reported as peer with Inferred=true instead of pretending that
// the direction is known.
type RelationshipKind string
const (
RelationshipUpstream RelationshipKind = "upstream"
RelationshipPeer RelationshipKind = "peer"
RelationshipIXP RelationshipKind = "ixp"
)
// RelationshipStatus is deliberately small and stable so callers can render
// a partial report without parsing provider-specific error strings.
type RelationshipStatus string
const (
RelationshipAvailable RelationshipStatus = "available"
RelationshipPartial RelationshipStatus = "partial"
RelationshipRateLimited RelationshipStatus = "rate_limited"
RelationshipTimeout RelationshipStatus = "timeout"
RelationshipMissingFields RelationshipStatus = "missing_fields"
RelationshipError RelationshipStatus = "error"
RelationshipUnsupported RelationshipStatus = "unsupported"
)
var errRelationshipMissingFields = errors.New("relationship response missing required fields")
// ASNRelationship is a normalized relationship entry. ASN is empty for an
// IXP that has no ASN in PeeringDB; IXPID and Name remain usable identifiers.
type ASNRelationship struct {
ASN string `json:"asn,omitempty"`
Name string `json:"name,omitempty"`
Kind RelationshipKind `json:"kind"`
Source string `json:"source"`
Status RelationshipStatus `json:"status"`
Power float64 `json:"power,omitempty"`
IXPID string `json:"ixp_id,omitempty"`
Inferred bool `json:"inferred,omitempty"`
RateLimited bool `json:"rate_limited,omitempty"`
Timeout bool `json:"timeout,omitempty"`
}
// RelationshipSourceStatus records the result of each upstream API. A
// report may be partial when one API is unavailable and the other succeeds.
type RelationshipSourceStatus struct {
Source string `json:"source"`
Status RelationshipStatus `json:"status"`
RateLimited bool `json:"rate_limited"`
Timeout bool `json:"timeout"`
Error string `json:"error,omitempty"`
}
// ASNRelationshipReport contains normalized upstream, peer and IXP entries.
// SourceStatuses allows callers to distinguish a clean empty result from a
// provider timeout or rate limit.
type ASNRelationshipReport struct {
TargetASN string `json:"target_asn"`
Status RelationshipStatus `json:"status"`
RateLimited bool `json:"rate_limited"`
Timeout bool `json:"timeout"`
Upstreams []ASNRelationship `json:"upstreams,omitempty"`
Peers []ASNRelationship `json:"peers,omitempty"`
IXPs []ASNRelationship `json:"ixps,omitempty"`
SourceStatuses []RelationshipSourceStatus `json:"sources,omitempty"`
}
// RelationshipConfig controls the public API endpoints. URLs are explicit
// Go configuration so callers can use local fixtures without environment
// variables. Empty URLs select the documented public endpoints.
type RelationshipConfig struct {
Client *http.Client
RIPEstatURL string
PeeringDBURL string
Timeout time.Duration
MaxResponseSize int64
}
const (
defaultRIPEstatURL = "https://stat.ripe.net/data/asn-neighbours/data.json"
defaultPeeringDBURL = "https://www.peeringdb.com/api/netixlan"
)
// QueryASNRelationships fetches relationship data from RIPEstat and IXP
// membership data from PeeringDB. It returns a report even when one source
// fails; only an invalid ASN or caller cancellation is returned as an error.
func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) {
normalized, err := normalizeASN(asn)
if err != nil {
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
if cfg.Timeout <= 0 {
cfg.Timeout = 8 * time.Second
}
if cfg.MaxResponseSize <= 0 {
cfg.MaxResponseSize = 4 << 20
}
if cfg.Client == nil {
cfg.Client = &http.Client{}
}
if strings.TrimSpace(cfg.RIPEstatURL) == "" {
cfg.RIPEstatURL = defaultRIPEstatURL
}
if strings.TrimSpace(cfg.PeeringDBURL) == "" {
cfg.PeeringDBURL = defaultPeeringDBURL
}
report := &ASNRelationshipReport{TargetASN: normalized}
ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized)
if err != nil {
return nil, errors.New("invalid relationship source configuration")
}
ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL)
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr))
if ripeErr == nil {
entries, parseErr := parseRIPEstatRelationships(ripeRaw, normalized)
if parseErr != nil {
ripeStatus = statusForParseError(parseErr)
report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("ripestat", ripeStatus, parseErr)
} else {
for _, entry := range entries {
if entry.Kind == RelationshipUpstream {
report.Upstreams = append(report.Upstreams, entry)
} else {
report.Peers = append(report.Peers, entry)
}
}
}
}
peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized)
if err != nil {
return nil, errors.New("invalid relationship source configuration")
}
peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL)
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr))
if peeringErr == nil {
entries, parseErr := parsePeeringDBIXPs(peeringRaw)
if parseErr != nil {
peeringStatus = statusForParseError(parseErr)
report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("peeringdb", peeringStatus, parseErr)
} else {
report.IXPs = append(report.IXPs, entries...)
}
}
sortRelationships(report)
report.Status = aggregateRelationshipStatus(report.SourceStatuses)
for _, source := range report.SourceStatuses {
report.RateLimited = report.RateLimited || source.RateLimited
report.Timeout = report.Timeout || source.Timeout
}
if err := ctx.Err(); err != nil {
return report, err
}
return report, nil
}
// QueryASNRelationshipReport is a descriptive alias for QueryASNRelationships.
func QueryASNRelationshipReport(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) {
return QueryASNRelationships(ctx, asn, cfg)
}
func normalizeASN(value string) (string, error) {
value = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(value)), "AS")
if value == "" {
return "", errors.New("ASN cannot be empty")
}
n, err := strconv.ParseUint(value, 10, 32)
if err != nil || n == 0 {
return "", fmt.Errorf("invalid ASN %q", value)
}
return strconv.FormatUint(n, 10), nil
}
func addQuery(rawURL, key, value string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := u.Query()
query.Set(key, value)
u.RawQuery = query.Encode()
return u.String(), nil
}
func fetchRelationshipJSON(parent context.Context, cfg RelationshipConfig, source, requestURL string) ([]byte, RelationshipStatus, error) {
ctx, cancel := context.WithTimeout(parent, cfg.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, RelationshipError, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-relationships/1")
resp, err := cfg.Client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || (netErrTimeout(err)) {
return nil, RelationshipTimeout, err
}
return nil, RelationshipError, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, RelationshipRateLimited, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
}
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
return nil, RelationshipTimeout, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return nil, RelationshipError, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cfg.MaxResponseSize+1))
if err != nil {
return nil, RelationshipError, err
}
if int64(len(body)) > cfg.MaxResponseSize {
return nil, RelationshipError, fmt.Errorf("%s response exceeds %d bytes", source, cfg.MaxResponseSize)
}
return body, RelationshipAvailable, nil
}
func netErrTimeout(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
func sourceStatus(source string, status RelationshipStatus, err error) RelationshipSourceStatus {
item := RelationshipSourceStatus{
Source: source, Status: status,
RateLimited: status == RelationshipRateLimited,
Timeout: status == RelationshipTimeout,
}
if err != nil {
switch status {
case RelationshipRateLimited:
item.Error = "rate_limited"
case RelationshipTimeout:
item.Error = "timeout"
case RelationshipMissingFields:
item.Error = "missing_fields"
default:
item.Error = "request_failed"
}
}
return item
}
func statusForParseError(err error) RelationshipStatus {
if errors.Is(err, errRelationshipMissingFields) {
return RelationshipMissingFields
}
return RelationshipError
}
func aggregateRelationshipStatus(sources []RelationshipSourceStatus) RelationshipStatus {
available := 0
for _, source := range sources {
if source.Status == RelationshipAvailable {
available++
}
}
if available == len(sources) {
return RelationshipAvailable
}
if available > 0 {
return RelationshipPartial
}
for _, source := range sources {
if source.Status == RelationshipRateLimited {
return RelationshipRateLimited
}
}
for _, source := range sources {
if source.Status == RelationshipTimeout {
return RelationshipTimeout
}
}
for _, source := range sources {
if source.Status == RelationshipMissingFields {
return RelationshipMissingFields
}
}
return RelationshipError
}
type ripeNeighbourResponse struct {
Data *struct {
Neighbours json.RawMessage `json:"neighbours"`
} `json:"data"`
}
type ripeNeighbour struct {
ASN json.RawMessage `json:"asn"`
Name string `json:"name"`
Power float64 `json:"power"`
Type string `json:"type"`
Relationship string `json:"relationship"`
Direction string `json:"direction"`
}
func parseRIPEstatRelationships(payload []byte, targetASN string) ([]ASNRelationship, error) {
var response ripeNeighbourResponse
if err := json.Unmarshal(payload, &response); err != nil {
return nil, err
}
if response.Data == nil || len(response.Data.Neighbours) == 0 || string(response.Data.Neighbours) == "null" {
return nil, errRelationshipMissingFields
}
var neighbours []ripeNeighbour
if err := json.Unmarshal(response.Data.Neighbours, &neighbours); err != nil {
return nil, errRelationshipMissingFields
}
var result []ASNRelationship
for _, neighbour := range neighbours {
asn, err := parseJSONASN(neighbour.ASN)
if err != nil || asn == targetASN {
continue
}
kind, inferred := relationshipKind(neighbour.Relationship, neighbour.Type, neighbour.Direction)
result = append(result, ASNRelationship{
ASN: asn, Name: strings.TrimSpace(neighbour.Name), Kind: kind,
Source: "ripestat", Status: RelationshipAvailable,
Power: neighbour.Power, Inferred: inferred,
})
}
return dedupeRelationships(result), nil
}
func relationshipKind(values ...string) (RelationshipKind, bool) {
for _, value := range values {
switch strings.ToLower(strings.TrimSpace(value)) {
case "upstream", "provider", "transit":
return RelationshipUpstream, false
case "peer", "peering", "customer", "downstream":
return RelationshipPeer, false
case "ixp", "internet exchange", "exchange":
return RelationshipIXP, false
}
}
return RelationshipPeer, true
}
func parseJSONASN(raw json.RawMessage) (string, error) {
if len(raw) == 0 || string(raw) == "null" {
return "", errors.New("missing ASN")
}
var value string
if raw[0] == '"' {
if err := json.Unmarshal(raw, &value); err != nil {
return "", err
}
} else {
var number json.Number
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
if err := decoder.Decode(&number); err != nil {
return "", err
}
value = number.String()
}
return normalizeASN(value)
}
func dedupeRelationships(values []ASNRelationship) []ASNRelationship {
seen := make(map[string]ASNRelationship, len(values))
for _, value := range values {
key := string(value.Kind) + ":" + value.ASN + ":" + value.IXPID
if previous, ok := seen[key]; !ok || previous.Name == "" && value.Name != "" {
seen[key] = value
}
}
result := make([]ASNRelationship, 0, len(seen))
for _, value := range seen {
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Kind != result[j].Kind {
return result[i].Kind < result[j].Kind
}
if result[i].ASN != result[j].ASN {
return result[i].ASN < result[j].ASN
}
return result[i].IXPID < result[j].IXPID
})
return result
}
type peeringDBResponse struct {
Data json.RawMessage `json:"data"`
}
func parsePeeringDBIXPs(payload []byte) ([]ASNRelationship, error) {
var response peeringDBResponse
if err := json.Unmarshal(payload, &response); err != nil {
return nil, err
}
if len(response.Data) == 0 || string(response.Data) == "null" {
return nil, errRelationshipMissingFields
}
var records []map[string]json.RawMessage
if err := json.Unmarshal(response.Data, &records); err != nil {
return nil, errRelationshipMissingFields
}
result := make([]ASNRelationship, 0, len(records))
for _, record := range records {
id := firstString(record, "ix_id", "ixlan_id", "id")
name := firstString(record, "name", "ix_name")
if nested, ok := record["ixlan"]; ok {
var object map[string]json.RawMessage
if json.Unmarshal(nested, &object) == nil {
if id == "" {
id = firstString(object, "ix_id", "id")
}
if name == "" {
name = firstString(object, "name", "name_long")
}
}
}
if nested, ok := record["ix"]; ok {
var object map[string]json.RawMessage
if json.Unmarshal(nested, &object) == nil {
if id == "" {
id = firstString(object, "id", "ix_id")
}
if name == "" {
name = firstString(object, "name", "name_long")
}
}
}
if id == "" && name == "" {
continue
}
result = append(result, ASNRelationship{
Name: name, Kind: RelationshipIXP, Source: "peeringdb",
Status: RelationshipAvailable, IXPID: id,
})
}
return dedupeRelationships(result), nil
}
func firstString(values map[string]json.RawMessage, keys ...string) string {
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
var value string
if json.Unmarshal(raw, &value) == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
if number, err := parseJSONASN(raw); err == nil {
return number
}
}
return ""
}
func sortRelationships(report *ASNRelationshipReport) {
report.Upstreams = dedupeRelationships(report.Upstreams)
report.Peers = dedupeRelationships(report.Peers)
report.IXPs = dedupeRelationships(report.IXPs)
sort.Slice(report.SourceStatuses, func(i, j int) bool {
return report.SourceStatuses[i].Source < report.SourceStatuses[j].Source
})
}

View File

@ -0,0 +1,182 @@
package bgptools
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestQueryASNRelationshipsFixture(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/ripe":
if got := r.URL.Query().Get("resource"); got != "64500" {
t.Errorf("resource query = %q", got)
}
_, _ = w.Write([]byte(`{"data":{"neighbours":[{"asn":64501,"name":"Transit Example","relationship":"upstream","power":12.5},{"asn":"64502","name":"Peer Example","type":"peer","power":3.25},{"asn":64500,"name":"target"}]}}`))
case "/peering":
if got := r.URL.Query().Get("asn"); got != "64500" {
t.Errorf("asn query = %q", got)
}
_, _ = w.Write([]byte(`{"data":[{"id":101,"ix_id":7,"name":"Example IX"},{"ixlan":{"id":202,"name":"Nested IX"}}]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
report, err := QueryASNRelationships(context.Background(), "AS64500", RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL + "/ripe",
PeeringDBURL: server.URL + "/peering",
Timeout: time.Second,
})
if err != nil {
t.Fatal(err)
}
if report.TargetASN != "64500" || report.Status != RelationshipAvailable {
t.Fatalf("unexpected report status: %+v", report)
}
if len(report.Upstreams) != 1 || report.Upstreams[0].ASN != "64501" || report.Upstreams[0].Source != "ripestat" {
t.Fatalf("unexpected upstreams: %+v", report.Upstreams)
}
if len(report.Peers) != 1 || report.Peers[0].ASN != "64502" {
t.Fatalf("unexpected peers: %+v", report.Peers)
}
if len(report.IXPs) != 2 || report.IXPs[0].Kind != RelationshipIXP {
t.Fatalf("unexpected IXPs: %+v", report.IXPs)
}
for _, source := range report.SourceStatuses {
if source.Status != RelationshipAvailable || source.RateLimited || source.Timeout {
t.Fatalf("unexpected source status: %+v", source)
}
}
}
func TestQueryASNRelationshipsRateLimitAndTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/limited":
w.WriteHeader(http.StatusTooManyRequests)
case "/slow":
<-r.Context().Done()
default:
_, _ = w.Write([]byte(`{"data":[]}`))
}
}))
defer server.Close()
limited, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL + "/limited",
PeeringDBURL: server.URL + "/peering",
Timeout: time.Second,
})
if err != nil {
t.Fatal(err)
}
if limited.Status != RelationshipPartial || !limited.RateLimited {
t.Fatalf("expected partial rate-limited report: %+v", limited)
}
if status := sourceStatusByName(limited, "ripestat"); status.Status != RelationshipRateLimited || !status.RateLimited || status.Timeout {
t.Fatalf("unexpected rate-limit source: %+v", status)
}
timed, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL + "/slow",
PeeringDBURL: server.URL + "/slow",
Timeout: 20 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if timed.Status != RelationshipTimeout || !timed.Timeout {
t.Fatalf("expected timeout report: %+v", timed)
}
for _, source := range timed.SourceStatuses {
if source.Status != RelationshipTimeout || !source.Timeout || source.RateLimited {
t.Fatalf("unexpected timeout source: %+v", source)
}
}
}
func TestQueryASNRelationshipsFieldDrift(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/ripe" {
_, _ = w.Write([]byte(`{"data":{"neighbors":[]}}`))
return
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
report, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL + "/ripe",
PeeringDBURL: server.URL + "/peering",
Timeout: time.Second,
})
if err != nil {
t.Fatal(err)
}
if report.Status != RelationshipMissingFields {
t.Fatalf("expected missing_fields report: %+v", report)
}
for _, source := range report.SourceStatuses {
if source.Status != RelationshipMissingFields {
t.Fatalf("expected missing_fields source, got %+v", source)
}
}
}
func TestQueryASNRelationshipsContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
started := time.Now()
_, err := QueryASNRelationships(ctx, "64500", RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL,
PeeringDBURL: server.URL,
Timeout: time.Second,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if time.Since(started) > 200*time.Millisecond {
t.Fatalf("cancellation was not immediate: %v", time.Since(started))
}
}
func TestNormalizeASN(t *testing.T) {
for _, input := range []string{"AS64500", " as64500 ", "64500"} {
if got, err := normalizeASN(input); err != nil || got != "64500" {
t.Fatalf("normalizeASN(%q) = %q, %v", input, got, err)
}
}
for _, input := range []string{"", "AS0", "AS4294967296", "AS-not-an-asn"} {
if _, err := normalizeASN(input); err == nil {
t.Fatalf("normalizeASN(%q) unexpectedly succeeded", input)
}
}
}
func sourceStatusByName(report *ASNRelationshipReport, name string) RelationshipSourceStatus {
for _, source := range report.SourceStatuses {
if strings.EqualFold(source.Source, name) {
return source
}
}
return RelationshipSourceStatus{Source: name, Status: RelationshipError}
}

630
bgptools/report.go Normal file
View File

@ -0,0 +1,630 @@
package bgptools
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// ReportStatus is the provider-neutral status used by the unified IP/BGP
// report. A report deliberately keeps source-level failures instead of
// turning them into an empty successful result.
type ReportStatus string
const (
ReportAvailable ReportStatus = "available"
ReportPartial ReportStatus = "partial"
ReportRateLimited ReportStatus = "rate_limited"
ReportTimeout ReportStatus = "timeout"
ReportMissingFields ReportStatus = "missing_fields"
ReportError ReportStatus = "error"
ReportUnsupported ReportStatus = "unsupported"
)
// IPBGPSourceStatus records the outcome of one logical source in a unified
// report. Error is intentionally a short diagnostic, never a raw response.
type IPBGPSourceStatus struct {
Source string `json:"source"`
Status ReportStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// RIRInfo describes the registry inference and its evidence source. The
// inference is conservative: country and address ownership are not used as a
// substitute for an explicit RDAP/WHOIS registry signal.
type RIRInfo struct {
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Status ReportStatus `json:"status"`
}
// GeofeedResult contains an RDAP/WHOIS geofeed URL and, when requested, the
// bounded fetch result. The payload itself is not retained in the report.
type GeofeedResult struct {
URL string `json:"url"`
Status ReportStatus `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
Bytes int64 `json:"bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// WHOISRecord is the small, structured subset used when RDAP is unavailable
// or missing required fields. Raw port-43 text is never returned.
type WHOISRecord struct {
Server string `json:"server"`
Status ReportStatus `json:"status"`
Prefixes []string `json:"prefixes,omitempty"`
RegistrationDate *time.Time `json:"registration_date,omitempty"`
GeofeedURLs []string `json:"geofeed_urls,omitempty"`
RIR RIRInfo `json:"rir"`
}
// IPBGPReport combines RDAP, registry metadata, optional geofeed fetches and
// ASN relationship data. Relationships are requested only when ASN or an
// injected ASN resolver supplies a target ASN.
type IPBGPReport struct {
IP string `json:"ip"`
ASN string `json:"asn,omitempty"`
Status ReportStatus `json:"status"`
RDAP *RDAPRecord `json:"rdap,omitempty"`
WHOIS *WHOISRecord `json:"whois,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
PrefixSource string `json:"prefix_source,omitempty"`
RegistrationDate *time.Time `json:"registration_date,omitempty"`
RIR RIRInfo `json:"rir"`
Geofeeds []GeofeedResult `json:"geofeeds,omitempty"`
Relationships *ASNRelationshipReport `json:"relationships,omitempty"`
Sources []IPBGPSourceStatus `json:"sources"`
}
// ASNResolver allows a caller to supply an existing, structured IP-to-ASN
// result without making this package invent a provider or read environment
// variables.
type ASNResolver func(context.Context, string) (string, error)
// WHOISDialContext is injectable so port-43 fallback can be tested entirely
// with a local TCP fixture.
type WHOISDialContext func(context.Context, string, string) (net.Conn, error)
// IPBGPReportConfig keeps every network dependency explicit and injectable.
// WHOIS fallback is opt-in; when enabled it always has its own finite timeout.
type IPBGPReportConfig struct {
Timeout time.Duration
RDAPClient *http.Client
RDAPBaseURL string
ASN string
ResolveASN ASNResolver
Relationships RelationshipConfig
FetchGeofeed bool
GeofeedClient *http.Client
GeofeedTimeout time.Duration
MaxGeofeedBytes int64
EnableWHOISFallback bool
WHOISServer string
WHOISTimeout time.Duration
MaxWHOISBytes int64
WHOISDialContext WHOISDialContext
WHOISBootstrapClient *http.Client
WHOISBootstrapURL string
MaxWHOISBootstrapBytes int64
}
const (
defaultIPBGPReportTimeout = 15 * time.Second
defaultGeofeedTimeout = 3 * time.Second
defaultWHOISTimeout = 3 * time.Second
defaultGeofeedBytes = 1 << 20
defaultWHOISBytes = 1 << 20
)
// QueryIPBGPReport executes the structured IP/BGP collection with a bounded
// context. Provider failures produce a partial report; invalid input and
// caller cancellation are returned as errors for compatibility with the
// lower-level APIs.
func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
if parent == nil {
parent = context.Background()
}
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return nil, ErrInvalidIPAddress
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultIPBGPReportTimeout
}
ctx, cancel := context.WithTimeout(parent, cfg.Timeout)
defer cancel()
report := &IPBGPReport{IP: parsed.String(), Status: ReportError}
rdap, rdapErr := QueryRDAP(ctx, report.IP, cfg.RDAPClient, cfg.RDAPBaseURL)
rdapStatus := reportStatusForError(rdapErr)
if rdapErr == nil {
report.RDAP = &rdap
report.Prefixes = uniqueSortedStrings(rdap.Prefixes)
if len(report.Prefixes) > 0 {
report.PrefixSource = "rdap"
}
report.RegistrationDate = rdap.RegistrationDate
rdapStatus = ReportAvailable
if len(report.Prefixes) == 0 || report.RegistrationDate == nil {
rdapStatus = ReportMissingFields
}
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rdap", Status: rdapStatus})
} else {
report.Sources = append(report.Sources, sourceStatusForError("rdap", rdapStatus, rdapErr))
}
report.RIR = inferRIR(rdap, rdapErr == nil)
if report.RIR.Status == "" {
report.RIR.Status = ReportMissingFields
}
if cfg.EnableWHOISFallback && (rdapErr != nil || len(report.Prefixes) == 0 || report.RegistrationDate == nil || report.RIR.Status != ReportAvailable) {
server := strings.TrimSpace(cfg.WHOISServer)
if server == "" {
server = strings.TrimSpace(rdap.Port43)
}
if server == "" {
resolved, err := resolveWHOISServer(ctx, report.IP, cfg)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("whois_bootstrap", reportStatusForError(err), err))
} else {
server = resolved
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois_bootstrap", Status: ReportAvailable})
}
}
if server == "" {
status := IPBGPSourceStatus{Source: "whois", Status: ReportUnsupported, Error: "no port-43 server resolved"}
report.Sources = append(report.Sources, status)
} else {
whois, err := queryWHOIS(ctx, report.IP, server, cfg)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("whois", reportStatusForError(err), err))
} else {
report.WHOIS = &whois
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois", Status: whois.Status})
if len(report.Prefixes) == 0 && len(whois.Prefixes) > 0 {
report.Prefixes = append([]string(nil), whois.Prefixes...)
report.PrefixSource = "whois"
}
if report.RegistrationDate == nil {
report.RegistrationDate = whois.RegistrationDate
}
if report.RIR.Status != ReportAvailable && whois.RIR.Status == ReportAvailable {
report.RIR = whois.RIR
}
}
}
}
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rir", Status: report.RIR.Status})
geofeedURLs := append([]string(nil), rdap.GeofeedURLs...)
if report.WHOIS != nil {
geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...)
}
geofeedURLs = uniqueSortedStrings(geofeedURLs)
if report.RDAP != nil {
report.RDAP.GeofeedURLs = sanitizeReportURLs(report.RDAP.GeofeedURLs)
}
if report.WHOIS != nil {
report.WHOIS.GeofeedURLs = sanitizeReportURLs(report.WHOIS.GeofeedURLs)
}
for _, geofeedURL := range geofeedURLs {
if !cfg.FetchGeofeed {
report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"})
continue
}
result := fetchGeofeed(ctx, geofeedURL, cfg)
report.Geofeeds = append(report.Geofeeds, result)
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed", Status: result.Status, Error: result.Error})
}
asn := strings.TrimSpace(cfg.ASN)
if asn == "" && cfg.ResolveASN != nil {
resolved, err := cfg.ResolveASN(ctx, report.IP)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("asn_resolver", reportStatusForError(err), err))
} else {
asn = strings.TrimSpace(resolved)
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_resolver", Status: ReportAvailable})
}
}
if asn != "" {
normalizedASN, err := normalizeASN(asn)
if err != nil {
return report, err
}
report.ASN = normalizedASN
relationships, err := QueryASNRelationships(ctx, normalizedASN, cfg.Relationships)
if relationships != nil {
report.Relationships = relationships
}
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return report, err
}
report.Sources = append(report.Sources, sourceStatusForError("asn_relationships", reportStatusForError(err), err))
} else {
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: reportStatusFromRelationship(relationships.Status)})
}
} else {
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: ReportUnsupported, Error: "ASN not supplied"})
}
if err := ctx.Err(); err != nil {
return report, err
}
report.Prefixes = uniqueSortedStrings(report.Prefixes)
sort.SliceStable(report.Sources, func(i, j int) bool { return report.Sources[i].Source < report.Sources[j].Source })
report.Status = aggregateReportStatus(report.Sources)
return report, nil
}
// QueryIPBGP is a short alias for callers that prefer the domain name over the
// implementation-oriented Report suffix.
func QueryIPBGP(ctx context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
return QueryIPBGPReport(ctx, ip, cfg)
}
func inferRIR(record RDAPRecord, available bool) RIRInfo {
if !available {
return RIRInfo{Status: ReportMissingFields}
}
text := strings.Join([]string{record.Source, record.Port43, record.Handle, record.ParentHandle}, " ")
if name := rirNameFromText(text); name != "" {
return RIRInfo{Name: name, Source: "rdap", Status: ReportAvailable}
}
return RIRInfo{Source: "rdap", Status: ReportMissingFields}
}
func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult {
result := GeofeedResult{URL: sanitizeReportURL(rawURL)}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
result.Status = ReportUnsupported
result.Error = "unsupported geofeed URL"
return result
}
timeout := cfg.GeofeedTimeout
if timeout <= 0 {
timeout = defaultGeofeedTimeout
}
maxBytes := cfg.MaxGeofeedBytes
if maxBytes <= 0 {
maxBytes = defaultGeofeedBytes
}
client := cfg.GeofeedClient
if client == nil {
client = &http.Client{}
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
result.Status = ReportError
result.Error = stableReportError(result.Status, err)
return result
}
req.Header.Set("Accept", "text/csv, text/plain, */*")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-geofeed/1")
resp, err := client.Do(req)
if err != nil {
result.Status = reportStatusForError(err)
result.Error = stableReportError(result.Status, err)
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode == http.StatusTooManyRequests {
result.Status = ReportRateLimited
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
result.Status = ReportTimeout
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = ReportError
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
result.Status = reportStatusForError(err)
result.Error = stableReportError(result.Status, err)
return result
}
if int64(len(body)) > maxBytes {
result.Status = ReportError
result.Error = fmt.Sprintf("response exceeds %d bytes", maxBytes)
return result
}
result.Status = ReportAvailable
result.Bytes = int64(len(body))
return result
}
func queryWHOIS(parent context.Context, ip, server string, cfg IPBGPReportConfig) (WHOISRecord, error) {
address, err := normalizeWHOISAddress(server)
if err != nil {
return WHOISRecord{}, err
}
timeout := cfg.WHOISTimeout
if timeout <= 0 {
timeout = defaultWHOISTimeout
}
maxBytes := cfg.MaxWHOISBytes
if maxBytes <= 0 {
maxBytes = defaultWHOISBytes
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
dial := cfg.WHOISDialContext
if dial == nil {
dialer := &net.Dialer{}
dial = dialer.DialContext
}
conn, err := dial(ctx, "tcp", address)
if err != nil {
return WHOISRecord{}, err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if _, err := fmt.Fprintf(conn, "%s\r\n", ip); err != nil {
return WHOISRecord{}, err
}
body, err := io.ReadAll(io.LimitReader(conn, maxBytes+1))
if err != nil {
return WHOISRecord{}, err
}
if int64(len(body)) > maxBytes {
return WHOISRecord{}, fmt.Errorf("whois response exceeds %d bytes", maxBytes)
}
result, err := parseWHOISRecord(address, body)
if err != nil {
return WHOISRecord{}, err
}
if len(result.Prefixes) == 0 && result.RegistrationDate == nil && result.RIR.Status != ReportAvailable && len(result.GeofeedURLs) == 0 {
result.Status = ReportMissingFields
} else {
result.Status = ReportAvailable
}
return result, nil
}
func normalizeWHOISAddress(server string) (string, error) {
server = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(server, "whois://"), "tcp://"))
if server == "" || strings.ContainsAny(server, "/ \t\r\n") {
return "", errors.New("invalid WHOIS server")
}
if host, port, err := net.SplitHostPort(server); err == nil {
if host == "" || port == "" {
return "", errors.New("invalid WHOIS server")
}
return net.JoinHostPort(host, port), nil
}
if strings.Contains(server, ":") && net.ParseIP(server) == nil {
return "", errors.New("invalid WHOIS server")
}
return net.JoinHostPort(server, "43"), nil
}
func parseWHOISRecord(server string, body []byte) (WHOISRecord, error) {
result := WHOISRecord{Server: server, RIR: RIRInfo{Status: ReportMissingFields}}
scanner := bufio.NewScanner(strings.NewReader(string(body)))
scanner.Buffer(make([]byte, 1024), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
switch key {
case "cidr", "route", "route6", "inet6route", "prefix":
for _, item := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\t' }) {
if normalized, ok := normalizeWHOISPrefix(item); ok {
result.Prefixes = append(result.Prefixes, normalized)
}
}
case "registration date", "regdate", "created", "created date", "creation date":
if parsed, ok := parseWHOISDate(value); ok {
result.RegistrationDate = &parsed
}
case "geofeed", "geofeed url", "geofeed-url":
if parsed, err := url.Parse(value); err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") {
result.GeofeedURLs = append(result.GeofeedURLs, parsed.String())
}
case "rir", "registry", "source", "whois server":
if name := rirNameFromText(value); name != "" {
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
}
}
}
if err := scanner.Err(); err != nil {
return WHOISRecord{}, err
}
result.Prefixes = uniqueSortedStrings(result.Prefixes)
result.GeofeedURLs = uniqueSortedStrings(result.GeofeedURLs)
if result.RIR.Status != ReportAvailable {
if name := rirNameFromText(server); name != "" {
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
}
}
return result, nil
}
func normalizeWHOISPrefix(value string) (string, bool) {
value = strings.TrimSpace(value)
_, network, err := net.ParseCIDR(value)
if err != nil {
return "", false
}
return network.String(), true
}
func parseWHOISDate(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
for _, layout := range []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
"02-Jan-2006",
"20060102",
} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func rirNameFromText(value string) string {
tokens := strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
})
registries := map[string]string{
"arin": "ARIN",
"ripe": "RIPE NCC",
"apnic": "APNIC",
"lacnic": "LACNIC",
"afrinic": "AFRINIC",
}
for _, token := range tokens {
if name := registries[token]; name != "" {
return name
}
}
return ""
}
func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus {
item := IPBGPSourceStatus{Source: source, Status: status}
if err != nil {
item.Error = stableReportError(status, err)
}
return item
}
func stableReportError(status ReportStatus, err error) string {
if errors.Is(err, context.Canceled) {
return "canceled"
}
switch status {
case ReportRateLimited:
return "rate_limited"
case ReportTimeout:
return "timeout"
case ReportUnsupported:
return "unsupported"
case ReportMissingFields:
return "missing_fields"
default:
return "request_failed"
}
}
func sanitizeReportURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
return parsed.String()
}
func sanitizeReportURLs(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if sanitized := sanitizeReportURL(value); sanitized != "" {
result = append(result, sanitized)
}
}
return uniqueSortedStrings(result)
}
func reportStatusForError(err error) ReportStatus {
if err == nil {
return ReportAvailable
}
if errors.Is(err, ErrRDAPRateLimited) || errors.Is(err, ErrOriginASNRateLimited) {
return ReportRateLimited
}
if errors.Is(err, context.DeadlineExceeded) || netErrTimeout(err) {
return ReportTimeout
}
return ReportError
}
func reportStatusFromRelationship(status RelationshipStatus) ReportStatus {
switch status {
case RelationshipAvailable:
return ReportAvailable
case RelationshipPartial:
return ReportPartial
case RelationshipRateLimited:
return ReportRateLimited
case RelationshipTimeout:
return ReportTimeout
case RelationshipMissingFields:
return ReportMissingFields
case RelationshipUnsupported:
return ReportUnsupported
default:
return ReportError
}
}
func aggregateReportStatus(sources []IPBGPSourceStatus) ReportStatus {
if len(sources) == 0 {
return ReportUnsupported
}
hasAvailable := false
var first ReportStatus
for _, source := range sources {
if source.Status == ReportAvailable {
hasAvailable = true
continue
}
if first == "" {
first = source.Status
}
}
if first == "" {
return ReportAvailable
}
if hasAvailable {
return ReportPartial
}
for _, source := range sources {
if source.Status != first {
return ReportPartial
}
}
return first
}

252
bgptools/report_test.go Normal file
View File

@ -0,0 +1,252 @@
package bgptools
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestQueryIPBGPReportOfflineFixtures(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/rdap/"):
if got := strings.TrimPrefix(r.URL.Path, "/rdap/"); got != "192.0.2.1" {
t.Errorf("RDAP IP = %q", got)
}
w.Header().Set("Content-Type", "application/rdap+json")
_, _ = fmt.Fprintf(w, `{"handle":"NET-ARIN-TEST","port43":"whois.arin.net","cidr0_cidrs":[{"v4prefix":"192.0.2.42","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":%q}]}`, server.URL+"/geofeed.csv")
case r.URL.Path == "/geofeed.csv":
_, _ = io.WriteString(w, "192.0.2.0/24,US,US-CA,Example City,\n")
case r.URL.Path == "/ripe":
if got := r.URL.Query().Get("resource"); got != "64500" {
t.Errorf("resource query = %q", got)
}
_, _ = io.WriteString(w, `{"data":{"neighbours":[{"asn":64501,"name":"Transit","relationship":"upstream"},{"asn":64502,"name":"Peer","relationship":"peer"}]}}`)
case r.URL.Path == "/peering":
_, _ = io.WriteString(w, `{"data":[{"ix_id":7,"name":"Example IX"}]}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{
Timeout: time.Second,
RDAPClient: server.Client(),
RDAPBaseURL: server.URL + "/rdap",
FetchGeofeed: true,
GeofeedClient: server.Client(),
ResolveASN: func(_ context.Context, ip string) (string, error) {
if ip != "192.0.2.1" {
t.Fatalf("resolver IP = %q", ip)
}
return "AS64500", nil
},
Relationships: RelationshipConfig{
Client: server.Client(),
RIPEstatURL: server.URL + "/ripe",
PeeringDBURL: server.URL + "/peering",
Timeout: time.Second,
},
})
if err != nil {
t.Fatal(err)
}
if report.Status != ReportAvailable || report.IP != "192.0.2.1" || report.ASN != "64500" {
t.Fatalf("unexpected report identity/status: %+v", report)
}
if len(report.Prefixes) != 1 || report.Prefixes[0] != "192.0.2.0/24" || report.PrefixSource != "rdap" {
t.Fatalf("unexpected prefixes: %+v source=%q", report.Prefixes, report.PrefixSource)
}
if report.RIR.Name != "ARIN" || report.RIR.Status != ReportAvailable || report.RegistrationDate == nil {
t.Fatalf("unexpected registry fields: RIR=%+v registration=%v", report.RIR, report.RegistrationDate)
}
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportAvailable || report.Geofeeds[0].Bytes == 0 {
t.Fatalf("unexpected geofeed result: %+v", report.Geofeeds)
}
if report.Relationships == nil || len(report.Relationships.Upstreams) != 1 || len(report.Relationships.Peers) != 1 || len(report.Relationships.IXPs) != 1 {
t.Fatalf("unexpected relationships: %+v", report.Relationships)
}
}
func TestQueryIPBGPReportWHOISFallbackTCPFixture(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
tcpErr := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
tcpErr <- err
return
}
defer conn.Close()
query, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
tcpErr <- err
return
}
if strings.TrimSpace(query) != "203.0.113.9" {
tcpErr <- fmt.Errorf("WHOIS query = %q", query)
return
}
_, err = io.WriteString(conn, "CIDR: 203.0.113.99/24\r\nRegDate: 2021-02-03\r\nSource: ARIN\r\nGeofeed: https://example.test/geofeed.csv\r\n")
tcpErr <- err
}()
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer rdapServer.Close()
report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{
Timeout: time.Second,
RDAPClient: rdapServer.Client(),
RDAPBaseURL: rdapServer.URL,
EnableWHOISFallback: true,
WHOISServer: listener.Addr().String(),
WHOISTimeout: 250 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if err := <-tcpErr; err != nil {
t.Fatal(err)
}
if report.Status != ReportPartial || report.WHOIS == nil || report.WHOIS.Status != ReportAvailable {
t.Fatalf("unexpected fallback status: %+v", report)
}
if report.PrefixSource != "whois" || len(report.Prefixes) != 1 || report.Prefixes[0] != "203.0.113.0/24" {
t.Fatalf("unexpected WHOIS prefixes: %+v", report.Prefixes)
}
if report.RIR.Name != "ARIN" || report.RIR.Source != "whois" || report.RegistrationDate == nil {
t.Fatalf("unexpected WHOIS metadata: RIR=%+v registration=%v", report.RIR, report.RegistrationDate)
}
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportUnsupported {
t.Fatalf("disabled geofeed fetch was not explicit: %+v", report.Geofeeds)
}
}
func TestQueryIPBGPReportWHOISTimeoutIsBounded(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan struct{})
go func() {
defer close(done)
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_, _ = bufio.NewReader(conn).ReadString('\n')
_, _ = io.Copy(io.Discard, conn)
}()
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer rdapServer.Close()
started := time.Now()
report, err := QueryIPBGPReport(context.Background(), "198.51.100.1", IPBGPReportConfig{
Timeout: time.Second,
RDAPClient: rdapServer.Client(),
RDAPBaseURL: rdapServer.URL,
EnableWHOISFallback: true,
WHOISServer: listener.Addr().String(),
WHOISTimeout: 30 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("WHOIS timeout took %v", elapsed)
}
if source := unifiedSourceByName(report, "whois"); source.Status != ReportTimeout {
t.Fatalf("WHOIS source = %+v, want timeout", source)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("TCP fixture did not observe connection cleanup")
}
}
func TestQueryIPBGPReportResolvesWHOISFromIANABootstrap(t *testing.T) {
bootstrap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"services":[[["203.0.113.0/24"],["https://rdap.arin.net/registry/"]]]}`)
}))
defer bootstrap.Close()
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer rdapServer.Close()
dialed := make(chan string, 1)
report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{
Timeout: time.Second,
RDAPClient: rdapServer.Client(),
RDAPBaseURL: rdapServer.URL,
EnableWHOISFallback: true,
WHOISBootstrapClient: bootstrap.Client(),
WHOISBootstrapURL: bootstrap.URL,
WHOISTimeout: 250 * time.Millisecond,
WHOISDialContext: func(_ context.Context, _, address string) (net.Conn, error) {
dialed <- address
client, server := net.Pipe()
go func() {
defer server.Close()
_, _ = bufio.NewReader(server).ReadString('\n')
_, _ = io.WriteString(server, "CIDR: 203.0.113.0/24\r\nSource: ARIN\r\n")
}()
return client, nil
},
})
if err != nil {
t.Fatal(err)
}
if got := <-dialed; got != "whois.arin.net:43" {
t.Fatalf("WHOIS address = %q", got)
}
if report.WHOIS == nil || report.WHOIS.Status != ReportAvailable || unifiedSourceByName(report, "whois_bootstrap").Status != ReportAvailable {
t.Fatalf("bootstrap WHOIS evidence missing: %+v", report)
}
}
func TestQueryIPBGPReportRejectsInvalidInput(t *testing.T) {
if _, err := QueryIPBGPReport(context.Background(), "", IPBGPReportConfig{}); !errors.Is(err, ErrInvalidIPAddress) {
t.Fatalf("error = %v, want %v", err, ErrInvalidIPAddress)
}
}
func TestRIRInferenceRequiresTokenBoundary(t *testing.T) {
if got := rirNameFromText("https://example.test/stripe-network"); got != "" {
t.Fatalf("false RIR inference = %q", got)
}
if got := rirNameFromText("whois.ripe.net"); got != "RIPE NCC" {
t.Fatalf("RIR inference = %q, want RIPE NCC", got)
}
}
func unifiedSourceByName(report *IPBGPReport, name string) IPBGPSourceStatus {
for _, source := range report.Sources {
if source.Source == name {
return source
}
}
return IPBGPSourceStatus{Source: name, Status: ReportError, Error: "not found"}
}

125
bgptools/whois_bootstrap.go Normal file
View File

@ -0,0 +1,125 @@
package bgptools
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
)
const (
defaultIPv4BootstrapURL = "https://data.iana.org/rdap/ipv4.json"
defaultIPv6BootstrapURL = "https://data.iana.org/rdap/ipv6.json"
defaultWHOISBootstrapSize = 4 << 20
)
var errWHOISBootstrapMissing = errors.New("IANA bootstrap has no WHOIS service for IP")
// resolveWHOISServer uses the IANA RDAP bootstrap only to identify the
// responsible RIR. The actual fallback remains a bounded port-43 query.
func resolveWHOISServer(ctx context.Context, ip string, cfg IPBGPReportConfig) (string, error) {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return "", ErrInvalidIPAddress
}
bootstrapURL := strings.TrimSpace(cfg.WHOISBootstrapURL)
if bootstrapURL == "" {
bootstrapURL = defaultIPv6BootstrapURL
if parsed.To4() != nil {
bootstrapURL = defaultIPv4BootstrapURL
}
}
client := cfg.WHOISBootstrapClient
if client == nil {
client = cfg.RDAPClient
}
if client == nil {
client = &http.Client{}
}
maximum := cfg.MaxWHOISBootstrapBytes
if maximum <= 0 {
maximum = defaultWHOISBootstrapSize
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bootstrapURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-whois-bootstrap/1")
response, err := client.Do(req)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("IANA bootstrap HTTP %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maximum+1))
if err != nil {
return "", err
}
if int64(len(body)) > maximum {
return "", fmt.Errorf("IANA bootstrap response exceeds %d bytes", maximum)
}
var payload struct {
Services [][][]string `json:"services"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
bestPrefix := -1
bestServer := ""
for _, service := range payload.Services {
if len(service) < 2 {
continue
}
server := whoisServerForRDAPURLs(service[1])
if server == "" {
continue
}
for _, prefix := range service[0] {
_, network, err := net.ParseCIDR(strings.TrimSpace(prefix))
if err != nil || !network.Contains(parsed) {
continue
}
ones, _ := network.Mask.Size()
if ones > bestPrefix {
bestPrefix = ones
bestServer = server
}
}
}
if bestServer == "" {
return "", errWHOISBootstrapMissing
}
return bestServer, nil
}
func whoisServerForRDAPURLs(values []string) string {
for _, value := range values {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil {
continue
}
host := strings.ToLower(parsed.Hostname())
switch {
case strings.Contains(host, "arin.net"):
return "whois.arin.net"
case strings.Contains(host, "apnic.net"):
return "whois.apnic.net"
case strings.Contains(host, "ripe.net"):
return "whois.ripe.net"
case strings.Contains(host, "afrinic.net"):
return "whois.afrinic.net"
case strings.Contains(host, "lacnic.net"):
return "whois.lacnic.net"
}
}
return ""
}

View File

@ -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()

View File

@ -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
} }

View File

@ -1,30 +1,28 @@
2400:9380:9002 2400:9380:9002::/48
2400:9380:9115 2400:9380:9115::/48
2400:9380:9116 2400:9380:9116::/48
2400:9380:9206 2400:9380:9206::/48
2400:9380:9262 2400:9380:a003::/48
2400:9380:a003 2400:9380:a00a::/48
2400:9380:a00a 2400:9380:a00c::/48
2400:9380:a00c 2400:9380:a014::/48
2400:9380:a014 2400:9380:a016::/48
2400:9380:a016 2400:9380:a01f::/48
2400:9380:a01f 2400:9380:a022::/48
2400:9380:a022 2400:9380:a028::/48
2400:9380:a026 2400:9380:a042::/48
2400:9380:a028 2400:9380:a110::/48
2400:9380:a042 240e:97d:8000::/48
2400:9380:a110 2804:1e48:9003::/48
240e:97d:8000 2a04:f580:9001::/48
2804:1e48:9003 2a04:f580:9090::/48
2a04:f580:9001 2a04:f581:110b::/48
2a04:f580:9090 2a04:f581:a123::/48
2a04:f581:110b 2a04:f581:a125::/48
2a04:f581:a123 2c0f:f7a8:1::/48
2a04:f581:a125 2c0f:f7a8:24::/48
2c0f:f7a8:1 2c0f:f7a8:29::/48
2c0f:f7a8:2 2c0f:f7a8:2::/48
2c0f:f7a8:24 2c0f:f7a8:37::/48
2c0f:f7a8:29 2c0f:f7a8:47::/48
2c0f:f7a8:37 2c0f:f7a8:9011::/48
2c0f:f7a8:47
2c0f:f7a8:9011

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as23764.txt",
"count": 28,
"sha256": "d853ef48792875af80682ebfb9817bbbcfefd0f904cf057568664d3bdb9373bb",
"generated_at": "2026-07-20T19:12:53Z"
}

View File

@ -1,255 +1,629 @@
2400:9380:8001 2400:9380:8001::/48
2400:9380:8003 2400:9380:8003::/48
2400:9380:8021 2400:9380:8021::/48
2400:9380:8040 2400:9380:8040::/48
2400:9380:8140 2400:9380:8140::/48
2400:9380:8201 2400:9380:8201::/48
2400:9380:8301 2400:9380:8301::/48
240e 240e:100:8000::/34
240e:1 240e:100:c000::/34
240e:100 240e:101::/34
240e:101 240e:103:c000::/34
240e:103 240e:104:4000::/36
240e:104 240e:104:7000::/36
240e:106 240e:104:c000::/34
240e:108 240e:106:4000::/34
240e:11 240e:108:1100::/44
240e:11:8001 240e:108:1110::/44
240e:12 240e:108:1120::/44
240e:144 240e:108:1130::/44
240e:16:1001 240e:108:1140::/44
240e:16:1008 240e:108:1150::/44
240e:16:1009 240e:108:1160::/44
240e:184 240e:108:1180::/44
240e:1a 240e:108:1190::/44
240e:1c7 240e:108:11a0::/44
240e:1c:112 240e:108:11b0::/44
240e:2 240e:108:11c0::/44
240e:218 240e:108:11d0::/44
240e:219 240e:108:11e0::/44
240e:21a 240e:108:1200::/44
240e:21b 240e:108:1210::/44
240e:224 240e:108:12a0::/44
240e:225 240e:108:12c0::/44
240e:226 240e:108:12d0::/44
240e:227 240e:108:1300::/44
240e:24 240e:108:1310::/44
240e:240 240e:108:1320::/44
240e:241 240e:108:1330::/44
240e:242 240e:108:1340::/44
240e:243 240e:108:1350::/44
240e:244 240e:108:1360::/44
240e:245 240e:108:1370::/44
240e:246 240e:108:1380::/44
240e:247 240e:108:1390::/44
240e:2c 240e:108:13a0::/44
240e:318 240e:108:13b0::/44
240e:319 240e:108:13c0::/44
240e:31a 240e:108:13d0::/44
240e:31b 240e:108:13e0::/44
240e:324 240e:11:8001::/48
240e:325 240e:12::/32
240e:326 240e:144:c000::/34
240e:327 240e:14::/31
240e:340 240e:16:1001::/48
240e:341 240e:16:1008::/48
240e:342 240e:184:c000::/34
240e:343 240e:1:8000::/33
240e:350:205 240e:1::/33
240e:41 240e:1c:112::/48
240e:418 240e:1c::/31
240e:419 240e:204::/30
240e:41a 240e:208::/30
240e:41b 240e:20c::/30
240e:42 240e:210::/30
240e:424 240e:214::/30
240e:425 240e:218::/32
240e:426 240e:219::/32
240e:427 240e:21a::/32
240e:42:4000 240e:21b::/32
240e:43 240e:21c::/30
240e:43:8000 240e:220::/30
240e:44 240e:224::/32
240e:440 240e:225::/32
240e:441 240e:226::/32
240e:442 240e:227::/32
240e:443 240e:22c::/30
240e:444 240e:230::/30
240e:445 240e:234::/30
240e:446 240e:238::/30
240e:447 240e:23c::/30
240e:45 240e:240::/32
240e:45:8000 240e:241::/32
240e:46 240e:242::/32
240e:46:5008 240e:243::/32
240e:47 240e:244::/32
240e:47:4 240e:245::/32
240e:48 240e:246::/32
240e:49 240e:247::/32
240e:4a 240e:248::/30
240e:4a:4100 240e:24:8000::/34
240e:4b 240e:24c::/30
240e:4b:2 240e:250::/30
240e:4c 240e:254::/30
240e:4c:4006 240e:258::/30
240e:4d 240e:25c::/30
240e:4d:50ff 240e:260::/30
240e:4e 240e:264::/30
240e:4e:4000 240e:268::/30
240e:4f 240e:26c::/30
240e:50 240e:270::/30
240e:51 240e:274::/30
240e:518 240e:278::/30
240e:519 240e:27c::/30
240e:51a 240e:2::/33
240e:51b 240e:2c:8000::/34
240e:52 240e:304::/30
240e:524 240e:308::/30
240e:525 240e:30c::/30
240e:526 240e:310::/30
240e:527 240e:314::/30
240e:52:4802 240e:318::/32
240e:53 240e:319::/32
240e:54 240e:31a::/32
240e:540 240e:31b::/32
240e:541 240e:31c::/30
240e:542 240e:320::/30
240e:543 240e:324::/32
240e:544 240e:325::/32
240e:545 240e:326::/32
240e:546 240e:327::/32
240e:547 240e:32c::/30
240e:55 240e:330::/30
240e:56 240e:334::/30
240e:56:4807 240e:338::/30
240e:57 240e:33c::/30
240e:5a 240e:340::/32
240e:5a:6800 240e:341::/32
240e:5b 240e:342::/32
240e:5c 240e:343::/32
240e:5d 240e:344::/30
240e:5e 240e:348::/30
240e:5f 240e:34c::/30
240e:618 240e:350:205::/48
240e:619 240e:350::/29
240e:61a 240e:358::/29
240e:61b 240e:360::/29
240e:61d:1401 240e:368::/29
240e:624 240e:370::/29
240e:625 240e:378::/29
240e:626 240e:380::/29
240e:627 240e:388::/29
240e:630:b83 240e:390::/29
240e:638 240e:398::/29
240e:638:b00 240e:3a0::/28
240e:638:b02 240e:3b0::/28
240e:64 240e:404::/30
240e:640 240e:408::/30
240e:641 240e:414::/30
240e:642 240e:418::/32
240e:643 240e:419::/32
240e:64c:340 240e:41:8000::/33
240e:650:2540 240e:41::/33
240e:650:280 240e:41a::/32
240e:650:4400 240e:41b::/32
240e:658:6c20 240e:41c::/30
240e:658:6c31 240e:420::/30
240e:658:a00 240e:424::/32
240e:659:1160 240e:425::/32
240e:659:11e0 240e:426::/32
240e:659:1b0 240e:427::/32
240e:661:6180 240e:42:4000::/48
240e:699 240e:42:8000::/33
240e:6a0 240e:42::/33
240e:6a0:10 240e:42c::/30
240e:6a0:100 240e:430::/30
240e:6a0:105 240e:434::/30
240e:6a0:b0c 240e:438::/30
240e:7 240e:43:8000::/33
240e:718 240e:43:8000::/48
240e:719 240e:43::/33
240e:71a 240e:43c::/30
240e:71b 240e:440::/32
240e:724 240e:441::/32
240e:725 240e:442::/32
240e:726 240e:443::/32
240e:727 240e:444::/32
240e:740 240e:445::/32
240e:741 240e:446::/32
240e:742 240e:447::/32
240e:743 240e:448::/30
240e:767 240e:44:8000::/33
240e:840 240e:44::/33
240e:841 240e:44c::/30
240e:9 240e:450::/30
240e:918 240e:454::/30
240e:919 240e:458::/30
240e:91a 240e:45:8000::/33
240e:91b 240e:45:8000::/48
240e:924 240e:45c::/30
240e:925 240e:460::/30
240e:926 240e:464::/30
240e:927 240e:468::/30
240e:940 240e:46:5008::/48
240e:941 240e:46:8000::/33
240e:942 240e:46::/33
240e:943 240e:46c::/30
240e:944 240e:470::/30
240e:945 240e:474::/30
240e:946 240e:478::/30
240e:947 240e:47::/32
240e:980 240e:47c::/30
240e:981 240e:48:8000::/33
240e:982 240e:48::/33
240e:983 240e:49:8000::/33
240e:9f:8340 240e:49::/33
240e:9f:8400 240e:4a:4100::/48
240e:9f:8440 240e:4a:8000::/33
240e:9f:8880 240e:4a::/33
240e:9f:88c0 240e:4b:1::/48
240e:a0c 240e:4b:8000::/33
240e:a0d 240e:4b::/33
240e:a0e 240e:4c:4006::/48
240e:a0f 240e:4c:5000::/48
240e:a18 240e:4c:8000::/33
240e:a19 240e:4c::/33
240e:a1a 240e:4d:50ff::/48
240e:a1b 240e:4d:8000::/33
240e:a24 240e:4d::/33
240e:a25 240e:4e:4000::/48
240e:a26 240e:4e:8000::/33
240e:a27 240e:4e::/33
240e:a40 240e:4f:8000::/33
240e:a41 240e:4f::/33
240e:a42 240e:504::/30
240e:a43 240e:508::/30
240e:a44 240e:50:8000::/33
240e:a45 240e:50::/33
240e:a46 240e:514::/30
240e:a47 240e:518::/32
240e:a5:8000 240e:519::/32
240e:a5:a100 240e:51::/32
240e:a5:a140 240e:51a::/32
240e:a9:8010 240e:51b::/32
240e:ab:b202 240e:51c::/30
240e:b 240e:520::/30
240e:bd:8000 240e:524::/32
240e:cd:8000 240e:525::/32
2605:9d80:8001 240e:526::/32
2605:9d80:8011 240e:527::/32
2605:9d80:8021 240e:52:4802::/48
2605:9d80:8031 240e:52:8000::/33
2605:9d80:8041 240e:52::/33
2605:9d80:8081 240e:52c::/30
2a04:f580:8010 240e:530::/30
2a04:f580:8011 240e:534::/30
2a04:f580:8090 240e:538::/30
2a04:f580:8210 240e:53:8000::/33
2a04:f580:8211 240e:53::/33
2a04:f580:8290 240e:53c::/30
2c0f:f7a8:8011 240e:540::/32
2c0f:f7a8:8050 240e:541::/32
2c0f:f7a8:805f 240e:542::/32
2c0f:f7a8:8150 240e:543::/32
2c0f:f7a8:815f 240e:544::/32
2c0f:f7a8:8211 240e:545::/32
240e:546::/32
240e:547::/32
240e:548::/30
240e:54:8000::/33
240e:54::/33
240e:54c::/30
240e:550::/30
240e:554::/30
240e:558::/30
240e:55:8000::/33
240e:55::/33
240e:55c::/30
240e:560::/30
240e:564::/30
240e:568::/30
240e:56:4807::/48
240e:56:8000::/33
240e:56::/33
240e:56c::/30
240e:570::/30
240e:574::/30
240e:578::/30
240e:57:8000::/33
240e:57::/33
240e:57c::/30
240e:5a:6800::/48
240e:5a:8000::/33
240e:5a::/33
240e:5b:8000::/33
240e:5b::/33
240e:5c:8000::/33
240e:5c::/33
240e:5d:8000::/33
240e:5d::/33
240e:5e:8000::/33
240e:5e::/33
240e:5f:8000::/33
240e:5f::/33
240e:604::/30
240e:608::/30
240e:60::/30
240e:60c::/30
240e:610::/30
240e:614::/30
240e:618::/32
240e:619::/32
240e:61a::/32
240e:61b::/32
240e:61c::/30
240e:61d:1401::/48
240e:620::/30
240e:624::/32
240e:625::/32
240e:626::/32
240e:627::/32
240e:62c::/30
240e:630::/30
240e:634::/30
240e:638::/30
240e:638::/48
240e:63c::/30
240e:640::/32
240e:641::/32
240e:642::/32
240e:643::/32
240e:644::/30
240e:648::/30
240e:64::/32
240e:64c::/30
240e:650::/29
240e:658::/29
240e:659:1160::/48
240e:660::/29
240e:668::/29
240e:670::/29
240e:678::/29
240e:680::/29
240e:688::/29
240e:690::/29
240e:698::/29
240e:6a0::/28
240e:6b0::/28
240e:704::/30
240e:708::/30
240e:70c::/30
240e:710::/30
240e:714::/30
240e:718::/32
240e:719::/32
240e:71a::/32
240e:71b::/32
240e:71c::/30
240e:720::/30
240e:724::/32
240e:725::/32
240e:726::/32
240e:727::/32
240e:72c::/30
240e:730::/30
240e:734::/30
240e:738::/30
240e:73c::/30
240e:740::/32
240e:741::/32
240e:742::/32
240e:743::/32
240e:744::/30
240e:748::/30
240e:74c::/30
240e:750::/29
240e:758::/29
240e:760::/29
240e:768::/29
240e:770::/29
240e:778::/29
240e:780::/29
240e:788::/29
240e:790::/29
240e:798::/29
240e:7::/32
240e:7a0::/28
240e:7b0::/28
240e:804::/30
240e:814::/30
240e:81c::/30
240e:820::/30
240e:82c::/30
240e:830::/30
240e:834::/30
240e:838::/30
240e:83c::/30
240e:840::/32
240e:841::/32
240e:848::/30
240e:84::/31
240e:84c::/30
240e:850::/30
240e:854::/30
240e:858::/30
240e:85c::/30
240e:860::/30
240e:864::/30
240e:868::/30
240e:86::/31
240e:86c::/30
240e:870::/30
240e:874::/30
240e:878::/30
240e:87c::/30
240e:88::/31
240e:8a::/31
240e:8c::/31
240e:8e::/31
240e:904::/30
240e:908::/30
240e:90::/31
240e:90c::/30
240e:910::/30
240e:914::/30
240e:918::/32
240e:919::/32
240e:91a::/32
240e:91b::/32
240e:91c::/30
240e:920::/30
240e:924::/32
240e:925::/32
240e:926::/32
240e:927::/32
240e:92::/31
240e:92c::/30
240e:930::/30
240e:934::/30
240e:938::/30
240e:93c::/30
240e:940::/32
240e:941::/32
240e:942::/32
240e:943::/32
240e:944::/32
240e:945::/32
240e:946::/32
240e:947::/32
240e:948::/30
240e:94c::/30
240e:950::/30
240e:954::/30
240e:958::/30
240e:95c::/30
240e:960::/30
240e:964::/30
240e:968::/30
240e:96::/31
240e:96c::/30
240e:970::/30
240e:974::/30
240e:978::/30
240e:97c::/30
240e:980:9400::/40
240e:981:f000::/36
240e:982:1000::/36
240e:982:2000::/36
240e:982:3000::/36
240e:982:4000::/36
240e:982:5000::/36
240e:982:6000::/36
240e:982:7000::/36
240e:982:8000::/36
240e:982:9000::/36
240e:982::/36
240e:982:a000::/36
240e:982:b000::/36
240e:982:c000::/36
240e:982:d000::/36
240e:982:e000::/36
240e:983:1000::/40
240e:983:100::/40
240e:983:1100::/40
240e:983:1200::/40
240e:983:1300::/40
240e:983:1400::/40
240e:983:1500::/40
240e:983:1600::/40
240e:983:1700::/40
240e:983:1800::/40
240e:983:1900::/40
240e:983:1a00::/40
240e:983:1b00::/40
240e:983:1c00::/40
240e:983:1d00::/40
240e:983:1e00::/40
240e:983::/40
240e:983:a00::/40
240e:983:c00::/40
240e:983:d00::/40
240e:983:f00::/40
240e:98::/31
240e:9::/32
240e:9a::/31
240e:9c::/30
240e::/20
240e::/24
240e:a04::/30
240e:a08::/30
240e:a0::/31
240e:a0c::/32
240e:a0d::/32
240e:a0e::/32
240e:a0f::/32
240e:a14::/30
240e:a18::/32
240e:a19::/32
240e:a1a::/32
240e:a1b::/32
240e:a1c::/30
240e:a20::/30
240e:a24::/32
240e:a25::/32
240e:a26::/32
240e:a27::/32
240e:a2::/31
240e:a2c::/30
240e:a30::/30
240e:a34::/30
240e:a38::/30
240e:a40::/32
240e:a41::/32
240e:a42::/32
240e:a43::/32
240e:a44::/32
240e:a45::/32
240e:a46::/32
240e:a47::/32
240e:a48::/30
240e:a4::/31
240e:a4c::/30
240e:a50::/30
240e:a54::/30
240e:a58::/30
240e:a5:8000::/48
240e:a5c::/30
240e:a60::/30
240e:a64::/30
240e:a68::/30
240e:a6::/31
240e:a6c::/30
240e:a70::/30
240e:a74::/30
240e:a78::/30
240e:a7c::/30
240e:a8::/31
240e:aa::/31
240e:ac::/31
240e:ae::/31
240e:b04::/30
240e:b0::/31
240e:b0c::/30
240e:b14::/30
240e:b1c::/30
240e:b20::/30
240e:b2::/31
240e:b2c::/30
240e:b30::/30
240e:b34::/30
240e:b38::/30
240e:b44::/30
240e:b48::/30
240e:b4::/31
240e:b4c::/30
240e:b50::/29
240e:b60::/29
240e:b68::/29
240e:b6::/31
240e:b70::/29
240e:b78::/29
240e:b80::/29
240e:b88::/29
240e:b8::/31
240e:b90::/29
240e:b98::/29
240e:b:8000::/33
240e:b::/33
240e:ba::/31
240e:bb0::/28
240e:bc::/31
240e:bd:8000::/48
240e:be::/31
240e:c0::/31
240e:c2::/31
240e:c4::/30
240e:c60::/30
240e:c8::/31
240e:ca::/31
240e:cc::/31
240e:cd:8000::/48
240e:ce::/31
240e:d0::/31
240e:d2::/31
240e:d4::/31
240e:d6::/31
240e:d8::/31
240e:da::/31
240e:dc::/31
240e:de::/31
240e:e8::/31
240e:ea::/31
240e:ec::/31
240e:ee::/31
240e:f0::/31
240e:f2::/31
240e:f4::/31
240e:f6::/31
240e:f8::/31
240e:fa::/31
240e:fc::/31
240e:fe::/31
2605:9d80:8001::/48
2605:9d80:8011::/48
2605:9d80:8021::/48
2605:9d80:8031::/48
2605:9d80:8041::/48
2605:9d80:8081::/48
2a04:f580:8010::/48
2a04:f580:8011::/48
2a04:f580:8090::/48
2a04:f580:8210::/48
2a04:f580:8211::/48
2a04:f580:8290::/48
2c0f:f7a8:8011::/48
2c0f:f7a8:8050::/48
2c0f:f7a8:805f::/48
2c0f:f7a8:8150::/48
2c0f:f7a8:815f::/48
2c0f:f7a8:8211::/48

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4134.txt",
"count": 629,
"sha256": "00941f76c022566166b926670ee9358084c53465cbaaf75a3da7b73d997d1947",
"generated_at": "2026-07-23T02:56:31Z"
}

View File

@ -1,173 +1,179 @@
2400:9380:9001 2400:9380:9001::/48
2400:9380:9009 2400:9380:9009::/48
2400:9380:9020 2400:9380:9020::/48
2400:9380:9021 2400:9380:9021::/48
2400:9380:9050 2400:9380:9050::/48
2400:9380:9051 2400:9380:9051::/48
2400:9380:9060 2400:9380:9060::/48
2400:9380:9071 2400:9380:9071::/48
2400:9380:9080 2400:9380:9080::/48
2400:9380:9081 2400:9380:9081::/48
2400:9380:90b1 2400:9380:90b1::/48
2400:9380:90b2 2400:9380:90b2::/48
2400:9380:90b3 2400:9380:90b3::/48
2400:9380:90b4 2400:9380:90b4::/48
2400:9380:90b5 2400:9380:90b5::/48
2400:9380:90b6 2400:9380:90b6::/48
2400:9380:90b7 2400:9380:90b7::/48
2400:9380:9121 2400:9380:9121::/48
2400:9380:9220 2400:9380:9220::/48
2400:9380:9221 2400:9380:9221::/48
2400:9380:9250 2400:9380:9250::/48
2400:9380:9251 2400:9380:9251::/48
2400:9380:9260 2400:9380:9260::/48
2400:9380:9271 2400:9380:9271::/48
2400:9380:9280 2400:9380:9280::/48
2400:9380:9281 2400:9380:9281::/48
2400:9380:92b1 2400:9380:9282::/48
2400:9380:92b2 2400:9380:92b1::/48
2400:9380:92b3 2400:9380:92b2::/48
2400:9380:92b4 2400:9380:92b3::/48
2400:9380:92b5 2400:9380:92b4::/48
2400:9380:92b6 2400:9380:92b5::/48
2400:9380:92b7 2400:9380:92b6::/48
240e 2400:9380:92b7::/48
240e:182:5401 240e:182:5401::/48
240e:182:5501 240e:182:5501::/48
240e:409:9000 240e:409:9000::/48
240e:409:9001 240e:409:9001::/48
240e:410:ff00 240e:410:ff00::/48
240e:411:ff00 240e:411:ff00::/48
240e:414 240e:414:e710::/45
240e:43d:fff1 240e:414:e718::/45
240e:43d:fff3 240e:43d:fff1::/48
240e:43d:fff4 240e:43d:fff3::/48
240e:43d:fff7 240e:43d:fff4::/48
240e:440:ac00 240e:43d:fff7::/48
240e:440:ac01 240e:440:ac00::/48
240e:440:ac02 240e:440:ac01::/48
240e:441:ac00 240e:440:ac02::/48
240e:441:ac01 240e:441:ac00::/48
240e:441:ac02 240e:441:ac01::/48
240e:445:3f00 240e:441:ac02::/48
240e:446:3f00 240e:445:3f00::/48
240e:451:bfc0 240e:446:3f00::/48
240e:451:bfe0 240e:451:bfc0::/48
240e:451:ffa0 240e:451:bfe0::/48
240e:451:ffc0 240e:451:ffa0::/48
240e:451:ffe0 240e:451:ffc0::/48
240e:456:fe00 240e:451:ffe0::/48
240e:457:fe00 240e:456:fe00::/48
240e:469:f400 240e:457:fe00::/48
240e:476:febf 240e:469:f400::/48
240e:476:feff 240e:476:febf::/48
240e:604:314 240e:476:feff::/48
240e:604:319 240e:604:314::/48
240e:60e 240e:604:319::/48
240e:60e:8000 240e:60e:8000::/48
240e:60e:8001 240e:60e:8001::/48
240e:615 240e:60e:e000::/35
240e:61d 240e:615:7000::/40
240e:628 240e:61d:e600::/40
240e:62c 240e:628:10::/45
240e:62f 240e:62c:1f0::/44
240e:638:f 240e:62f::/48
240e:63c 240e:638:f::/48
240e:640:178 240e:63c:3f00::/44
240e:640:179 240e:640:178::/48
240e:640:17a 240e:640:179::/48
240e:640:17b 240e:640:17a::/48
240e:645 240e:640:17b::/48
240e:648:1e 240e:645:1000::/36
240e:648:c00f 240e:648:1e::/48
240e:648:c40f 240e:648:c00f::/48
240e:648:c80f 240e:648:c40f::/48
240e:648:cc0f 240e:648:c80f::/48
240e:648:d00f 240e:648:cc0f::/48
240e:648:d40f 240e:648:d00f::/48
240e:648:d80f 240e:648:d40f::/48
240e:648:dc0f 240e:648:d80f::/48
240e:649:c00f 240e:648:dc0f::/48
240e:649:c40f 240e:649:c00f::/48
240e:649:c80f 240e:649:c40f::/48
240e:649:cc0f 240e:649:c80f::/48
240e:649:d00f 240e:649:cc0f::/48
240e:649:d40f 240e:649:d00f::/48
240e:649:d80f 240e:649:d40f::/48
240e:649:dc0f 240e:649:d80f::/48
240e:64e 240e:649:dc0f::/48
240e:64e:de0 240e:64e:d00::/44
240e:64e:dec 240e:64e:de0::/48
240e:64e:ded 240e:64e:dec::/48
240e:650 240e:64e:ded::/48
240e:659:f100 240e:650:8000::/38
240e:65f 240e:659:f100::/48
240e:669 240e:65f:e000::/36
240e:670 240e:669:b000::/40
240e:679:1001 240e:670:d000::/36
240e:679:800 240e:679:1001::/48
240e:699 240e:688:ed00::/48
240e:699:7a00 240e:699:7a00::/48
240e:699:7a01 240e:699:7a01::/48
240e:699:7b00 240e:699:7b00::/48
240e:699:7b06 240e:699:7b06::/48
240e:699:7b07 240e:699:7b07::/48
240e:699:7b08 240e:699:7b08::/48
240e:699:7b0a 240e:699:7b0a::/48
240e:699:7b0c 240e:699:7b0c::/48
240e:699:7b0d 240e:699:7b0d::/48
240e:699:7b0f 240e:699:7b0f::/48
240e:699:7b12 240e:699:7b11::/48
240e:6a0:d00 240e:699:7b12::/48
240e:6a0:d07 240e:699:bc00::/38
240e:6a0:d0a 240e:6a0:d00::/48
240e:713:f020 240e:6a0:d03::/48
240e:713:f021 240e:6a0:d04::/48
240e:733:4c0 240e:6a0:d07::/48
240e:733:4c1 240e:6a0:d0a::/48
240e:767:f000 240e:713:f020::/48
240e:787:7000 240e:713:f021::/48
240e:787:7001 240e:733:4c0::/48
240e:790 240e:733:4c1::/48
240e:965:822 240e:767:f000::/48
240e:eb 240e:787:7000::/48
240e:f6:8002 240e:787:7001::/48
240e:f6:8003 240e:790:ff00::/40
240e:ff:c020 240e:7b6::/31
240e:ff:c022 240e:965:822::/48
240e:ff:c027 240e:eb:e800::/37
2605:9d80:9003 240e:eb:f000::/37
2605:9d80:9013 240e:f6:8002::/48
2605:9d80:9023 240e:f6:8003::/48
2605:9d80:9033 240e:ff:c020::/48
2605:9d80:9042 240e:ff:c022::/48
2605:9d80:9071 240e:ff:c024::/48
2605:9d80:9092 240e:ff:c027::/48
2804:1e48 240e:ff:c029::/48
2804:1e48:9001 2605:9d80:9003::/48
2804:1e48:9002 2605:9d80:9013::/48
2a04:f580:9010 2605:9d80:9023::/48
2a04:f580:9012 2605:9d80:9033::/48
2a04:f580:9013 2605:9d80:9042::/48
2a04:f580:9020 2605:9d80:9071::/48
2a04:f580:9030 2605:9d80:9092::/48
2a04:f580:9040 2804:1e48:9001::/48
2a04:f580:9050 2804:1e48:9002::/48
2a04:f580:9060 2a04:f580:9010::/48
2a04:f580:9070 2a04:f580:9012::/48
2a04:f580:9080 2a04:f580:9013::/48
2a04:f580:9210 2a04:f580:9020::/48
2a04:f580:9212 2a04:f580:9030::/48
2a04:f580:9213 2a04:f580:9040::/48
2a04:f580:9220 2a04:f580:9050::/48
2a04:f580:9230 2a04:f580:9060::/48
2a04:f580:9240 2a04:f580:9070::/48
2a04:f580:9250 2a04:f580:9080::/48
2a04:f580:9260 2a04:f580:9210::/48
2a04:f580:9270 2a04:f580:9212::/48
2a04:f580:9280 2a04:f580:9213::/48
2a04:f580:9290 2a04:f580:9220::/48
2c0f:f7a8:9020 2a04:f580:9230::/48
2c0f:f7a8:9041 2a04:f580:9240::/48
2c0f:f7a8:9211 2a04:f580:9250::/48
2c0f:f7a8:9220 2a04:f580:9260::/48
2a04:f580:9270::/48
2a04:f580:9280::/48
2a04:f580:9290::/48
2c0f:f7a8:9020::/48
2c0f:f7a8:9041::/48
2c0f:f7a8:9220::/48

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4809.txt",
"count": 179,
"sha256": "1b43123532881e6dc9b08cfd56216a9eda028ff41b8ac6dfb4e8c51cb38d873c",
"generated_at": "2026-07-20T19:12:53Z"
}

View File

@ -1,418 +1,470 @@
2001:4510 2001:4510:1480::/41
2001:4511 2001:4511:1480::/41
2402:18a0 2402:18a0::/32
2402:f140:ff13 2402:f140:ff13::/48
2402:f140:ff14 2402:f140:ff14::/48
2404:6500:dcb3 2404:6500:dcb3::/48
2405:1480 2405:1480:3000::/47
2406:1e40 2406:1e40:f012::/47
2406:cac0 2406:cac0:200::/40
2407:6c40 2406:cac0::/40
2407:6c40:1500 2407:6c40:1500::/48
2408 2407:6c40:1600::/40
2408:8000 2408:8000:10fe::/48
2408:8000:10fe 2408:8000:10ff::/48
2408:8000:10ff 2408:8000:2000::/36
2408:8000:2 2408:8000:2::/48
2408:8000:3 2408:8000:3000::/36
2408:8000:5005 2408:8000:3::/48
2408:8001 2408:8000:4000::/36
2408:802a 2408:8000:5000::/36
2408:802c 2408:8000:6000::/36
2408:803e 2408:8000:7000::/36
2408:8056 2408:8000:8000::/36
2408:80c2 2408:8000:8010::/47
2408:80c5 2408:8000::/48
2408:80e2 2408:8000:a000::/36
2408:80e9 2408:8000:b000::/36
2408:80f5 2408:8000:c000::/36
2408:80f9 2408:8000:d000::/36
2408:815f 2408:8000:e000::/36
2408:8181 2408:8000:f000::/36
2408:8182 2408:8001:1000::/36
2408:8183 2408:8001:2000::/36
2408:81a2 2408:8001:4000::/36
2408:81a3 2408:8001:5000::/36
2408:8210 2408:8001:6000::/36
2408:8211 2408:8001:7000::/36
2408:8212 2408:8001:8000::/36
2408:8213 2408:8001:9000::/36
2408:8214 2408:8001::/36
2408:8215 2408:8001:a000::/36
2408:821a 2408:8001:b000::/36
2408:821b 2408:8001:c000::/36
2408:8220 2408:8001:d000::/36
2408:8221 2408:8001:e000::/36
2408:8226 2408:8001:f000::/36
2408:822a 2408:802a:8000::/33
2408:822b 2408:802c::/32
2408:822e 2408:803e::/32
2408:822f 2408:8056::/32
2408:8230 2408:80c2::/33
2408:8231 2408:80c5::/33
2408:8232 2408:80e2::/33
2408:8233 2408:80e9:4000::/34
2408:8234 2408:80f5:4000::/34
2408:8235 2408:80f9:4000::/34
2408:8236 2408:815f:e000::/35
2408:8237 2408:8181:6000::/40
2408:8238 2408:8181:8000::/40
2408:8239 2408:8181::/40
2408:823c 2408:8181:a000::/40
2408:823d 2408:8181:a220::/44
2408:8240 2408:8181:e000::/40
2408:8248 2408:8182:c000::/40
2408:8249 2408:8183:4000::/40
2408:824a 2408:8183:8000::/40
2408:824b 2408:81a2:2000::/35
2408:824c 2408:81a2:4000::/35
2408:824e 2408:81a3:6000::/35
2408:824f 2408:8210::/32
2408:8250 2408:8211::/32
2408:8251 2408:8212::/32
2408:8252 2408:8213::/32
2408:8253 2408:8214::/32
2408:8254 2408:8215::/32
2408:825c 2408:821a::/32
2408:825d 2408:821b::/32
2408:825f 2408:8220::/32
2408:8260 2408:8221::/32
2408:8262 2408:8226::/32
2408:8263 2408:822a::/32
2408:8264 2408:822b::/32
2408:8265 2408:822e::/32
2408:8266 2408:822f::/32
2408:826a 2408:8230::/32
2408:826c 2408:8231::/32
2408:826d 2408:8232::/32
2408:826e 2408:8233::/32
2408:826f 2408:8234::/32
2408:8270 2408:8235::/32
2408:8274 2408:8236::/32
2408:8275 2408:8237::/32
2408:8276 2408:8238::/32
2408:8277 2408:8239::/32
2408:8278 2408:823c::/32
2408:8279 2408:823d::/32
2408:827a 2408:8240::/32
2408:8310 2408:8244::/30
2408:8311 2408:8248::/32
2408:8312 2408:8249::/32
2408:8313 2408:824a::/32
2408:832a 2408:824b::/32
2408:832e 2408:824c::/32
2408:832f 2408:824e::/32
2408:8330 2408:824f::/32
2408:8331 2408:8250::/32
2408:8332 2408:8251::/32
2408:8333 2408:8252::/32
2408:8338 2408:8253::/32
2408:8340 2408:8254::/32
2408:8348 2408:825c::/32
2408:8349 2408:825d::/32
2408:834a 2408:825f::/32
2408:834b 2408:8260::/32
2408:834e 2408:8262::/32
2408:834f 2408:8263::/32
2408:8350 2408:8264::/32
2408:8351 2408:8265::/32
2408:8352 2408:8266::/32
2408:8353 2408:826a::/32
2408:8354 2408:826c::/32
2408:8360 2408:826d::/32
2408:8361 2408:826e::/32
2408:8362 2408:826f::/32
2408:8363 2408:8270::/32
2408:8364 2408:8274::/32
2408:8365 2408:8275::/32
2408:836c 2408:8276::/32
2408:836d 2408:8277::/32
2408:836e 2408:8278::/32
2408:836f 2408:8279::/32
2408:8374 2408:827a::/32
2408:8375 2408:8310::/32
2408:8376 2408:8311::/32
2408:8377 2408:8312::/32
2408:8378 2408:8313::/32
2408:8379 2408:832a::/32
2408:837a 2408:832e::/32
2408:8410 2408:832f::/32
2408:8411 2408:8330::/32
2408:8412 2408:8331::/32
2408:8413 2408:8332::/32
2408:8414 2408:8333::/32
2408:8415 2408:8338::/32
2408:8417 2408:8340::/32
2408:8418 2408:8344::/30
2408:841a 2408:8348::/32
2408:841b 2408:8349::/32
2408:841c 2408:834a::/32
2408:841d 2408:834b::/32
2408:841e 2408:834e::/32
2408:8420 2408:834f::/32
2408:8421 2408:8350::/32
2408:8422 2408:8351::/32
2408:8426 2408:8352::/32
2408:8427 2408:8353::/32
2408:842a 2408:8354::/32
2408:842b 2408:8360::/32
2408:842c 2408:8361::/32
2408:842e 2408:8362::/32
2408:8430 2408:8363::/32
2408:8431 2408:8364::/32
2408:8434 2408:8365::/32
2408:8435 2408:836c::/32
2408:8436 2408:836d::/32
2408:8437 2408:836e::/32
2408:8438 2408:836f::/32
2408:8439 2408:8374::/32
2408:843c 2408:8375::/32
2408:843d 2408:8376::/32
2408:843e 2408:8377::/32
2408:843f 2408:8378::/32
2408:8440 2408:8379::/32
2408:8441 2408:837a::/32
2408:8448 2408:8410::/32
2408:844b 2408:8411::/32
2408:844c 2408:8412::/32
2408:844d 2408:8413::/32
2408:844e 2408:8414::/32
2408:844f 2408:8415::/32
2408:8452 2408:8417::/32
2408:8453 2408:8418::/32
2408:8454 2408:841a::/32
2408:8459 2408:841b::/32
2408:845c 2408:841c::/32
2408:845d 2408:841d::/32
2408:8460 2408:841e::/32
2408:8461 2408:8420::/32
2408:8462 2408:8421::/32
2408:8463 2408:8422::/32
2408:8464 2408:8426::/32
2408:8465 2408:8427::/32
2408:8466 2408:842a::/32
2408:8469 2408:842b::/32
2408:846a 2408:842c::/32
2408:846b 2408:842e::/32
2408:846c 2408:8430::/32
2408:846d 2408:8431::/32
2408:846e 2408:8434::/32
2408:846f 2408:8435::/32
2408:8470 2408:8436::/32
2408:8471 2408:8437::/32
2408:8474 2408:8438::/32
2408:8475 2408:8439::/32
2408:8476 2408:843c::/32
2408:8477 2408:843d::/32
2408:8478 2408:843e::/32
2408:8479 2408:843f::/32
2408:847a 2408:8440::/32
2408:84e3 2408:8441::/32
2408:84e4 2408:8444::/30
2408:84e5 2408:8448::/32
2408:84e6 2408:844b::/32
2408:84e7 2408:844c::/32
2408:84e9 2408:844d::/32
2408:84eb 2408:844e::/32
2408:84ec 2408:844f::/32
2408:84ed 2408:8452::/32
2408:84ee 2408:8453::/32
2408:84ef 2408:8454::/32
2408:84f0 2408:845c::/32
2408:84f1 2408:845d::/32
2408:84f2 2408:8460::/32
2408:84f4 2408:8461::/32
2408:84f5 2408:8462::/32
2408:84f6 2408:8463::/32
2408:84f7 2408:8464::/32
2408:84f8 2408:8465::/32
2408:84f9 2408:8466::/32
2408:84fa 2408:8469::/32
2408:84fb 2408:846a::/32
2408:84fc 2408:846b::/32
2408:84fd 2408:846c::/32
2408:84fe 2408:846d::/32
2408:84ff 2408:846e::/32
2408:856c 2408:846f::/32
2408:856d 2408:8470::/32
2408:8610 2408:8471::/32
2408:8611 2408:8474::/32
2408:8612 2408:8475::/32
2408:8613 2408:8476::/32
2408:8614 2408:8477::/32
2408:8614:e20 2408:8478::/32
2408:8615 2408:8479::/32
2408:861a 2408:847a::/32
2408:861b 2408:84e3::/32
2408:861c 2408:84e4::/32
2408:8620 2408:84e5::/32
2408:8621 2408:84e6::/32
2408:8624 2408:84e7::/32
2408:8625 2408:84e9::/32
2408:8626 2408:84eb::/32
2408:862a 2408:84ec::/32
2408:862b 2408:84ed::/32
2408:862d 2408:84ee::/32
2408:862e 2408:84ef::/32
2408:862f 2408:84f0::/32
2408:8630 2408:84f1::/32
2408:8631 2408:84f2::/32
2408:8632 2408:84f4::/32
2408:8633 2408:84f5::/32
2408:8634 2408:84f6::/32
2408:8635 2408:84f7::/32
2408:8636 2408:84f8::/32
2408:8637 2408:84f9::/32
2408:8638 2408:84fa::/32
2408:8639 2408:84fb::/32
2408:863c 2408:84fc::/32
2408:863d 2408:84fd::/32
2408:8640 2408:84fe::/32
2408:8642 2408:84ff::/32
2408:8648 2408:856c::/32
2408:8649 2408:856d::/32
2408:8649:5a00 2408:8610::/32
2408:864c 2408:8611::/32
2408:864e 2408:8612::/32
2408:864f 2408:8613::/32
2408:8650 2408:8614::/32
2408:8651 2408:8614:e20::/48
2408:8652 2408:8615::/32
2408:8653 2408:861a::/32
2408:865c 2408:861b::/32
2408:865d 2408:861c::/32
2408:865f 2408:8620::/32
2408:8660 2408:8621::/32
2408:8662 2408:8624::/32
2408:8663 2408:8625::/32
2408:8664 2408:8626::/32
2408:8665 2408:862a::/32
2408:8666 2408:862b::/32
2408:866a 2408:862d::/32
2408:866b 2408:862e::/32
2408:866c 2408:862f::/32
2408:866d 2408:8630::/32
2408:866e 2408:8631::/32
2408:866f 2408:8632::/32
2408:8670 2408:8633::/32
2408:8674 2408:8634::/32
2408:8675 2408:8635::/32
2408:8676 2408:8636::/32
2408:8677 2408:8637::/32
2408:8678 2408:8638::/32
2408:8679 2408:8639::/32
2408:867a 2408:863c::/32
2408:8710 2408:863d::/32
2408:8711 2408:8640::/32
2408:8712 2408:8642::/32
2408:8713 2408:8644::/30
2408:8719 2408:8648::/32
2408:871a 2408:8649::/32
2408:871b 2408:864c::/32
2408:8720 2408:864e::/32
2408:8721 2408:864f::/32
2408:8722 2408:8650::/32
2408:8723 2408:8651::/32
2408:8726 2408:8652::/32
2408:872b 2408:8653::/32
2408:872f 2408:865c::/32
2408:8730 2408:865d::/32
2408:8731 2408:865f::/32
2408:8732 2408:8660::/32
2408:8733 2408:8662::/32
2408:8734 2408:8663::/32
2408:8735 2408:8664::/32
2408:8736 2408:8665::/32
2408:8738 2408:8666::/32
2408:873c 2408:866a::/32
2408:873d 2408:866b::/32
2408:8740 2408:866c:200::/42
2408:8742 2408:866c::/32
2408:8748 2408:866d::/32
2408:8749 2408:866e::/32
2408:874a 2408:866f::/32
2408:874b 2408:8670::/32
2408:874c 2408:8674::/32
2408:874d 2408:8675::/32
2408:874e 2408:8676::/32
2408:874f 2408:8677::/32
2408:8752 2408:8678::/32
2408:875c 2408:8679::/32
2408:8760 2408:867a::/32
2408:8762 2408:8710::/32
2408:8763 2408:8711::/32
2408:8764 2408:8712::/32
2408:8765 2408:8713::/32
2408:8766 2408:8719::/32
2408:8768 2408:871a::/32
2408:876a 2408:871b::/32
2408:876c 2408:8720::/32
2408:876d 2408:8721::/32
2408:876e 2408:8722::/32
2408:876f 2408:8723::/32
2408:8770 2408:8726::/32
2408:8772 2408:872b::/32
2408:8773 2408:872b:e00::/47
2408:8774 2408:872f::/32
2408:8776 2408:8730::/32
2408:8777 2408:8731::/32
2408:8778 2408:8732::/32
2408:8779 2408:8733::/32
2408:877a 2408:8734::/32
2408:8812 2408:8735::/32
2408:8813 2408:8736::/32
2408:8814 2408:8738::/32
2408:8815 2408:873c::/32
2408:8818 2408:873d::/32
2408:8819 2408:8740::/32
2408:882c 2408:8742::/32
2408:883a 2408:8744::/30
2408:8862 2408:8748::/32
2408:8863 2408:8749::/32
2408:8864 2408:874a::/32
2408:8865 2408:874b::/32
2408:8866 2408:874c::/32
2408:886e 2408:874d::/32
2408:886f 2408:874e::/32
2408:8872 2408:874f::/32
2408:8878 2408:8752::/32
2408:8879 2408:875c::/32
2408:887e 2408:8760::/32
2408:8912 2408:8762::/32
2408:8913 2408:8763::/32
2408:8914 2408:8764::/32
2408:8915 2408:8765::/32
2408:8916 2408:8766::/32
2408:8917 2408:8768::/32
2408:891c 2408:876a::/32
2408:8920 2408:876c:1700::/44
2408:8924 2408:876c:1740::/44
2408:892c 2408:876c:17c0::/44
2408:8936 2408:876c::/32
2408:893a 2408:876d::/32
2408:8940 2408:876e::/32
2408:8948 2408:876f::/32
2408:894c 2408:8770::/32
2408:894e 2408:8772::/32
2408:8956 2408:8773::/32
2408:8957 2408:8774::/32
2408:8962 2408:8776::/32
2408:8963 2408:8777::/32
2408:8964 2408:8778::/32
2408:8965 2408:8779::/32
2408:8966 2408:877a::/32
2408:896c 2408:8812::/32
2408:896e 2408:8813::/32
2408:896f 2408:8814::/32
2408:8972 2408:8815::/32
2408:8978 2408:8818::/32
2408:8979 2408:8819::/32
2408:897a 2408:882c::/32
2408:897b 2408:883a::/32
2408:897e 2408:8862::/32
2408:8a21 2408:8863::/32
2408:8a22 2408:8864::/32
2408:8a23 2408:8865::/32
2408:8a24 2408:8866::/32
2408:8a26 2408:886e::/32
2408:8a27 2408:886f::/32
2408:8872::/32
2408:8878::/32
2408:8879::/32
2408:887e::/32
2408:8912::/32
2408:8913::/32
2408:8914::/32
2408:8915::/32
2408:8916::/32
2408:8917::/32
2408:891c::/32
2408:8920::/32
2408:8924::/32
2408:892c::/32
2408:8936::/32
2408:893a::/32
2408:8940::/32
2408:8948::/32
2408:894c::/32
2408:894e::/32
2408:8962::/32
2408:8963::/32
2408:8964::/32
2408:8965::/32
2408:8966::/32
2408:896c::/32
2408:896e::/32
2408:896f::/32
2408:8972::/32
2408:8978::/32
2408:8979::/32
2408:897a::/32
2408:897b::/32
2408:897e::/32
2408:8a21:4000::/36
2408:8a21:5000::/36
2408:8a22:1000::/36
2408:8a22::/36
2408:8a23:4000::/36
2408:8a23:5000::/36
2408:8a23:6000::/36
2408:8a23:7000::/36
2408:8a24:4000::/36
2408:8a24:5000::/36
2408:8a24:6000::/36
2408:8a24:7000::/36
2408:8a26:c000::/36
2408:8a26:d000::/36
2408:8a26:e000::/36
2408:8a26:f000::/36
2408:8a27:4000::/36
2408:8a27:5000::/36

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4837.txt",
"count": 470,
"sha256": "d109b070f48e97a39e94dc831aab2923b6c440de80621a58319b1ba5926fd60c",
"generated_at": "2026-07-20T19:12:53Z"
}

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as58453.txt",
"count": 17,
"sha256": "e7e579da764e9dedf84145b5c4cdc51c4b2841517adbc6edb206896b40710119",
"generated_at": "2026-07-20T19:12:53Z"
}

View File

@ -1,2 +1,2 @@
2402:4f00 2402:4f00:f000::/36
2402:4f00:f000 2402:4f00:f000::/48

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as58807.txt",
"count": 2,
"sha256": "0765c7eefa122984fe69678b60ba99aa605d52601ef4ebf988c4e691e91c5361",
"generated_at": "2026-07-20T19:12:53Z"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as9808.txt",
"count": 7170,
"sha256": "7532e5bba93ed6b7d5cf010b22d9f5c87606392673fd9877ce28417f2baadd5d",
"generated_at": "2026-07-23T02:56:31Z"
}

View File

@ -1,88 +1,144 @@
2408:8120 2408:8120:1::/48
2408:8120:1 2408:8120:2::/48
2408:8120:2 2408:8120:7000::/36
2408:8256:226d 2408:8256:226d::/48
2408:8256:228b 2408:8256:228b::/48
2408:827a:8ff 2408:827a:8ff::/48
2408:8411:c0c0 2408:8411:c0c0::/48
2408:8411:c0c1 2408:8411:c0c1::/48
2408:8411:c0c2 2408:8411:c0c2::/48
2408:8411:c0c3 2408:8411:c0c3::/48
2408:8411:c0c4 2408:8411:c0c4::/48
2408:8411:c0c5 2408:8411:c0c5::/48
2408:8417 2408:8417:f000::/44
2408:8418 2408:8417:f010::/44
2408:8435 2408:8417:f020::/44
2408:843e:e010 2408:8418:f000::/44
2408:843e:e011 2408:8418:f010::/44
2408:843e:e012 2408:8418:f020::/44
2408:843e:e080 2408:841d:ef20::/44
2408:843e:e081 2408:841d:ef30::/44
2408:843e:e082 2408:841e:ef10::/44
2408:8441 2408:841e:ef20::/44
2408:8444 2408:841e:ef30::/44
2408:844c:cb09 2408:8435:b490::/47
2408:844c:cb0a 2408:8435:b492::/47
2408:844c:cb0b 2408:8435:b494::/47
2408:844d:cb09 2408:8435:b496::/47
2408:844d:cb0a 2408:8435:b498::/47
2408:844d:cb0b 2408:8435:b49a::/47
2408:844f 2408:843e:e010::/48
2408:8462 2408:843e:e011::/48
2408:8463 2408:843e:e012::/48
2408:8469:fc00 2408:843e:e080::/48
2408:8471:fa00 2408:843e:e081::/48
2408:8471:fa01 2408:843e:e082::/48
2408:8471:fa02 2408:8441:fe00::/44
2408:8471:fb00 2408:8441:fe10::/44
2408:8471:fb01 2408:8441:fe20::/44
2408:8471:fb02 2408:8441:ff00::/44
2408:8474 2408:8441:ff10::/44
2408:8478 2408:8441:ff20::/44
2408:847a:ff20 2408:8444:d500::/44
2408:847a:ff21 2408:8444:d580::/44
2408:847a:ff22 2408:8444:d700::/44
2408:847a:ff40 2408:8444:d780::/44
2408:847a:ff41 2408:8444:d900::/44
2408:847a:ff42 2408:8444:d980::/44
2408:8610:3bff 2408:844c:cb09::/48
2408:8614:1f0 2408:844c:cb0a::/48
2408:8614:af0 2408:844c:cb0b::/48
2408:861c:1fff 2408:844d:cb09::/48
2408:8625:10fb 2408:844d:cb0a::/48
2408:8625:fe 2408:844d:cb0b::/48
2408:8626:f200 2408:844f:d408::/47
2408:862b 2408:844f:d40a::/47
2408:862e:2ff 2408:844f:d40c::/47
2408:8634:1015 2408:844f:d500::/47
2408:8638:116 2408:844f:d502::/47
2408:8640 2408:844f:d504::/47
2408:8649:1a00 2408:8462:ff00::/44
2408:8649:2a00 2408:8462:ff10::/44
2408:864c:10ff 2408:8462:ff20::/44
2408:8651:ff00 2408:8463:ff00::/44
2408:8652:ff00 2408:8463:ff10::/44
2408:8656:a52 2408:8463:ff20::/44
2408:865c 2408:8469:fc00::/48
2408:8660:100 2408:846a:b600::/44
2408:8660:a100 2408:846a:b610::/44
2408:8660:ab00 2408:846a:b620::/44
2408:8660:b500 2408:846b:b600::/44
2408:8660:bf00 2408:846b:b610::/44
2408:8666:ff00 2408:846b:b620::/44
2408:866c:ff00 2408:8471:fa00::/48
2408:8670:800 2408:8471:fa01::/48
2408:8678:1400 2408:8471:fa02::/48
2408:8756:3efd 2408:8471:fb00::/48
2408:8a00 2408:8471:fb01::/48
2408:8a01 2408:8471:fb02::/48
2408:8a02 2408:8474:fb10::/44
2408:8a04 2408:8474:fb20::/44
2408:8a06 2408:8474:fb30::/44
2408:8a06:1 2408:8474:fc10::/44
2408:8a06:100 2408:8474:fc20::/44
2408:8a06:101 2408:8474:fc30::/44
2408:8a07 2408:8478:fb10::/44
2408:8a26:ee10 2408:8478:fb20::/44
2408:8a26:f020 2408:8478:fb30::/44
2408:8a26:f950 2408:8478:fc10::/44
2408:8478:fc20::/44
2408:8478:fc30::/44
2408:847a:ff20::/48
2408:847a:ff21::/48
2408:847a:ff22::/48
2408:847a:ff40::/48
2408:847a:ff41::/48
2408:847a:ff42::/48
2408:8610:3bff::/48
2408:8614:1f0::/48
2408:8614:af0::/48
2408:861c:1fff::/48
2408:8625:10fb::/48
2408:8625:fe::/48
2408:8626:f200::/48
2408:862b::/48
2408:862e:2ff::/48
2408:8634:1015::/48
2408:8638:116::/48
2408:8640:f100::/40
2408:8649:1a00::/48
2408:8649:2a00::/48
2408:864c:10ff::/48
2408:8651:ff00::/48
2408:8652:ff00::/48
2408:8656:a52::/48
2408:865c:f0::/44
2408:8660:100::/48
2408:8660:a100::/48
2408:8660:ab00::/48
2408:8660:b500::/48
2408:8660:bf00::/48
2408:8666:ff00::/48
2408:866a:a05::/48
2408:866c:ff00::/48
2408:8670:800::/48
2408:8678:1400::/48
2408:8756:3efd::/48
2408:8a00:c000::/36
2408:8a00:d000::/38
2408:8a00:d400::/38
2408:8a00:e000::/36
2408:8a00:f000::/36
2408:8a01::/36
2408:8a02:b110::/44
2408:8a02:b120::/44
2408:8a04:8000::/36
2408:8a04:e000::/40
2408:8a06:100::/48
2408:8a06:101::/48
2408:8a06:1::/48
2408:8a06::/48
2408:8a07::/40
2408:8a26:ee10::/48
2408:8a26:f020::/48
2408:8a26:f950::/48

View File

@ -0,0 +1,7 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as9929.txt",
"count": 144,
"sha256": "a5f65f08a447982cd2c8611005261deab3400f1232ae594fb98f6bfb3fe09084",
"generated_at": "2026-07-20T19:12:54Z"
}

294
bk/prefix_registry.go Normal file
View File

@ -0,0 +1,294 @@
package backtrace
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
ASNPrefixRegistrySchema = "backtrace.asn-prefixes/v1"
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
ValidateManifest bool
}
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
//go:embed prefix/*.txt.manifest.json
var embeddedPrefixManifestFS embed.FS
func init() {
activeASNPrefixes.Store(cloneASNPrefixes(asnPrefixes))
}
func DefaultASNPrefixRegistrySources() []ASNPrefixRegistrySource {
return []ASNPrefixRegistrySource{
{Name: "cdn", Base: ASNPrefixRegistryCDNBaseURL, ValidateManifest: true},
{Name: "raw", Base: ASNPrefixRegistryRawBaseURL, ValidateManifest: true},
}
}
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)
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 {
if err := validateEmbeddedPrefixManifests(); err != nil {
return ASNPrefixRegistryLoadResult{}, fmt.Errorf("validate embedded ASN prefix manifests: %w", err)
}
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())
}()
})
}
type prefixManifest struct {
Schema string `json:"schema"`
File string `json:"file"`
Count int `json:"count"`
SHA256 string `json:"sha256"`
GeneratedAt string `json:"generated_at"`
}
func fetchASNPrefixSource(ctx context.Context, client *http.Client, source ASNPrefixRegistrySource) (map[string][]string, error) {
base := source.Base
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"
var manifestData []byte
if source.ValidateManifest {
fetchedManifest, fetchErr := fetchPrefixResource(ctx, client, endpoint+".manifest.json")
if fetchErr != nil {
results <- prefixResult{asn: asn, err: fmt.Errorf("manifest: %w", fetchErr)}
return
}
manifestData = fetchedManifest
}
data, err := fetchPrefixResource(ctx, client, endpoint)
if err != nil {
results <- prefixResult{asn: asn, err: err}
return
}
if source.ValidateManifest {
if err := validatePrefixManifest(manifestData, data, strings.ToLower(asn)+".txt"); err != nil {
results <- prefixResult{asn: asn, err: err}
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 fetchPrefixResource(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "text/plain, application/json")
request.Header.Set("User-Agent", "oneclickvirt-backtrace/asn-prefix-registry-v1")
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
}
return io.ReadAll(io.LimitReader(response.Body, 4<<20))
}
func validatePrefixManifest(data, snapshot []byte, expectedFile string) error {
var manifest prefixManifest
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("manifest contains trailing JSON")
}
if manifest.Schema != ASNPrefixRegistrySchema || manifest.File != expectedFile || manifest.Count < 1 {
return errors.New("manifest schema, file, or count is invalid")
}
if _, err := time.Parse(time.RFC3339, manifest.GeneratedAt); err != nil {
return fmt.Errorf("manifest generated_at is invalid: %w", err)
}
hash := sha256.Sum256(snapshot)
if !strings.EqualFold(manifest.SHA256, hex.EncodeToString(hash[:])) {
return errors.New("manifest SHA-256 does not match snapshot")
}
values, err := parseASNPrefixLines(snapshot)
if err != nil {
return err
}
if len(values) != manifest.Count {
return fmt.Errorf("manifest count %d does not match snapshot count %d", manifest.Count, len(values))
}
return nil
}
func validateEmbeddedPrefixManifests() error {
for _, asn := range knownPrefixASNs {
name := strings.ToLower(asn) + ".txt"
manifest, err := embeddedPrefixManifestFS.ReadFile("prefix/" + name + ".manifest.json")
if err != nil {
return err
}
var snapshot []byte
switch asn {
case "AS23764":
snapshot = []byte(as23764Data)
case "AS4134":
snapshot = []byte(as4134Data)
case "AS4809":
snapshot = []byte(as4809Data)
case "AS4837":
snapshot = []byte(as4837Data)
case "AS58453":
snapshot = []byte(as58453Data)
case "AS58807":
snapshot = []byte(as58807Data)
case "AS9808":
snapshot = []byte(as9808Data)
case "AS9929":
snapshot = []byte(as9929Data)
}
if err := validatePrefixManifest(manifest, snapshot, name); err != nil {
return fmt.Errorf("%s: %w", asn, err)
}
}
return 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
}

View File

@ -0,0 +1,84 @@
package backtrace
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"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) {
path := strings.TrimPrefix(request.URL.Path, "/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
http.NotFound(writer, request)
return
}
base, file := parts[0], parts[1]
snapshot := []byte("2409:8000\n")
if base == "bad" && file == "as23764.txt.manifest.json" {
_, _ = writer.Write([]byte(`{}`))
return
}
if strings.HasSuffix(file, ".manifest.json") {
hash := sha256.Sum256(snapshot)
manifest, _ := json.Marshal(prefixManifest{Schema: ASNPrefixRegistrySchema, File: strings.TrimSuffix(file, ".manifest.json"), Count: 1, SHA256: hex.EncodeToString(hash[:]), GeneratedAt: "2026-01-01T00:00:00Z"})
_, _ = writer.Write(manifest)
return
}
_, _ = writer.Write(snapshot)
}))
defer server.Close()
sources := []ASNPrefixRegistrySource{{Name: "cdn", Base: server.URL + "/bad", ValidateManifest: true}, {Name: "raw", Base: server.URL + "/good", ValidateManifest: true}}
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 TestValidatePrefixManifestRejectsHashAndCountMismatch(t *testing.T) {
snapshot := []byte("2409:8000\n")
manifest := prefixManifest{Schema: ASNPrefixRegistrySchema, File: "as23764.txt", Count: 2, SHA256: strings.Repeat("0", 64), GeneratedAt: "2026-01-01T00:00:00Z"}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if err := validatePrefixManifest(data, snapshot, "as23764.txt"); err == nil {
t.Fatal("invalid manifest unexpectedly accepted")
}
}
func TestValidateEmbeddedPrefixManifests(t *testing.T) {
if err := validateEmbeddedPrefixManifests(); err != nil {
t.Fatal(err)
}
}
func TestEmbeddedManifestFilesArePresent(t *testing.T) {
for _, asn := range knownPrefixASNs {
name := strings.ToLower(asn) + ".txt.manifest.json"
if _, err := embeddedPrefixManifestFS.ReadFile(filepath.Join("prefix", name)); err != nil {
t.Fatalf("missing embedded manifest %s: %v", name, 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)
}
}

View File

@ -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,36 +78,52 @@ func main() {
resp.Body.Close() resp.Body.Close()
} }
}() }()
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace")) var options cliOptions
var showVersion, showIpInfo, help, ipv6 bool backtraceFlag := newBacktraceFlagSet(&options)
var specifiedIP string if err := backtraceFlag.Parse(os.Args[1:]); err != nil {
backtraceFlag := flag.NewFlagSet("backtrace", flag.ContinueOnError) os.Exit(2)
backtraceFlag.BoolVar(&help, "h", false, "Show help information") }
backtraceFlag.BoolVar(&showVersion, "v", false, "Show version") if !options.jsonOutput {
backtraceFlag.BoolVar(&showIpInfo, "s", true, "Disabe show ip info") fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
backtraceFlag.BoolVar(&model.EnableLoger, "log", false, "Enable logging") }
backtraceFlag.BoolVar(&ipv6, "ipv6", false, "Enable ipv6 testing") if options.help {
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, sanitizeErrorText(err.Error()))
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: %s\n", sanitizeErrorText(err.Error()))
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 %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))
@ -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
} }
@ -142,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("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效"))
@ -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
View 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)
}
}

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

@ -0,0 +1,207 @@
package main
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/oneclickvirt/backtrace/bgptools"
)
const defaultSource = "https://raw.githubusercontent.com/xykt/NetQuality/main/ref/AS_Mapping.txt"
type document struct {
Schema string `json:"schema"`
GeneratedAt time.Time `json:"generated_at"`
Entries []bgptools.ASNMetadata `json:"entries"`
}
type manifest struct {
Schema string `json:"schema"`
File string `json:"file"`
Count int `json:"count"`
SHA256 string `json:"sha256"`
GeneratedAt time.Time `json:"generated_at"`
}
func main() {
source := flag.String("source", defaultSource, "ASN name registry URL")
output := flag.String("output", "bgptools/data/bgp-asn-map.json", "snapshot output path")
minimum := flag.Int("min-count", bgptools.ASNMetadataMinimum, "minimum accepted ASN count")
timeout := flag.Duration("timeout", 30*time.Second, "fetch timeout")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
entries, err := fetchEntries(ctx, http.DefaultClient, *source)
if err != nil {
fatal(err)
}
if err := updateSnapshot(*output, entries, *minimum); err != nil {
fatal(err)
}
}
func fetchEntries(ctx context.Context, client *http.Client, source string) ([]bgptools.ASNMetadata, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-sync/1")
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ASN registry returned HTTP %d", response.StatusCode)
}
return parseEntries(io.LimitReader(response.Body, 4<<20))
}
func parseEntries(reader io.Reader) ([]bgptools.ASNMetadata, error) {
seen := make(map[uint32]string)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 || !strings.HasPrefix(strings.ToUpper(fields[0]), "AS") {
continue
}
number, err := strconv.ParseUint(strings.TrimPrefix(strings.ToUpper(fields[0]), "AS"), 10, 32)
if err != nil || number == 0 {
continue
}
if _, exists := seen[uint32(number)]; !exists {
seen[uint32(number)] = strings.Join(fields[1:], " ")
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
entries := make([]bgptools.ASNMetadata, 0, len(seen))
for asn, name := range seen {
entries = append(entries, bgptools.ASNMetadata{ASN: asn, Name: name})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ASN < entries[j].ASN })
if len(entries) == 0 {
return nil, fmt.Errorf("ASN registry contains no valid entries")
}
return entries, nil
}
func updateSnapshot(path string, entries []bgptools.ASNMetadata, minimum int) error {
if len(entries) < minimum {
return fmt.Errorf("ASN count %d is below minimum %d", len(entries), minimum)
}
currentData, err := os.ReadFile(path)
if err != nil {
return err
}
var current document
if err := json.Unmarshal(currentData, &current); err != nil || current.Schema != bgptools.ASNMetadataSchema {
return fmt.Errorf("current ASN metadata schema is invalid")
}
if len(current.Entries) >= minimum && len(entries)*100 < len(current.Entries)*65 {
return fmt.Errorf("ASN count dropped from %d to %d", len(current.Entries), len(entries))
}
if sameEntries(current.Entries, entries) && !current.GeneratedAt.IsZero() {
return writeManifest(path, currentData, len(current.Entries))
}
next, err := json.MarshalIndent(document{Schema: bgptools.ASNMetadataSchema, GeneratedAt: time.Now().UTC(), Entries: entries}, "", " ")
if err != nil {
return err
}
next = append(next, '\n')
temporary, err := os.CreateTemp(filepath.Dir(path), ".asn-metadata-*.json")
if err != nil {
return err
}
name := temporary.Name()
defer os.Remove(name)
if _, err := temporary.Write(next); err != nil {
temporary.Close()
return err
}
if err := temporary.Chmod(0o644); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
return writeManifest(path, next, len(entries))
}
func writeManifest(snapshotPath string, snapshot []byte, count int) error {
var snapshotDocument document
if err := json.Unmarshal(snapshot, &snapshotDocument); err != nil {
return err
}
hash := sha256.Sum256(snapshot)
value := manifest{
Schema: bgptools.ASNMetadataManifestSchema, File: filepath.Base(snapshotPath), Count: count,
SHA256: hex.EncodeToString(hash[:]), GeneratedAt: snapshotDocument.GeneratedAt,
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
manifestPath := strings.TrimSuffix(snapshotPath, ".json") + ".manifest.json"
if current, readErr := os.ReadFile(manifestPath); readErr == nil && string(current) == string(data) {
return nil
}
return writeAtomic(manifestPath, data)
}
func writeAtomic(path string, data []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".asn-metadata-manifest-*.json")
if err != nil {
return err
}
name := temporary.Name()
defer os.Remove(name)
if _, err := temporary.Write(data); err != nil {
temporary.Close()
return err
}
if err := temporary.Chmod(0o644); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
func sameEntries(left, right []bgptools.ASNMetadata) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

View File

@ -0,0 +1,62 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/oneclickvirt/backtrace/bgptools"
)
func TestParseEntriesDeduplicatesAndSorts(t *testing.T) {
entries, err := parseEntries(strings.NewReader("AS3356 Lumen\nAS174 Cogent\nAS3356 Level3\ninvalid\n"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || entries[0] != (bgptools.ASNMetadata{ASN: 174, Name: "Cogent"}) || entries[1].Name != "Lumen" {
t.Fatalf("unexpected entries: %#v", entries)
}
}
func TestUpdateSnapshotWritesMatchingManifestWithoutRewritingSnapshot(t *testing.T) {
path := filepath.Join(t.TempDir(), "asn.json")
current := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":2,"name":"Two"}]}`)
if err := os.WriteFile(path, current, 0o644); err != nil {
t.Fatal(err)
}
entries := []bgptools.ASNMetadata{{ASN: 1, Name: "One"}, {ASN: 2, Name: "Two"}}
if err := updateSnapshot(path, entries, 1); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(path)
if string(after) != string(current) {
t.Fatal("semantic no-op rewrote snapshot")
}
data, err := os.ReadFile(filepath.Join(filepath.Dir(path), "asn.manifest.json"))
if err != nil {
t.Fatal(err)
}
var value manifest
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
hash := sha256.Sum256(current)
if value.Schema != bgptools.ASNMetadataManifestSchema || value.File != "asn.json" || value.Count != 2 || value.SHA256 != hex.EncodeToString(hash[:]) {
t.Fatalf("manifest mismatch: %#v", value)
}
}
func TestUpdateSnapshotRejectsCountDrop(t *testing.T) {
path := filepath.Join(t.TempDir(), "asn.json")
current := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":2,"name":"Two"}]}`
if err := os.WriteFile(path, []byte(current), 0o644); err != nil {
t.Fatal(err)
}
if err := updateSnapshot(path, []bgptools.ASNMetadata{{ASN: 1, Name: "One"}}, 1); err == nil {
t.Fatal("count drop unexpectedly accepted")
}
}

280
cmd/update-prefixes/main.go Normal file
View File

@ -0,0 +1,280 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"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 {
path := filepath.Join(config.OutputDir, strings.ToLower(asn)+".txt")
if err := writePrefixFile(path, valuesByASN[asn]); err != nil {
if errors.Is(err, errPrefixCountDrop) {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", asn, err)
current, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
valuesByASN[asn] = prefixValuesFromFile(current)
} else {
return err
}
}
if err := writePrefixManifest(path); err != nil {
return err
}
}
return nil
}
type prefixManifest struct {
Schema string `json:"schema"`
File string `json:"file"`
Count int `json:"count"`
SHA256 string `json:"sha256"`
GeneratedAt string `json:"generated_at"`
}
func prefixValuesFromFile(data []byte) []string {
values := make([]string, 0)
for _, line := range strings.Split(string(data), "\n") {
if value := strings.TrimSpace(line); value != "" {
values = append(values, value)
}
}
return values
}
func writePrefixManifest(snapshotPath string) error {
snapshot, err := os.ReadFile(snapshotPath)
if err != nil {
return err
}
values := prefixValuesFromFile(snapshot)
if len(values) == 0 {
return errors.New("prefix snapshot is empty")
}
hash := sha256.Sum256(snapshot)
fileName := filepath.Base(snapshotPath)
sha256Value := hex.EncodeToString(hash[:])
manifestPath := snapshotPath + ".manifest.json"
if current, readErr := os.ReadFile(manifestPath); readErr == nil {
var existing prefixManifest
decoder := json.NewDecoder(bytes.NewReader(current))
decoder.DisallowUnknownFields()
var extra any
if decoder.Decode(&existing) == nil && decoder.Decode(&extra) == io.EOF &&
existing.Schema == "backtrace.asn-prefixes/v1" && existing.File == fileName &&
existing.Count == len(values) && strings.EqualFold(existing.SHA256, sha256Value) {
if _, timeErr := time.Parse(time.RFC3339, existing.GeneratedAt); timeErr == nil {
return nil
}
}
} else if !errors.Is(readErr, os.ErrNotExist) {
return readErr
}
manifest := prefixManifest{Schema: "backtrace.asn-prefixes/v1", File: fileName, Count: len(values), SHA256: sha256Value, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return writeAtomicFile(manifestPath, data)
}
func writeAtomicFile(output string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(output), ".prefix-manifest-*")
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(data); 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 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
}

View File

@ -0,0 +1,70 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"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)
}
}
func TestWritePrefixManifestPreservesGeneratedAtWhenSnapshotIsUnchanged(t *testing.T) {
directory := t.TempDir()
snapshotPath := filepath.Join(directory, "as64500.txt")
if err := os.WriteFile(snapshotPath, []byte("2001:db8::/48\n"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := snapshotPath + ".manifest.json"
old := prefixManifest{Schema: "backtrace.asn-prefixes/v1", File: "as64500.txt", Count: 1, SHA256: "", GeneratedAt: "2020-01-02T03:04:05Z"}
data, err := os.ReadFile(snapshotPath)
if err != nil {
t.Fatal(err)
}
// Seed a valid manifest with an old timestamp. The updater must retain it.
hash := sha256.Sum256(data)
old.SHA256 = hex.EncodeToString(hash[:])
encoded, err := json.Marshal(old)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifestPath, append(encoded, '\n'), 0o644); err != nil {
t.Fatal(err)
}
if err := writePrefixManifest(snapshotPath); err != nil {
t.Fatal(err)
}
actual, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(actual, []byte(`"generated_at":"2020-01-02T03:04:05Z"`)) {
t.Fatalf("generated_at was not preserved: %s", actual)
}
}

View File

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