diff --git a/internal/asnresolve/ripestat.go b/internal/asnresolve/ripestat.go new file mode 100644 index 0000000..6d9a94d --- /dev/null +++ b/internal/asnresolve/ripestat.go @@ -0,0 +1,108 @@ +// 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" + "strconv" + "strings" + "time" +) + +// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key). +const DefaultRIPEStatURL = "https://stat.ripe.net/data/announced-prefixes/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 { + hc = http.DefaultClient + } + 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 := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200)) + } + + 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 +} + +func truncateForErr(b []byte, n int) string { + s := string(b) + if len(s) > n { + return s[:n] + "…" + } + return s +} + +// PolitePause is a short delay between upstream ASN lookups (same refresh). +func PolitePause() { + d := 150 * time.Millisecond + if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE_PAUSE_MS")); s != "" { + if ms, err := parsePositiveInt(s); err == nil && ms > 0 { + d = time.Duration(ms) * time.Millisecond + } + } + time.Sleep(d) +} + +func parsePositiveInt(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid") + } + return n, nil +} diff --git a/internal/asnresolve/ripestat_test.go b/internal/asnresolve/ripestat_test.go new file mode 100644 index 0000000..4d3a935 --- /dev/null +++ b/internal/asnresolve/ripestat_test.go @@ -0,0 +1,33 @@ +package asnresolve + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAnnouncedPrefixes_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":{"prefixes":[{"prefix":"203.0.113.0/24"},{"prefix":"2001:db8::/32"}]}}`)) + })) + defer ts.Close() + + t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", ts.URL) + t.Setenv("EVOBGP_ASN_RESOLVE_PAUSE_MS", "1") + + pfx, err := AnnouncedPrefixes(context.Background(), ts.Client(), 64496) + if err != nil { + t.Fatal(err) + } + if len(pfx) != 2 { + t.Fatalf("got %d prefixes", len(pfx)) + } + if pfx[0].String() != "2001:db8::/32" || pfx[1].String() != "203.0.113.0/24" { + t.Fatalf("order or values: %#v", pfx) + } +} diff --git a/internal/birdfmt/community_bird.go b/internal/birdfmt/community_bird.go new file mode 100644 index 0000000..f0ed1b7 --- /dev/null +++ b/internal/birdfmt/community_bird.go @@ -0,0 +1,123 @@ +package birdfmt + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +var standardPairRE = regexp.MustCompile(`^\s*(\d{1,5}):(\d{1,5})\s*$`) + +// RouteCommunityAttrs returns BIRD 2 lines (indented with 4 spaces) for use inside a static `route … { … }` block. +// Empty string means no community attributes. +func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) { + name = strings.TrimSpace(name) + raw := strings.TrimSpace(valueJSON) + if raw == "" { + raw = "{}" + } + + if s, ok := parseLargeFromJSONLoose(raw); ok { + return s, nil + } + _ = kind // reserved for future kinds (e.g. extended communities) + + if m := standardPairRE.FindStringSubmatch(name); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + + var obj map[string]any + if json.Unmarshal([]byte(raw), &obj) == nil { + if v, ok := obj["standard"].(string); ok { + if m := standardPairRE.FindStringSubmatch(v); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + } + if arr, ok := obj["standard"].([]any); ok && len(arr) == 2 { + a, aok := numberToUint32String(arr[0]) + b, bok := numberToUint32String(arr[1]) + if aok && bok { + return standardAddLine(a, b) + } + } + } + + var strVal string + if json.Unmarshal([]byte(raw), &strVal) == nil { + if m := standardPairRE.FindStringSubmatch(strVal); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + } + + return "", nil +} + +func standardAddLine(a, b string) (string, error) { + ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16) + bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16) + if err1 != nil || err2 != nil { + return "", fmt.Errorf("birdfmt: standard community parts must be 0..65535 (%s:%s)", a, b) + } + return fmt.Sprintf(" bgp_community.add((%d,%d));", ai, bi), nil +} + +func parseLargeFromJSONLoose(raw string) (string, bool) { + var obj map[string]any + if json.Unmarshal([]byte(raw), &obj) != nil { + return "", false + } + arr, ok := obj["large"].([]any) + if !ok || len(arr) == 0 { + return "", false + } + var b strings.Builder + for _, row := range arr { + triple, ok := row.([]any) + if !ok || len(triple) != 3 { + continue + } + a, ok1 := numberToUint32String(triple[0]) + bb, ok2 := numberToUint32String(triple[1]) + c, ok3 := numberToUint32String(triple[2]) + if !ok1 || !ok2 || !ok3 { + continue + } + ai, _ := strconv.ParseUint(a, 10, 32) + bi, _ := strconv.ParseUint(bb, 10, 32) + ci, _ := strconv.ParseUint(c, 10, 32) + fmt.Fprintf(&b, " bgp_large_community.add((%d,%d,%d));\n", ai, bi, ci) + } + s := strings.TrimRight(b.String(), "\r\n") + return s, s != "" +} + +func numberToUint32String(v any) (string, bool) { + switch x := v.(type) { + case float64: + if x < 0 || x > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(x), 10), true + case json.Number: + n, err := x.Int64() + if err != nil || n < 0 || n > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(n), 10), true + case int: + if x < 0 || x > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(x), 10), true + case string: + n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32) + if err != nil { + return "", false + } + return strconv.FormatUint(n, 10), true + default: + return "", false + } +} diff --git a/internal/birdfmt/community_bird_test.go b/internal/birdfmt/community_bird_test.go new file mode 100644 index 0000000..f775c3b --- /dev/null +++ b/internal/birdfmt/community_bird_test.go @@ -0,0 +1,23 @@ +package birdfmt + +import "testing" + +func TestRouteCommunityAttrs_StandardName(t *testing.T) { + s, err := RouteCommunityAttrs("", "65000:120", "{}") + if err != nil { + t.Fatal(err) + } + if s != " bgp_community.add((65000,120));" { + t.Fatalf("got %q", s) + } +} + +func TestRouteCommunityAttrs_LargeJSON(t *testing.T) { + s, err := RouteCommunityAttrs("large", "x", `{"large":[[65000,1,2]]}`) + if err != nil { + t.Fatal(err) + } + if s != " bgp_large_community.add((65000,1,2));" { + t.Fatalf("got %q", s) + } +} diff --git a/internal/birdfmt/static_attrs.go b/internal/birdfmt/static_attrs.go new file mode 100644 index 0000000..5a7d7af --- /dev/null +++ b/internal/birdfmt/static_attrs.go @@ -0,0 +1,137 @@ +package birdfmt + +import ( + "net/netip" + "sort" + "strings" +) + +// StaticRoute is one static route line in BIRD (optionally with a per-route body). +type StaticRoute struct { + Prefix netip.Prefix + RouteBody string // optional lines inside `route P unreachable { ... }` +} + +// RenderStaticIPv4Routes renders a BIRD 2 protocol static block for IPv4. +func RenderStaticIPv4Routes(protocolName string, routes []StaticRoute) string { + if protocolName == "" { + protocolName = "evobgp_prefixes_v4" + } + type key struct { + p string + b string + } + uniq := make(map[key]StaticRoute) + for _, r := range routes { + if !r.Prefix.Addr().Is4() { + continue + } + p := r.Prefix.Masked() + body := strings.TrimSpace(r.RouteBody) + uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body} + } + keys := make([]key, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].p != keys[j].p { + return keys[i].p < keys[j].p + } + return keys[i].b < keys[j].b + }) + + var b strings.Builder + b.WriteString("protocol static ") + b.WriteString(protocolName) + b.WriteString(" {\n ipv4;\n") + for _, k := range keys { + r := uniq[k] + p := r.Prefix.String() + if r.RouteBody == "" { + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable;\n") + continue + } + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable {\n") + for _, line := range strings.Split(r.RouteBody, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(" };\n") + } + b.WriteString("}\n") + return b.String() +} + +// RenderStaticIPv6Routes renders a BIRD 2 protocol static block for IPv6. +func RenderStaticIPv6Routes(protocolName string, routes []StaticRoute) string { + if protocolName == "" { + protocolName = "evobgp_prefixes_v6" + } + type key struct { + p string + b string + } + uniq := make(map[key]StaticRoute) + for _, r := range routes { + if !r.Prefix.Addr().Is6() { + continue + } + p := r.Prefix.Masked() + body := strings.TrimSpace(r.RouteBody) + uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body} + } + keys := make([]key, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].p != keys[j].p { + return keys[i].p < keys[j].p + } + return keys[i].b < keys[j].b + }) + + var b strings.Builder + b.WriteString("protocol static ") + b.WriteString(protocolName) + b.WriteString(" {\n ipv6;\n") + for _, k := range keys { + r := uniq[k] + p := r.Prefix.String() + if r.RouteBody == "" { + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable;\n") + continue + } + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable {\n") + for _, line := range strings.Split(r.RouteBody, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(" };\n") + } + b.WriteString("}\n") + return b.String() +} diff --git a/internal/birdfmt/static_attrs_test.go b/internal/birdfmt/static_attrs_test.go new file mode 100644 index 0000000..2a053a1 --- /dev/null +++ b/internal/birdfmt/static_attrs_test.go @@ -0,0 +1,20 @@ +package birdfmt + +import ( + "net/netip" + "strings" + "testing" +) + +func TestRenderStaticIPv4Routes_WithCommunity(t *testing.T) { + p := netip.MustParsePrefix("203.0.113.0/24") + s := RenderStaticIPv4Routes("t", []StaticRoute{ + {Prefix: p, RouteBody: " bgp_community.add((65000,1));"}, + }) + if !strings.Contains(s, "route 203.0.113.0/24 unreachable {") { + t.Fatalf("missing block: %s", s) + } + if !strings.Contains(s, "bgp_community.add((65000,1))") { + t.Fatalf("missing community: %s", s) + } +} diff --git a/internal/pipeline/bird_materialize.go b/internal/pipeline/bird_materialize.go new file mode 100644 index 0000000..acbca63 --- /dev/null +++ b/internal/pipeline/bird_materialize.go @@ -0,0 +1,94 @@ +package pipeline + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + + "evobgp/internal/birdfmt" + "evobgp/internal/store" +) + +// materializeRowsForBird turns prefix rows into export-filter CIDR lists, optional AS_PATH rules, and static routes with per-prefix communities. +// Duplicate CIDRs: first occurrence wins for static community; filters use unique CIDRs. +func materializeRowsForBird(st store.Backend, tenantID string, rows []store.PrefixRow) ( + filterV4, filterV6 []netip.Prefix, + pathASNs []int64, + staticV4, staticV6 []birdfmt.StaticRoute, + err error, +) { + var pathSeen map[int64]struct{} + addPath := func(n int64) { + if pathSeen == nil { + pathSeen = make(map[int64]struct{}) + } + if _, ok := pathSeen[n]; ok { + return + } + pathSeen[n] = struct{}{} + pathASNs = append(pathASNs, n) + } + + filterSeen := make(map[string]struct{}) + staticChosen := make(map[string]birdfmt.StaticRoute) + + for _, pr := range rows { + p := strings.TrimSpace(pr.Prefix) + if strings.HasPrefix(p, "as:") { + n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64) + if err != nil || !store.ValidASN(n) { + continue + } + addPath(n) + continue + } + pfx, err := netip.ParsePrefix(p) + if err != nil { + continue + } + pfx = pfx.Masked() + key := pfx.String() + if _, ok := filterSeen[key]; !ok { + filterSeen[key] = struct{}{} + if pfx.Addr().Is4() { + filterV4 = append(filterV4, pfx) + } else { + filterV6 = append(filterV6, pfx) + } + } + + if _, ok := staticChosen[key]; ok { + continue + } + body, err := communityRouteBody(st, tenantID, pr.CommunityID) + if err != nil { + return nil, nil, nil, nil, nil, err + } + staticChosen[key] = birdfmt.StaticRoute{Prefix: pfx, RouteBody: body} + } + + for _, sr := range staticChosen { + if sr.Prefix.Addr().Is4() { + staticV4 = append(staticV4, sr) + } else if sr.Prefix.Addr().Is6() { + staticV6 = append(staticV6, sr) + } + } + return filterV4, filterV6, pathASNs, staticV4, staticV6, nil +} + +func communityRouteBody(st store.Backend, tenantID string, cid *string) (string, error) { + if cid == nil { + return "", nil + } + id := strings.TrimSpace(*cid) + if id == "" { + return "", nil + } + c, err := st.GetCommunity(tenantID, id) + if err != nil { + return "", fmt.Errorf("community %s: %w", id, err) + } + return birdfmt.RouteCommunityAttrs(c.Kind, c.Name, c.ValueJSON) +} diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 556a157..faad5ff 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -8,10 +8,12 @@ import ( "io" "net/http" "net/netip" + "os" "sort" "strconv" "strings" + "evobgp/internal/asnresolve" "evobgp/internal/birdfmt" "evobgp/internal/store" @@ -62,7 +64,10 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan if err != nil { return "", err } - for _, e := range list { + sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN }) + legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0" + seenPfx := make(map[string]struct{}) + for i, e := range list { if !store.ValidASN(e.ASN) { continue } @@ -71,7 +76,26 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan c := *mod.DefaultCommunityID comm = &c } - rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"}) + if legacy { + rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"}) + continue + } + if i > 0 { + asnresolve.PolitePause() + } + pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, e.ASN) + if err != nil { + return "", fmt.Errorf("resolve AS%d: %w", e.ASN, err) + } + src := fmt.Sprintf("as:%d", e.ASN) + for _, pfx := range pfxs { + k := pfx.String() + if _, ok := seenPfx[k]; ok { + continue + } + seenPfx[k] = struct{}{} + rows = append(rows, store.PrefixRow{Prefix: k, CommunityID: comm, Source: src}) + } } case "CDN_CIDRS": sources, err := st.ListCDNSources(tenantID, moduleID) @@ -187,27 +211,9 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string { } func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID string, rows []store.PrefixRow) (map[string]string, error) { - var v4, v6 []netip.Prefix - var pathASNs []int64 - for _, pr := range rows { - p := strings.TrimSpace(pr.Prefix) - if strings.HasPrefix(p, "as:") { - n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64) - if err != nil || !store.ValidASN(n) { - continue - } - pathASNs = append(pathASNs, n) - continue - } - pfx, err := netip.ParsePrefix(p) - if err != nil { - continue - } - if pfx.Addr().Is4() { - v4 = append(v4, pfx.Masked()) - } else { - v6 = append(v6, pfx.Masked()) - } + v4, v6, pathASNs, sr4, sr6, err := materializeRowsForBird(st, tenantID, rows) + if err != nil { + return nil, err } f4, err := birdfmt.RenderExportFilterIPv4(birdFilterNameV4, v4, pathASNs) if err != nil { @@ -217,8 +223,8 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri if err != nil { return nil, err } - staticV4 := birdfmt.RenderStaticIPv4Protocol("evobgp_prefixes_v4", v4) - staticV6 := birdfmt.RenderStaticIPv6Protocol("evobgp_prefixes_v6", v6) + staticV4 := birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4", sr4) + staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6) locals := birdLocalsFromStore(st, tenantID) peersBody, err := renderPeersBirdFragment(st, tenantID, locals) diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 356bd4f..112eee6 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -505,8 +505,10 @@ - - + + Ревизия {previewRevision?.id.slice(0, 8)}… Срендеренный конфиг BIRD 2 (фрагменты из control plane) и материализованные префиксы. @@ -515,48 +517,59 @@ {#if previewLoading}

Загрузка…

{:else} - - - Конфиг BIRD - Префиксы - - - {@const frags = asPreviewFragments(previewData)} - {#if Object.keys(frags).length === 0} -

Нет фрагментов превью (старая ревизия или пустой render).

- {:else} -
- - -
-

- Совет: откройте _bird_full_expanded.conf — один текст с - bird.conf и содержимым всех include. -

- -
{frags[birdPreviewPath] ?? ''}
-
- {/if} -
- -

Префиксов: {prefixesData.length}

- - {#each prefixesData as pfx} -

{pfx}

+
+ + + Конфиг BIRD + Префиксы + + + {@const frags = asPreviewFragments(previewData)} + {#if Object.keys(frags).length === 0} +

Нет фрагментов превью (старая ревизия или пустой render).

{:else} -

Нет префиксов

- {/each} - -
-
+
+ + +
+

+ Совет: откройте _bird_full_expanded.conf — один текст с + bird.conf и содержимым всех include. +

+
+
{frags[birdPreviewPath] ?? ''}
+
+ {/if} + + +

+ Префиксов: + {prefixesData.length} +

+
+ {#each prefixesData as pfx} +

{pfx}

+ {:else} +

Нет префиксов

+ {/each} +
+
+ +
{/if}