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.
1163 lines
32 KiB
Go
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()
|
|
}
|