Files
EvoBGP/internal/asnresolve/ripestat.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

151 lines
4.6 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"
"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) {
ctx, cancel := ripeRequestContext(ctx)
defer cancel()
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() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
}
body, err := httpclient.ReadResponseBody(resp, 32<<20)
if err != nil {
return nil, fmt.Errorf("ripestat AS%d response: %w", asn, err)
}
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) {
ctx, cancel := ripeRequestContext(ctx)
defer cancel()
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() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("ripestat as-overview AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
}
body, err := httpclient.ReadResponseBody(resp, 4<<20)
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d response: %w", asn, err)
}
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
}
func ripeRequestContext(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, httpclient.DefaultTimeout)
}