Files
EvoBGP/internal/pipeline/refresh.go
T
Denozordec 4f50350990
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 44s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m12s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m24s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m19s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m21s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m21s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
feat: implement revision retention settings and pruning logic
Added functionality to handle revision retention minutes in global settings, including validation for input values. Introduced a new method to prune old revisions based on the specified retention period, ensuring that only the most recent revisions are kept. Updated the frontend to allow users to configure the revision retention setting, enhancing the management of revision lifecycles.
2026-04-08 14:08:23 +07:00

1025 lines
28 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 := hashAggregatedMaterialization(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
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 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"))
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
}
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: "cdn:" + src.ID})
}
}
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)
}
}
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 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()
}