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}