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)
+