Files
EvoBGP/internal/pipeline/refresh.go
T
Denozordec 47764345f6 feat: enhance CDN source management with last refreshed timestamp
Added functionality to track the last refreshed timestamp for CDN sources. Updated the database schema and relevant methods to include the last refreshed timestamp during creation and updates. Implemented logic to skip fetching CDN sources based on their refresh interval, improving efficiency in the module prefix collection process. Enhanced the data retrieval methods to support the new timestamp field, ensuring accurate state management for CDN sources.
2026-04-09 16:04:53 +07:00

1163 lines
32 KiB
Go

package pipeline
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"evobgp/internal/asnresolve"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/miekg/dns"
)
const (
birdFilterNameV4 = "evobgp_export_v4"
birdFilterNameV6 = "evobgp_export_v6"
auxBirdFullExpanded = "_bird_full_expanded.conf"
revisionTTLKey = "revision_retention_minutes"
revisionMinTTLMin = 15
revisionMaxTTLMin = 30 * 24 * 60
revisionDefaultTTL = 30 * 24 * time.Hour
)
// 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)
}
// RefreshModuleIngest runs ingest for one module and persists side-effects (ASN metadata, CDN etags, etc).
// It does not create a new config revision.
func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) 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")
}
_, err = collectModulePrefixRows(ctx, st, hc, tenantID, mod)
if err != nil {
return err
}
return nil
}
// RenderTenantRevision renders one tenant-wide revision using current data from all enabled modules.
// If materialized prefixes are unchanged, returns latest revision id without creating a duplicate.
func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string) (revisionID string, err error) {
if hc == nil {
hc = http.DefaultClient
}
agg, err := aggregateTenantPrefixRowsAll(ctx, st, hc, tenantID)
if err != nil {
return "", err
}
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
return prev.ID, nil
}
agg = smartAggregatePrefixRows(agg)
revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, triggerModuleID)
preview, err := buildPreviewFragments(st, tenantID, triggerModuleID, revisionID, agg)
if err != nil {
return "", err
}
if err := st.CreateRenderRevision(revisionID, tenantID, triggerModuleID, parent, hash, preview, agg); err != nil {
return "", err
}
applyRevisionRetention(st, tenantID)
return revisionID, nil
}
// RenderTenantRevisionFromPrefixes renders one tenant-wide revision from already materialized prefixes.
// This is used for fast paths (e.g. peer-only changes) to avoid ingest/external fetches.
func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string, rows []store.PrefixRow) (revisionID string, err error) {
_ = ctx
if hc == nil {
hc = http.DefaultClient
}
agg := append([]store.PrefixRow(nil), rows...)
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
return prev.ID, nil
}
agg = smartAggregatePrefixRows(agg)
revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, triggerModuleID)
preview, err := buildPreviewFragments(st, tenantID, triggerModuleID, revisionID, agg)
if err != nil {
return "", err
}
if err := st.CreateRenderRevision(revisionID, tenantID, triggerModuleID, parent, hash, preview, agg); err != nil {
return "", err
}
applyRevisionRetention(st, tenantID)
return revisionID, nil
}
// RefreshModule keeps backwards-compatible behavior: module ingest + immediate tenant render.
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
if err := RefreshModuleIngest(ctx, st, hc, tenantID, moduleID); err != nil {
return "", err
}
return RenderTenantRevision(ctx, st, hc, tenantID, moduleID)
}
// collectModulePrefixRows returns materialized prefix rows for a single module (source of truth from store / ASN resolve / CDN fetch).
func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module) ([]store.PrefixRow, error) {
moduleID := mod.ID
switch mod.Type {
case "IP_RANGES":
list, err := st.ListIPRangeEntries(tenantID, moduleID)
if err != nil {
return nil, err
}
var rows []store.PrefixRow
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"})
}
return rows, nil
case "AS_PREFIXES":
list, err := st.ListASEntries(tenantID, moduleID)
if err != nil {
return nil, 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{})
var rows []store.PrefixRow
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 nil, 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 nil, 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()
if _, ok := seenPfx[k]; ok {
continue
}
seenPfx[k] = struct{}{}
rows = append(rows, store.PrefixRow{Prefix: k, CommunityID: comm, Source: src})
}
}
return rows, nil
case "CDN_CIDRS":
sources, err := st.ListCDNSources(tenantID, moduleID)
if err != nil {
return nil, err
}
var rows []store.PrefixRow
latestCDNRows := latestCDNRowsBySource(st, tenantID)
for _, src := range sources {
sourceKey := "cdn:" + src.ID
now := time.Now().UTC()
if shouldSkipCDNSourceFetch(src, now) {
if cached := latestCDNRows[sourceKey]; len(cached) > 0 {
rows = append(rows, cached...)
continue
}
}
u := strings.TrimSpace(src.URL)
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return nil, 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 nil, err
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{}
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
patch.Etag = &e
}
refreshedAt := now
patch.LastRefreshedAt = &refreshedAt
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath)
if err != nil {
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
}
for _, pfx := range pfxs {
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: sourceKey})
}
}
return rows, nil
case "DOMAINS":
entries, err := st.ListDomainEntries(tenantID, moduleID)
if err != nil {
return nil, err
}
var profile *store.DohProfile
if mod.DohProfileID != nil && strings.TrimSpace(*mod.DohProfileID) != "" {
profile, err = st.GetDohProfile(tenantID, strings.TrimSpace(*mod.DohProfileID))
if err != nil {
return nil, fmt.Errorf("get doh profile: %w", err)
}
}
var rows []store.PrefixRow
seen := make(map[string]struct{})
for _, e := range entries {
if e == nil {
continue
}
comm := e.CommunityID
if comm == nil && mod.DefaultCommunityID != nil {
c := *mod.DefaultCommunityID
comm = &c
}
addrs, err := resolveDomainIPs(ctx, hc, profile, e.FQDN)
if err != nil {
return nil, fmt.Errorf("resolve domain %q: %w", e.FQDN, err)
}
src := "domain:" + strings.TrimSpace(e.FQDN)
for _, ip := range addrs {
cidr := ipToHostPrefix(ip)
if cidr == "" {
continue
}
key := cidr + "|" + src
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
rows = append(rows, store.PrefixRow{
Prefix: cidr,
CommunityID: comm,
Source: src,
})
}
}
return rows, nil
default:
return nil, fmt.Errorf("unknown module type %q", mod.Type)
}
}
func shouldSkipCDNSourceFetch(src *store.CDNSource, now time.Time) bool {
if src == nil || src.RefreshIntervalSec == nil || *src.RefreshIntervalSec <= 0 || src.LastRefreshedAt == nil {
return false
}
nextRefreshAt := src.LastRefreshedAt.UTC().Add(time.Duration(*src.RefreshIntervalSec) * time.Second)
return now.UTC().Before(nextRefreshAt)
}
func latestCDNRowsBySource(st store.Backend, tenantID string) map[string][]store.PrefixRow {
out := make(map[string][]store.PrefixRow)
if st == nil {
return out
}
revs, _, _ := st.ListRevisions(tenantID, "", "", 1)
if len(revs) == 0 || strings.TrimSpace(revs[0].ID) == "" {
return out
}
revID := strings.TrimSpace(revs[0].ID)
cursor := ""
for {
page, next, more := st.ListRevisionPrefixes(tenantID, revID, cursor, 2000)
for _, row := range page {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
continue
}
out[row.Source] = append(out[row.Source], row)
}
if !more || strings.TrimSpace(next) == "" {
break
}
cursor = next
}
return out
}
type dohJSONAnswer struct {
Type int `json:"type"`
Data string `json:"data"`
}
type dohJSONResponse struct {
Answer []dohJSONAnswer `json:"Answer"`
}
func resolveDomainIPs(ctx context.Context, hc *http.Client, profile *store.DohProfile, fqdn string) ([]netip.Addr, error) {
host := strings.TrimSpace(strings.TrimSuffix(fqdn, "."))
if host == "" {
return nil, nil
}
if profile == nil || strings.TrimSpace(profile.URL) == "" {
return resolveDomainWithSystemDNS(ctx, host)
}
timeout := 10 * time.Second
if profile.TimeoutMs != nil && *profile.TimeoutMs > 0 {
timeout = time.Duration(*profile.TimeoutMs) * time.Millisecond
}
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
baseURL := strings.TrimSpace(profile.URL)
// Prefer RFC8484 dns-message transport. Some providers don't support dns-json.
v4, err4 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeA)
v6, err6 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeAAAA)
if err4 != nil {
// Fallback to JSON mode for providers that only expose dns-json.
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
}
if err6 != nil {
v6, err6 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "AAAA")
}
if err4 != nil && err6 != nil {
// Some DoH providers return non-JSON responses (RFC8484 dns-message, HTML error pages, etc.).
// Fall back to system resolver to avoid failing the whole module refresh.
ips, fallbackErr := resolveDomainWithSystemDNS(dctx, host)
if fallbackErr != nil {
return nil, fmt.Errorf("doh failed for A and AAAA: %v; %v; fallback dns failed: %w", err4, err6, fallbackErr)
}
return ips, nil
}
out := uniqAddrs(append(v4, v6...))
if len(out) > 0 {
return out, nil
}
// If DoH succeeds but returns no A/AAAA records, attempt system resolver as best-effort fallback.
ips, err := resolveDomainWithSystemDNS(dctx, host)
if err != nil {
return nil, err
}
return ips, nil
}
func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL, host string, qtype uint16) ([]netip.Addr, error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), qtype)
wire, err := msg.Pack()
if err != nil {
return nil, err
}
u, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("dns", base64.RawURLEncoding.EncodeToString(wire))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/dns-message")
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, err
}
parsed := new(dns.Msg)
if err := parsed.Unpack(raw); err != nil {
return nil, err
}
if parsed.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("doh rcode=%s", dns.RcodeToString[parsed.Rcode])
}
var out []netip.Addr
for _, rr := range parsed.Answer {
switch x := rr.(type) {
case *dns.A:
if qtype == dns.TypeA {
if ip, ok := netip.AddrFromSlice(x.A.To4()); ok {
out = append(out, ip.Unmap())
}
}
case *dns.AAAA:
if qtype == dns.TypeAAAA {
if ip, ok := netip.AddrFromSlice(x.AAAA.To16()); ok {
out = append(out, ip.Unmap())
}
}
}
}
// Some providers may return JSON even on dns-message request.
if len(out) == 0 && bytes.Contains(bytes.ToLower(raw), []byte(`"answer"`)) {
qs := "A"
if qtype == dns.TypeAAAA {
qs = "AAAA"
}
return resolveDomainWithDOHJSON(ctx, hc, baseURL, host, qs)
}
return uniqAddrs(out), nil
}
func resolveDomainWithSystemDNS(ctx context.Context, host string) ([]netip.Addr, error) {
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
return uniqAddrs(ips), nil
}
func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, host, qtype string) ([]netip.Addr, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("name", host)
q.Set("type", qtype)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/dns-json")
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var payload dohJSONResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return nil, err
}
var out []netip.Addr
for _, ans := range payload.Answer {
if (qtype == "A" && ans.Type != 1) || (qtype == "AAAA" && ans.Type != 28) {
continue
}
ip, err := netip.ParseAddr(strings.TrimSpace(ans.Data))
if err != nil {
continue
}
out = append(out, ip.Unmap())
}
return uniqAddrs(out), nil
}
func uniqAddrs(in []netip.Addr) []netip.Addr {
seen := make(map[string]struct{}, len(in))
out := make([]netip.Addr, 0, len(in))
for _, a := range in {
if !a.IsValid() {
continue
}
k := a.String()
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, a)
}
return out
}
func ipToHostPrefix(ip netip.Addr) string {
if !ip.IsValid() {
return ""
}
bits := 128
if ip.Is4() {
bits = 32
}
return netip.PrefixFrom(ip, bits).Masked().String()
}
// aggregateTenantPrefixRowsAll builds the union of materialized prefixes for all enabled modules
// using current source data from store/external resolvers.
func aggregateTenantPrefixRowsAll(ctx context.Context, st store.Backend, hc *http.Client, tenantID string) ([]store.PrefixRow, error) {
mods := st.ListModules(tenantID)
var out []store.PrefixRow
for _, m := range mods {
if m == nil || !m.Enabled {
continue
}
omod, err := st.GetModule(tenantID, m.ID)
if err != nil {
return nil, err
}
rows, err := collectModulePrefixRows(ctx, st, hc, tenantID, omod)
if err != nil {
return nil, fmt.Errorf("module %s: %w", m.ID, err)
}
out = append(out, rows...)
}
return out, nil
}
type prefixGroupKey struct {
community string
source string
}
// smartAggregatePrefixRows performs "safe" IPv4 CIDR aggregation after full tenant materialization.
// We aggregate only inside identical community/source groups to preserve BIRD attributes semantics.
func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
grouped := make(map[prefixGroupKey][]store.PrefixRow)
var passthrough []store.PrefixRow
for _, row := range rows {
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
if err != nil || !pfx.Addr().Is4() {
passthrough = append(passthrough, row)
continue
}
k := prefixGroupKey{source: row.Source}
if row.CommunityID != nil {
k.community = *row.CommunityID
}
r := row
r.Prefix = pfx.Masked().String()
grouped[k] = append(grouped[k], r)
}
out := append([]store.PrefixRow{}, passthrough...)
for _, grp := range grouped {
out = append(out, aggregateIPv4Group(grp)...)
}
return out
}
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
if len(rows) <= 1 {
return rows
}
set := make(map[string]store.PrefixRow, len(rows))
for _, row := range rows {
set[row.Prefix] = row
}
pruneCoveredPrefixes(set)
for {
if !mergeSiblingPrefixes(set) {
break
}
pruneCoveredPrefixes(set)
}
out := make([]store.PrefixRow, 0, len(set))
for _, row := range set {
out = append(out, row)
}
return out
}
func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
type item struct {
key string
pfx netip.Prefix
bits int
}
items := make([]item, 0, len(set))
for k := range set {
p, err := netip.ParsePrefix(k)
if err != nil || !p.Addr().Is4() {
continue
}
items = append(items, item{key: k, pfx: p, bits: p.Bits()})
}
sort.Slice(items, func(i, j int) bool {
if items[i].bits != items[j].bits {
return items[i].bits < items[j].bits
}
return items[i].key < items[j].key
})
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if items[j].bits <= items[i].bits {
continue
}
if items[i].pfx.Contains(items[j].pfx.Addr()) {
delete(set, items[j].key)
}
}
}
}
func mergeSiblingPrefixes(set map[string]store.PrefixRow) bool {
merged := false
seen := make(map[string]struct{}, len(set))
for key, row := range set {
if _, done := seen[key]; done {
continue
}
pfx, err := netip.ParsePrefix(key)
if err != nil || !pfx.Addr().Is4() {
continue
}
bits := pfx.Bits()
if bits <= 8 {
continue
}
netNum := ipv4PrefixNetwork(pfx)
blockSize := uint32(1) << (32 - bits)
siblingNet := netNum ^ blockSize
siblingPfx := netip.PrefixFrom(u32ToIPv4(siblingNet), bits).Masked().String()
_, ok := set[siblingPfx]
if !ok {
continue
}
parentBits := bits - 1
parentBlock := uint32(1) << (32 - parentBits)
parentNet := netNum & ^(parentBlock - 1)
parentPfx := netip.PrefixFrom(u32ToIPv4(parentNet), parentBits).Masked().String()
delete(set, key)
delete(set, siblingPfx)
parentRow := row
parentRow.Prefix = parentPfx
set[parentPfx] = parentRow
seen[key] = struct{}{}
seen[siblingPfx] = struct{}{}
merged = true
}
return merged
}
func ipv4PrefixNetwork(p netip.Prefix) uint32 {
a := p.Masked().Addr().As4()
return uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])
}
func u32ToIPv4(v uint32) netip.Addr {
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
}
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
}
// latestTenantRevision is the newest config_revision for the tenant (any module), or nil.
func latestTenantRevision(st store.Backend, tenantID string) *store.Revision {
items, _, _ := st.ListRevisions(tenantID, "", "", 1)
if len(items) == 0 {
return nil
}
return items[0]
}
// hashAggregatedMaterialization hashes the full tenant-wide prefix set used for BIRD (all enabled modules).
func hashAggregatedMaterialization(tenantID 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(strings.TrimSpace(tenantID)))
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 hashAggregatedMaterializationWithPeers(st store.Backend, tenantID 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(strings.TrimSpace(tenantID)))
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})
}
h.Write([]byte("peers"))
h.Write([]byte{0})
peers := st.ListPeers(tenantID)
sort.Slice(peers, func(i, j int) bool {
if peers[i] == nil || peers[j] == nil {
return i < j
}
return peers[i].ID < peers[j].ID
})
for _, p := range peers {
if p == nil {
continue
}
speakerID := ""
if p.SpeakerID != nil {
speakerID = strings.TrimSpace(*p.SpeakerID)
}
h.Write([]byte(strings.TrimSpace(p.ID)))
h.Write([]byte{1})
h.Write([]byte(strings.TrimSpace(p.Neighbor)))
h.Write([]byte{1})
h.Write([]byte(strconv.FormatInt(p.RemoteASN, 10)))
h.Write([]byte{1})
h.Write([]byte(strconv.FormatBool(p.Enabled)))
h.Write([]byte{1})
h.Write([]byte(strings.TrimSpace(p.PoliciesJSON)))
h.Write([]byte{1})
h.Write([]byte(speakerID))
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, staticGroups, 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, staticV6 := renderStaticProtocolsByCommunity(staticGroups)
locals := birdLocalsFromStore(st, tenantID)
tplBody, err := birdfmt.RenderBGPTemplates(birdfmt.BGPTemplatesOptions{
LocalASN: locals.localASN,
ExportFilterV4: birdFilterNameV4,
ExportFilterV6: birdFilterNameV6,
})
if err != nil {
return nil, err
}
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 tenant aggregate config (trigger module %s) revision %s", moduleID, revisionID),
})
if err != nil {
return nil, err
}
p4 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV4)
p6 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV6)
pTpl := birdfmt.FragmentIncludePath(birdfmt.FragmentBGPTemplate)
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),
pTpl: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), tplBody),
px4: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV4),
px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6),
pPeers: peersBody,
}
out[auxBirdFullExpanded] = buildExpandedBirdText(main, out)
return out, nil
}
func renderStaticProtocolsByCommunity(groups []staticCommunityRoutes) (string, string) {
var b4 strings.Builder
var b6 strings.Builder
for _, grp := range groups {
nameSuffix := communityProtocolSuffix(grp.CommunityID)
if len(grp.RoutesV4) > 0 {
b4.WriteString(birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4_"+nameSuffix, grp.RoutesV4))
}
if len(grp.RoutesV6) > 0 {
b6.WriteString(birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6_"+nameSuffix, grp.RoutesV6))
}
}
return b4.String(), b6.String()
}
func communityProtocolSuffix(communityID string) string {
raw := strings.TrimSpace(communityID)
if raw == "" {
return "default"
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
out := strings.Trim(b.String(), "_")
if out == "" {
return "default"
}
return "c_" + out
}
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
}
if s := stringFromSettingsMap(settings, "bird_bgp_source_ipv4"); s != "" {
// BIRD router id must be an IPv4 address; historically aligned with optional BGP source setting.
loc.routerID = strings.TrimSpace(s)
}
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
}
func intFromSettingsMap(m map[string]any, key string) int {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, err := strconv.Atoi(strings.TrimSpace(x))
if err == nil {
return n
}
}
return 0
}
func applyRevisionRetention(st store.Backend, tenantID string) {
settings, err := st.ListGlobalSettings(tenantID)
if err != nil {
return
}
ttl := revisionDefaultTTL
if minutes := intFromSettingsMap(settings, revisionTTLKey); minutes > 0 {
if minutes < revisionMinTTLMin {
minutes = revisionMinTTLMin
}
if minutes > revisionMaxTTLMin {
minutes = revisionMaxTTLMin
}
ttl = time.Duration(minutes) * time.Minute
}
cutoff := time.Now().UTC().Add(-ttl)
_, _ = st.PruneRevisionsBefore(tenantID, cutoff)
}
type peerPolicyJSON struct {
LocalIPv4 string `json:"local_ipv4"`
LocalIPv6 string `json:"local_ipv6"`
LocalASN float64 `json:"local_asn"`
}
func effectivePeerLocals(loc birdLocals, pol peerPolicyJSON) (v4, v6 string, asn uint32) {
v4 = strings.TrimSpace(loc.localV4)
v6 = strings.TrimSpace(loc.localV6)
if s := strings.TrimSpace(pol.LocalIPv4); s != "" {
v4 = s
}
if s := strings.TrimSpace(pol.LocalIPv6); s != "" {
v6 = s
}
asn = loc.localASN
if pol.LocalASN >= 1 && pol.LocalASN <= 4294967295 {
asn = uint32(pol.LocalASN)
}
return v4, v6, asn
}
// peerNeedsLocalOverride is true when the peer's effective local IP or ASN should override template "local as …" (add explicit "local <addr> as …" on the peer).
func peerNeedsLocalOverride(loc birdLocals, effLocal string, effASN uint32, ipv4 bool) bool {
if ipv4 {
return strings.TrimSpace(effLocal) != strings.TrimSpace(loc.localV4) || effASN != loc.localASN
}
return strings.TrimSpace(effLocal) != strings.TrimSpace(loc.localV6) || effASN != loc.localASN
}
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
}
addr, ok := store.ParsePeerNeighbor(p.Neighbor)
if !ok {
continue
}
if !store.ValidASN(p.RemoteASN) {
continue
}
pol := parsePeerPolicies(p.PoliciesJSON)
lv4, lv6, asn := effectivePeerLocals(loc, pol)
proto := peerProtocolName(p.ID)
ra := uint32(p.RemoteASN)
if addr.Is4() {
opts := birdfmt.BGPPeerFromTemplateOptions{
ProtocolName: proto,
TemplateName: birdfmt.BGPTemplateNameV4,
NeighborIP: addr.String(),
NeighborASN: ra,
}
if peerNeedsLocalOverride(loc, lv4, asn, true) {
opts.OverrideLocalIP = lv4
opts.OverrideLocalASN = asn
}
s, err := birdfmt.RenderProtocolBGPFromTemplate(opts)
if err != nil {
return "", err
}
parts = append(parts, s)
continue
}
if addr.Is6() {
opts := birdfmt.BGPPeerFromTemplateOptions{
ProtocolName: proto,
TemplateName: birdfmt.BGPTemplateNameV6,
NeighborIP: addr.String(),
NeighborASN: ra,
}
if peerNeedsLocalOverride(loc, lv6, asn, false) {
opts.OverrideLocalIP = lv6
opts.OverrideLocalASN = asn
}
s, err := birdfmt.RenderProtocolBGPFromTemplate(opts)
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()
}