mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-07-23 03:20:10 +08:00
230 lines
8.4 KiB
Go
230 lines
8.4 KiB
Go
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
|
|
}
|