Files
EvoBGP/internal/asnresolve/ripestat.go
T
Denozordec c958d5af0d
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 24s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m9s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m3s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m0s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m37s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m25s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m29s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m25s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m21s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m24s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m31s
feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function to retrieve organization names from RIPEstat, update ASEntry model to include ASN name, prefix count, and resolution timestamp. Modify database interactions and API responses to support new fields, improving ASN metadata handling.
2026-04-06 01:49:31 +07:00

157 lines
4.4 KiB
Go

// 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"
"strconv"
"strings"
"time"
)
// 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 = http.DefaultClient
}
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 := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
defer 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 = http.DefaultClient
}
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 := hc.Do(req)
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
defer 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
}
// PolitePause is a short delay between upstream ASN lookups (same refresh).
func PolitePause() {
d := 150 * time.Millisecond
if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE_PAUSE_MS")); s != "" {
if ms, err := parsePositiveInt(s); err == nil && ms > 0 {
d = time.Duration(ms) * time.Millisecond
}
}
time.Sleep(d)
}
func parsePositiveInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid")
}
return n, nil
}