// Package asnresolve fetches IP prefixes announced by an ASN (control-plane ingest). package asnresolve import ( "context" "encoding/json" "fmt" "io" "net/http" "net/netip" "os" "sort" "strings" "evobgp/internal/httpclient" ) // DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key). const DefaultRIPEStatURL = "https://stat.ripe.net/data/announced-prefixes/data.json" // DefaultASOverviewURL is the RIPEstat as-overview data call (holder / org name, no API key). const DefaultASOverviewURL = "https://stat.ripe.net/data/as-overview/data.json" // AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat). func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) { if hc == nil { hc = httpclient.New(httpclient.DefaultTimeout) } base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL")) if base == "" { base = DefaultRIPEStatURL } u := fmt.Sprintf("%s?resource=AS%d", strings.TrimSuffix(base, "?"), asn) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "evobgp-asnresolve/1.0") resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3) if err != nil { return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200)) } var wrap struct { Status string `json:"status"` Data struct { Prefixes []struct { Prefix string `json:"prefix"` } `json:"prefixes"` } `json:"data"` Messages [][]string `json:"messages"` } if err := json.Unmarshal(body, &wrap); err != nil { return nil, fmt.Errorf("ripestat AS%d: json: %w", asn, err) } if wrap.Status != "" && wrap.Status != "ok" { return nil, fmt.Errorf("ripestat AS%d: status %q", asn, wrap.Status) } var out []netip.Prefix for _, row := range wrap.Data.Prefixes { p := strings.TrimSpace(row.Prefix) if p == "" { continue } pfx, err := netip.ParsePrefix(p) if err != nil { continue } out = append(out, pfx.Masked()) } sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) return out, nil } // ASHolderName returns the holder / organization label for the ASN from RIPEstat as-overview (best-effort). func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) { if hc == nil { hc = httpclient.New(httpclient.DefaultTimeout) } base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL")) if base == "" { base = DefaultASOverviewURL } u := fmt.Sprintf("%s?resource=AS%d", strings.TrimSuffix(base, "?"), asn) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", err } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "evobgp-asnresolve/1.0") resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3) if err != nil { return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("ripestat as-overview AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200)) } var wrap struct { Status string `json:"status"` Data struct { Holder string `json:"holder"` } `json:"data"` } if err := json.Unmarshal(body, &wrap); err != nil { return "", fmt.Errorf("ripestat as-overview AS%d: json: %w", asn, err) } if wrap.Status != "" && wrap.Status != "ok" { return "", fmt.Errorf("ripestat as-overview AS%d: status %q", asn, wrap.Status) } return strings.TrimSpace(wrap.Data.Holder), nil } func truncateForErr(b []byte, n int) string { s := string(b) if len(s) > n { return s[:n] + "…" } return s }