Files
EvoBGP/internal/pipeline/refresh.go
T
Denozordec e1aaa18377
CI / changes (push) Successful in 10s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 35s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m27s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m16s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 19s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m17s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m48s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m49s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m28s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m35s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m15s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m27s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m23s
feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce sorting of ASNs and manage announced prefixes with polite pauses for better API interaction. Update buildPreviewFragments to streamline route rendering for BIRD configurations.
2026-04-06 00:01:16 +07:00

452 lines
12 KiB
Go

package pipeline
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"os"
"sort"
"strconv"
"strings"
"evobgp/internal/asnresolve"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
"github.com/google/uuid"
)
const (
birdFilterNameV4 = "evobgp_export_v4"
birdFilterNameV6 = "evobgp_export_v6"
auxBirdFullExpanded = "_bird_full_expanded.conf"
)
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
func MaterializedASPrefixKey(asn int64) string {
return fmt.Sprintf("as:%d", asn)
}
// RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module.
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
if hc == nil {
hc = http.DefaultClient
}
mod, err := st.GetModule(tenantID, moduleID)
if err != nil {
return "", err
}
if !mod.Enabled {
return "", fmt.Errorf("module disabled")
}
var rows []store.PrefixRow
switch mod.Type {
case "IP_RANGES":
list, err := st.ListIPRangeEntries(tenantID, moduleID)
if err != nil {
return "", err
}
for _, e := range list {
comm := e.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
rows = append(rows, store.PrefixRow{Prefix: e.Prefix, CommunityID: comm, Source: "ip_range"})
}
case "AS_PREFIXES":
list, err := st.ListASEntries(tenantID, moduleID)
if err != nil {
return "", err
}
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
}
comm := e.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
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)
if err != nil {
return "", err
}
for _, src := range sources {
u := strings.TrimSpace(src.URL)
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
if strings.TrimSpace(src.Etag) != "" {
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
}
resp, err := hc.Do(req)
if err != nil {
return "", fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return "", fmt.Errorf("cdn url %s: %s", u, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
_ = resp.Body.Close()
if err != nil {
return "", err
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
}
for _, pfx := range ParseCIDRLines(string(body)) {
comm := src.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: "cdn:" + src.ID})
}
}
case "DOMAINS":
if _, err := st.ListDomainEntries(tenantID, moduleID); err != nil {
return "", err
}
// DNS/DoH resolution not wired yet; emit empty prefix set (valid revision).
default:
return "", fmt.Errorf("unknown module type %q", mod.Type)
}
revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, moduleID)
hash := hashMaterialization(moduleID, rows)
preview, err := buildPreviewFragments(st, tenantID, moduleID, revisionID, rows)
if err != nil {
return "", err
}
if err := st.CreateRenderRevision(revisionID, tenantID, moduleID, parent, hash, preview, rows); err != nil {
return "", err
}
return revisionID, nil
}
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
if len(items) == 0 {
return nil
}
id := items[0].ID
return &id
}
func hashMaterialization(moduleID string, rows []store.PrefixRow) string {
type line struct{ p, c, s string }
var lines []line
for _, r := range rows {
c := ""
if r.CommunityID != nil {
c = *r.CommunityID
}
lines = append(lines, line{r.Prefix, c, r.Source})
}
sort.Slice(lines, func(i, j int) bool {
if lines[i].p != lines[j].p {
return lines[i].p < lines[j].p
}
if lines[i].c != lines[j].c {
return lines[i].c < lines[j].c
}
return lines[i].s < lines[j].s
})
h := sha256.New()
h.Write([]byte(moduleID))
h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
}
return fmt.Sprintf("sha256:%x", h.Sum(nil))
}
func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID string, rows []store.PrefixRow) (map[string]string, error) {
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 {
return nil, err
}
f6, err := birdfmt.RenderExportFilterIPv6(birdFilterNameV6, v6, pathASNs)
if err != nil {
return nil, err
}
staticV4 := birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4", sr4)
staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6)
locals := birdLocalsFromStore(st, tenantID)
peersBody, err := renderPeersBirdFragment(st, tenantID, locals)
if err != nil {
return nil, err
}
main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{
RouterID: locals.routerID,
Includes: birdfmt.StandardIncludeFragments(),
Preamble: fmt.Sprintf("EvoBGP module %s revision %s", moduleID, revisionID),
})
if err != nil {
return nil, err
}
p4 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV4)
p6 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV6)
px4 := birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV4)
px6 := birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV6)
pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers)
out := map[string]string{
"bird.conf": main,
p4: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f4),
p6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f6),
px4: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV4),
px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6),
pPeers: peersBody,
}
out[auxBirdFullExpanded] = buildExpandedBirdText(main, out)
return out, nil
}
type birdLocals struct {
routerID string
localV4 string
localV6 string
localASN uint32
}
func birdLocalsFromStore(st store.Backend, tenantID string) birdLocals {
def := birdLocals{
routerID: "192.0.2.1",
localV4: "192.0.2.1",
localV6: "2001:db8::1",
localASN: 65001,
}
settings, err := st.ListGlobalSettings(tenantID)
if err != nil {
return def
}
loc := def
if s := stringFromSettingsMap(settings, "bird_router_id"); s != "" {
loc.routerID = s
}
if s := stringFromSettingsMap(settings, "bird_local_ipv4"); s != "" {
loc.localV4 = s
}
if s := stringFromSettingsMap(settings, "bird_local_ipv6"); s != "" {
loc.localV6 = s
}
if n := uint32FromSettingsMap(settings, "bird_local_asn"); n != 0 {
loc.localASN = n
}
return loc
}
func stringFromSettingsMap(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
func uint32FromSettingsMap(m map[string]any, key string) uint32 {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
if x >= 1 && x <= 4294967295 {
return uint32(x)
}
case int:
if x >= 1 && x <= 4294967295 {
return uint32(x)
}
case int64:
if x >= 1 && x <= 4294967295 {
return uint32(x)
}
case string:
if n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32); err == nil && n >= 1 {
return uint32(n)
}
}
return 0
}
type peerPolicyJSON struct {
LocalIPv4 string `json:"local_ipv4"`
LocalIPv6 string `json:"local_ipv6"`
LocalASN float64 `json:"local_asn"`
}
func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals) (string, error) {
peers := st.ListPeers(tenantID)
var parts []string
parts = append(parts, birdfmt.ManagedBanner("peers"))
for _, p := range peers {
if p == nil || !p.Enabled {
continue
}
neighbor := strings.TrimSpace(p.Neighbor)
if neighbor == "" {
continue
}
addr, err := netip.ParseAddr(neighbor)
if err != nil {
continue
}
if !store.ValidASN(p.RemoteASN) {
continue
}
pol := parsePeerPolicies(p.PoliciesJSON)
lv4 := loc.localV4
if strings.TrimSpace(pol.LocalIPv4) != "" {
lv4 = strings.TrimSpace(pol.LocalIPv4)
}
lv6 := loc.localV6
if strings.TrimSpace(pol.LocalIPv6) != "" {
lv6 = strings.TrimSpace(pol.LocalIPv6)
}
asn := loc.localASN
if pol.LocalASN >= 1 && pol.LocalASN <= 4294967295 {
asn = uint32(pol.LocalASN)
}
proto := peerProtocolName(p.ID)
ra := uint32(p.RemoteASN)
if addr.Is4() {
s, err := birdfmt.RenderProtocolBGPIPv4(birdfmt.BGPPeerIPv4Options{
ProtocolName: proto,
LocalIP: lv4,
LocalASN: asn,
NeighborIP: addr.String(),
NeighborASN: ra,
ExportFilter: birdFilterNameV4,
})
if err != nil {
return "", err
}
parts = append(parts, s)
continue
}
if addr.Is6() {
s, err := birdfmt.RenderProtocolBGPIPv6(birdfmt.BGPPeerIPv6Options{
ProtocolName: proto,
LocalIP: lv6,
LocalASN: asn,
NeighborIP: addr.String(),
NeighborASN: ra,
ExportFilter: birdFilterNameV6,
})
if err != nil {
return "", err
}
parts = append(parts, s)
}
}
if len(parts) == 1 {
parts = append(parts, "# (no enabled BGP peers with valid neighbor addresses)\n")
}
return birdfmt.JoinFragments(parts...), nil
}
func parsePeerPolicies(raw string) peerPolicyJSON {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" {
return peerPolicyJSON{}
}
var pol peerPolicyJSON
_ = json.Unmarshal([]byte(raw), &pol)
return pol
}
func peerProtocolName(peerID string) string {
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
if len(s) > 16 {
s = s[:16]
}
if s == "" {
s = "x"
}
return "evobgp_p_" + s
}
// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview).
func buildExpandedBirdText(main string, frags map[string]string) string {
var b strings.Builder
b.WriteString(strings.TrimSpace(main))
b.WriteString("\n")
for _, inc := range birdfmt.StandardIncludeFragments() {
b.WriteString("\n# ---------- include \"")
b.WriteString(inc)
b.WriteString("\" ----------\n")
body := strings.TrimSpace(frags[inc])
if body == "" {
b.WriteString("# (empty)\n")
continue
}
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
}
return b.String()
}