From c958d5af0df64a998f4ac929c62b95a357e93208 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Apr 2026 01:49:31 +0700 Subject: [PATCH] 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. --- internal/asnresolve/ripestat.go | 48 +++++++++++++ internal/asnresolve/ripestat_test.go | 21 ++++++ internal/httpapi/routes_crud.go | 17 +++++ internal/pipeline/refresh.go | 10 +++ internal/repository/postgres_entities.go | 72 ++++++++++++++++--- internal/store/backend.go | 13 ++-- internal/store/memory_crud.go | 24 +++++++ .../000004_as_entry_resolve_meta.down.sql | 3 + .../000004_as_entry_resolve_meta.up.sql | 4 ++ .../000004_as_entry_resolve_meta.down.sql | 4 ++ .../000004_as_entry_resolve_meta.up.sql | 4 ++ web/src/lib/api/types.ts | 6 ++ .../routes/modules/[moduleId]/+page.svelte | 23 +++++- 13 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 migrations/postgres/000004_as_entry_resolve_meta.down.sql create mode 100644 migrations/postgres/000004_as_entry_resolve_meta.up.sql create mode 100644 migrations/sqlite/000004_as_entry_resolve_meta.down.sql create mode 100644 migrations/sqlite/000004_as_entry_resolve_meta.up.sql diff --git a/internal/asnresolve/ripestat.go b/internal/asnresolve/ripestat.go index 6d9a94d..5107d44 100644 --- a/internal/asnresolve/ripestat.go +++ b/internal/asnresolve/ripestat.go @@ -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 { diff --git a/internal/asnresolve/ripestat_test.go b/internal/asnresolve/ripestat_test.go index 4d3a935..4e0c374 100644 --- a/internal/asnresolve/ripestat_test.go +++ b/internal/asnresolve/ripestat_test.go @@ -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) + } +} diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index b74c777..2b60b63 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -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 } diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 05f0545..cf1e3e9 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -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() diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go index cab303f..4545534 100644 --- a/internal/repository/postgres_entities.go +++ b/internal/repository/postgres_entities.go @@ -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 diff --git a/internal/store/backend.go b/internal/store/backend.go index e616122..8409091 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -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 { diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 5eca7d3..f643830 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -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() diff --git a/migrations/postgres/000004_as_entry_resolve_meta.down.sql b/migrations/postgres/000004_as_entry_resolve_meta.down.sql new file mode 100644 index 0000000..43a6538 --- /dev/null +++ b/migrations/postgres/000004_as_entry_resolve_meta.down.sql @@ -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; diff --git a/migrations/postgres/000004_as_entry_resolve_meta.up.sql b/migrations/postgres/000004_as_entry_resolve_meta.up.sql new file mode 100644 index 0000000..c79054d --- /dev/null +++ b/migrations/postgres/000004_as_entry_resolve_meta.up.sql @@ -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; diff --git a/migrations/sqlite/000004_as_entry_resolve_meta.down.sql b/migrations/sqlite/000004_as_entry_resolve_meta.down.sql new file mode 100644 index 0000000..aded5c4 --- /dev/null +++ b/migrations/sqlite/000004_as_entry_resolve_meta.down.sql @@ -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; diff --git a/migrations/sqlite/000004_as_entry_resolve_meta.up.sql b/migrations/sqlite/000004_as_entry_resolve_meta.up.sql new file mode 100644 index 0000000..27efcc6 --- /dev/null +++ b/migrations/sqlite/000004_as_entry_resolve_meta.up.sql @@ -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; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index a6a5a35..5d29184 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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; diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index a644077..0cb6c60 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -474,7 +474,9 @@
AS-записи - Номер AS и привязка к BGP community + + Номер AS и community; имя, число префиксов и дата обновляются при успешном обновлении модуля (RIPEstat) +
@@ -483,6 +485,9 @@ ASN + Название AS + Префиксов + Обновлено Community @@ -491,6 +496,20 @@ {#each asEntries as entry (entry.id)} {entry.asn} + + {entry.asn_name?.trim() ? entry.asn_name : '—'} + + + {entry.prefix_count != null && entry.prefix_count !== undefined ? entry.prefix_count : '—'} + + + {entry.asn_resolved_at + ? new Date(entry.asn_resolved_at).toLocaleString('ru-RU') + : '—'} + {communityName(entry.community_id)}
@@ -501,7 +520,7 @@ {:else} - Нет записей + Нет записей {/each}