Compare commits

..

6 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
20 changed files with 1221 additions and 31 deletions

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

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

View File

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

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

View File

@ -60,13 +60,16 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri
requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String() requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil { if err != nil {
return RDAPRecord{}, err return RDAPRecord{}, errors.New("create RDAP request failed")
} }
req.Header.Set("Accept", "application/rdap+json, application/json") req.Header.Set("Accept", "application/rdap+json, application/json")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1") req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1")
response, err := client.Do(req) response, err := client.Do(req)
if err != nil { if err != nil {
return RDAPRecord{}, err if ctxErr := ctx.Err(); ctxErr != nil {
return RDAPRecord{}, ctxErr
}
return RDAPRecord{}, errors.New("RDAP request failed")
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests { if response.StatusCode == http.StatusTooManyRequests {
@ -77,7 +80,7 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri
} }
data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1)) data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
if err != nil { if err != nil {
return RDAPRecord{}, err return RDAPRecord{}, errors.New("RDAP response read failed")
} }
if len(data) > 4<<20 { if len(data) > 4<<20 {
return RDAPRecord{}, ErrRDAPResponseTooLarge return RDAPRecord{}, ErrRDAPResponseTooLarge
@ -125,7 +128,7 @@ func parseRDAPRecord(ip, source string, data []byte) (RDAPRecord, error) {
record := RDAPRecord{ record := RDAPRecord{
IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type, IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type,
Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress, Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress,
ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: source, ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: "rdap",
} }
for _, event := range payload.Events { for _, event := range payload.Events {
switch strings.ToLower(event.Action) { switch strings.ToLower(event.Action) {

View File

@ -131,7 +131,7 @@ func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConf
report := &ASNRelationshipReport{TargetASN: normalized} report := &ASNRelationshipReport{TargetASN: normalized}
ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized) ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized)
if err != nil { if err != nil {
return nil, err return nil, errors.New("invalid relationship source configuration")
} }
ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL) ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL)
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr)) report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr))
@ -153,7 +153,7 @@ func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConf
peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized) peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized)
if err != nil { if err != nil {
return nil, err return nil, errors.New("invalid relationship source configuration")
} }
peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL) peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL)
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr)) report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr))
@ -255,7 +255,16 @@ func sourceStatus(source string, status RelationshipStatus, err error) Relations
Timeout: status == RelationshipTimeout, Timeout: status == RelationshipTimeout,
} }
if err != nil { if err != nil {
item.Error = err.Error() 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 return item
} }

View File

@ -217,6 +217,12 @@ func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig)
geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...) geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...)
} }
geofeedURLs = uniqueSortedStrings(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 { for _, geofeedURL := range geofeedURLs {
if !cfg.FetchGeofeed { if !cfg.FetchGeofeed {
report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"}) report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"})
@ -224,7 +230,7 @@ func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig)
} }
result := fetchGeofeed(ctx, geofeedURL, cfg) result := fetchGeofeed(ctx, geofeedURL, cfg)
report.Geofeeds = append(report.Geofeeds, result) report.Geofeeds = append(report.Geofeeds, result)
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed:" + geofeedURL, Status: result.Status, Error: result.Error}) report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed", Status: result.Status, Error: result.Error})
} }
asn := strings.TrimSpace(cfg.ASN) asn := strings.TrimSpace(cfg.ASN)
@ -286,7 +292,7 @@ func inferRIR(record RDAPRecord, available bool) RIRInfo {
} }
func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult { func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult {
result := GeofeedResult{URL: rawURL} result := GeofeedResult{URL: sanitizeReportURL(rawURL)}
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
result.Status = ReportUnsupported result.Status = ReportUnsupported
@ -310,7 +316,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil { if err != nil {
result.Status = ReportError result.Status = ReportError
result.Error = err.Error() result.Error = stableReportError(result.Status, err)
return result return result
} }
req.Header.Set("Accept", "text/csv, text/plain, */*") req.Header.Set("Accept", "text/csv, text/plain, */*")
@ -318,7 +324,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
result.Status = reportStatusForError(err) result.Status = reportStatusForError(err)
result.Error = err.Error() result.Error = stableReportError(result.Status, err)
return result return result
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -341,7 +347,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil { if err != nil {
result.Status = reportStatusForError(err) result.Status = reportStatusForError(err)
result.Error = err.Error() result.Error = stableReportError(result.Status, err)
return result return result
} }
if int64(len(body)) > maxBytes { if int64(len(body)) > maxBytes {
@ -517,11 +523,51 @@ func rirNameFromText(value string) string {
func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus { func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus {
item := IPBGPSourceStatus{Source: source, Status: status} item := IPBGPSourceStatus{Source: source, Status: status}
if err != nil { if err != nil {
item.Error = err.Error() item.Error = stableReportError(status, err)
} }
return item 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 { func reportStatusForError(err error) ReportStatus {
if err == nil { if err == nil {
return ReportAvailable return ReportAvailable

View File

@ -210,6 +210,7 @@
240e:4b:8000::/33 240e:4b:8000::/33
240e:4b::/33 240e:4b::/33
240e:4c:4006::/48 240e:4c:4006::/48
240e:4c:5000::/48
240e:4c:8000::/33 240e:4c:8000::/33
240e:4c::/33 240e:4c::/33
240e:4d:50ff::/48 240e:4d:50ff::/48

View File

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

View File

@ -6639,7 +6639,6 @@
2409:8c7c::/32 2409:8c7c::/32
2409:8c7e:9301::/48 2409:8c7e:9301::/48
2409:8c7e::/32 2409:8c7e::/32
2409:8c85:1000::/48
2409:8c85:1001::/48 2409:8c85:1001::/48
2409:8c85:1::/48 2409:8c85:1::/48
2409:8c85:200::/48 2409:8c85:200::/48

View File

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

View File

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

27
cmd/output.go Normal file
View File

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

24
cmd/output_test.go Normal file
View File

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

View File

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

View File

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