mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-09-29 16:55:50 +08:00
Compare commits
No commits in common. "46e20f680e7e54761eb7f2c43ed109e3bd39b3f0" and "577871a7df1ce11e52c8e738f9b46825324e7bbb" have entirely different histories.
46e20f680e
...
577871a7df
51
.github/workflows/sync-asn-metadata.yml
vendored
51
.github/workflows/sync-asn-metadata.yml
vendored
@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
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, err
|
|
||||||
}
|
|
||||||
request.Header.Set("Accept", "application/json")
|
|
||||||
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/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("HTTP %d", response.StatusCode)
|
|
||||||
}
|
|
||||||
const maximumSize = 4 << 20
|
|
||||||
data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,342 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "backtrace.asn-metadata-manifest/v1",
|
|
||||||
"file": "bgp-asn-map.json",
|
|
||||||
"count": 84,
|
|
||||||
"sha256": "f681dd78d55c27d4615d2e705de9bde24d111e8897d37a489b91e263f27f1a66",
|
|
||||||
"generated_at": "2026-07-21T15:33:49.459211Z"
|
|
||||||
}
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
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, ¤t); 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)
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,7 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const BackTraceVersion = "v0.0.15"
|
const BackTraceVersion = "v0.0.12"
|
||||||
|
|
||||||
var EnableLoger = false
|
var EnableLoger = false
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user