Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
151 lines
4.6 KiB
Go
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)
|
|
}
|