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.
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

This commit is contained in:
Denozordec
2026-04-06 01:49:31 +07:00
parent 80965adc0c
commit c958d5af0d
13 changed files with 233 additions and 16 deletions
+48
View File
@@ -18,6 +18,9 @@ import (
// 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 {
@@ -80,6 +83,51 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
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 {
+21
View File
@@ -31,3 +31,24 @@ func TestAnnouncedPrefixes_Mock(t *testing.T) {
t.Fatalf("order or values: %#v", pfx)
}
}
func TestASHolderName_Mock(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("resource") != "AS64496" {
t.Fatalf("resource: %q", r.URL.Query().Get("resource"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","data":{"holder":"Example Networks Ltd"}}`))
}))
defer ts.Close()
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", ts.URL)
name, err := ASHolderName(context.Background(), ts.Client(), 64496)
if err != nil {
t.Fatal(err)
}
if name != "Example Networks Ltd" {
t.Fatalf("holder: %q", name)
}
}
+17
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"evobgp/internal/store"
)
@@ -237,6 +239,21 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
} else {
m["community_id"] = nil
}
if strings.TrimSpace(x.ASNName) != "" {
m["asn_name"] = strings.TrimSpace(x.ASNName)
} else {
m["asn_name"] = nil
}
if x.PrefixCount != nil {
m["prefix_count"] = *x.PrefixCount
} else {
m["prefix_count"] = nil
}
if x.ASNResolvedAt != nil {
m["asn_resolved_at"] = x.ASNResolvedAt.UTC().Format(time.RFC3339Nano)
} else {
m["asn_resolved_at"] = nil
}
return m
}
+10
View File
@@ -11,6 +11,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"evobgp/internal/asnresolve"
"evobgp/internal/birdfmt"
@@ -86,6 +87,15 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
if err != nil {
return "", fmt.Errorf("resolve AS%d: %w", e.ASN, err)
}
holder := ""
asnresolve.PolitePause()
if h, err := asnresolve.ASHolderName(ctx, hc, e.ASN); err == nil {
holder = h
}
now := time.Now().UTC()
if err := st.UpdateASEntryResolveMeta(tenantID, moduleID, e.ID, holder, int64(len(pfxs)), now); err != nil {
return "", fmt.Errorf("as entry meta AS%d: %w", e.ASN, err)
}
src := fmt.Sprintf("as:%d", e.ASN)
for _, pfx := range pfxs {
k := pfx.String()
+62 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"strings"
"time"
"evobgp/internal/store"
@@ -164,7 +165,8 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID)
SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at
FROM module_as_entry WHERE module_id=$1 ORDER BY asn`, moduleID)
if err != nil {
return nil, err
}
@@ -173,11 +175,18 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
for rows.Next() {
var e store.ASEntry
e.ModuleID = moduleID
var comm *string
if err := rows.Scan(&e.ID, &e.ASN, &comm); err != nil {
var comm, asnName *string
var pc *int64
var at *time.Time
if err := rows.Scan(&e.ID, &e.ASN, &comm, &asnName, &pc, &at); err != nil {
continue
}
e.CommunityID = strOrNil(comm)
if asnName != nil {
e.ASNName = *asnName
}
e.PrefixCount = pc
e.ASNResolvedAt = at
out = append(out, &e)
}
return out, nil
@@ -209,14 +218,22 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (
func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) {
var e store.ASEntry
e.ModuleID = moduleID
var comm *string
var comm, asnName *string
var pc *int64
var at *time.Time
err := p.pool.QueryRow(ctx, `
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
&e.ID, &e.ASN, &comm)
SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at
FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
&e.ID, &e.ASN, &comm, &asnName, &pc, &at)
if err != nil {
return nil, err
}
e.CommunityID = strOrNil(comm)
if asnName != nil {
e.ASNName = *asnName
}
e.PrefixCount = pc
e.ASNResolvedAt = at
return &e, nil
}
@@ -231,6 +248,7 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
}
return nil, err
}
prevASN := cur.ASN
if patch.ASN != nil {
cur.ASN = *patch.ASN
}
@@ -245,17 +263,51 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
if !store.ValidASN(cur.ASN) {
return nil, store.ErrInvalidInput
}
clearResolve := patch.ASN != nil && cur.ASN != prevASN
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
WHERE id=$1 AND module_id=$2`,
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
if clearResolve {
_, err = p.pool.Exec(ctx, `
UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid),
asn_name=NULL, prefix_count=NULL, asn_resolved_at=NULL, updated_at=now()
WHERE id=$1 AND module_id=$2`,
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
} else {
_, err = p.pool.Exec(ctx, `
UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
WHERE id=$1 AND module_id=$2`,
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
}
if err != nil {
return nil, err
}
return p.getASEntry(ctx, moduleID, entryID)
}
func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error {
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
}
ctx := context.Background()
var nameArg any
sn := strings.TrimSpace(asnName)
if sn == "" {
nameArg = nil
} else {
nameArg = sn
}
tag, err := p.pool.Exec(ctx, `
UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now()
WHERE id=$1 AND module_id=$2`,
entryID, moduleID, nameArg, prefixCount, resolvedAt.UTC())
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
+9 -4
View File
@@ -28,6 +28,8 @@ type Backend interface {
CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error)
UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntryPatch) (*ASEntry, error)
DeleteASEntry(tenantID, moduleID, entryID string) error
// UpdateASEntryResolveMeta записывает имя AS, число объявленных префиксов и время успешного резолва (pipeline).
UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
@@ -109,10 +111,13 @@ type CDNSourcePatch struct {
}
type ASEntry struct {
ID string `json:"id,omitempty"`
ModuleID string `json:"module_id,omitempty"`
ASN int64 `json:"asn"`
CommunityID *string `json:"community_id"`
ID string `json:"id,omitempty"`
ModuleID string `json:"module_id,omitempty"`
ASN int64 `json:"asn"`
CommunityID *string `json:"community_id"`
ASNName string `json:"asn_name,omitempty"`
PrefixCount *int64 `json:"prefix_count,omitempty"`
ASNResolvedAt *time.Time `json:"asn_resolved_at,omitempty"`
}
type ASEntryPatch struct {
+24
View File
@@ -248,6 +248,7 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
if !ok || e.ModuleID != moduleID {
return nil, ErrNotFound
}
prevASN := e.ASN
if patch.ASN != nil {
e.ASN = *patch.ASN
}
@@ -262,9 +263,32 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
if !ValidASN(e.ASN) {
return nil, ErrInvalidInput
}
if patch.ASN != nil && e.ASN != prevASN {
e.ASNName = ""
e.PrefixCount = nil
e.ASNResolvedAt = nil
}
return e, nil
}
func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
return err
}
e, ok := m.asEntries[entryID]
if !ok || e.ModuleID != moduleID {
return ErrNotFound
}
e.ASNName = strings.TrimSpace(asnName)
pc := prefixCount
e.PrefixCount = &pc
t := resolvedAt.UTC()
e.ASNResolvedAt = &t
return nil
}
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -0,0 +1,3 @@
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS asn_name;
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS prefix_count;
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS asn_resolved_at;
@@ -0,0 +1,4 @@
-- Метаданные RIPEstat после резолва ASN (имя, число префиксов, время обновления).
ALTER TABLE module_as_entry ADD COLUMN asn_name TEXT;
ALTER TABLE module_as_entry ADD COLUMN prefix_count BIGINT;
ALTER TABLE module_as_entry ADD COLUMN asn_resolved_at TIMESTAMPTZ;
@@ -0,0 +1,4 @@
-- Требуется SQLite 3.35.0+ (DROP COLUMN).
ALTER TABLE module_as_entry DROP COLUMN asn_name;
ALTER TABLE module_as_entry DROP COLUMN prefix_count;
ALTER TABLE module_as_entry DROP COLUMN asn_resolved_at;
@@ -0,0 +1,4 @@
-- Метаданные RIPEstat после резолва ASN (имя, число префиксов, время обновления).
ALTER TABLE module_as_entry ADD COLUMN asn_name TEXT;
ALTER TABLE module_as_entry ADD COLUMN prefix_count INTEGER;
ALTER TABLE module_as_entry ADD COLUMN asn_resolved_at TEXT;
+6
View File
@@ -38,6 +38,12 @@ export type AsEntry = {
id: string;
asn: number;
community_id: string | null;
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
asn_name?: string | null;
/** Число объявленных префиксов на момент последнего резолва */
prefix_count?: number | null;
/** ISO-время последнего успешного резолва ASN */
asn_resolved_at?: string | null;
};
export type AsEntryCreate = {
asn: number;
+21 -2
View File
@@ -474,7 +474,9 @@
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>Номер AS и привязка к BGP community</CardDescription>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном обновлении модуля (RIPEstat)
</CardDescription>
</div>
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
</CardHeader>
@@ -483,6 +485,9 @@
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Название AS</TableHead>
<TableHead class="text-right">Префиксов</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead>Community</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
@@ -491,6 +496,20 @@
{#each asEntries as entry (entry.id)}
<TableRow>
<TableCell class="font-mono">{entry.asn}</TableCell>
<TableCell
class="text-muted-foreground text-sm max-w-[14rem] truncate"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</TableCell>
<TableCell class="text-right font-mono text-sm">
{entry.prefix_count != null && entry.prefix_count !== undefined ? entry.prefix_count : '—'}
</TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{entry.asn_resolved_at
? new Date(entry.asn_resolved_at).toLocaleString('ru-RU')
: '—'}
</TableCell>
<TableCell class="text-muted-foreground text-sm">{communityName(entry.community_id)}</TableCell>
<TableCell>
<div class="flex gap-1">
@@ -501,7 +520,7 @@
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
<TableCell colspan={6} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
</TableRow>
{/each}
</TableBody>