From be8e7b5742a6cb529e0097a01e865776972ae3d3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Apr 2026 10:18:51 +0700 Subject: [PATCH] feat: enhance BGP template and peer configuration handling. Introduce policies_json parameter for local IP and ASN overrides in BGP speakers. Implement BGP template rendering and update peer configuration to utilize templates, improving flexibility in BGP setup. Add tests for new rendering logic and ensure proper integration with existing configurations. --- docs/openapi.yaml | 11 +- internal/birdfmt/bgp.go | 110 ++++++++++++++++++ internal/birdfmt/bgp_test.go | 47 ++++++++ internal/birdfmt/doc.go | 7 +- internal/birdfmt/layout.go | 14 ++- internal/birdfmt/standard_layout_test.go | 25 ++-- .../testdata/golden/main_bird_skeleton.golden | 1 + .../scenarios/standard_layout/bird.conf | 1 + .../bird.d/evobgp_bgp_template.conf | 15 +++ .../standard_layout/bird.d/evobgp_peers.conf | 10 +- internal/pipeline/refresh.go | 89 +++++++++----- 11 files changed, 279 insertions(+), 51 deletions(-) create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_bgp_template.conf diff --git a/docs/openapi.yaml b/docs/openapi.yaml index fc9fc18..f80abb8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -577,6 +577,13 @@ components: bgp_speaker_id: type: ["string", "null"] description: "`null` - политика для всех спикеров." + policies_json: + type: string + description: > + JSON-объект (строка). Поля `local_ipv4`, `local_ipv6`, `local_asn` переопределяют + глобальные `bird_local_ipv4` / `bird_local_ipv6` / `bird_local_asn` тенанта. + Если эффективный локальный адрес или ASN пира отличается от значений в шаблоне BIRD, + в сгенерированный блок `protocol bgp … from bgp_template` добавляется строка `local … as …`. additionalProperties: true BgpSpeaker: @@ -1744,7 +1751,9 @@ paths: patch: tags: [Peers] summary: Обновить пира - description: Политики, neighbor, ASN, привязка к `bgp_speaker_id` или `null` для всех спикеров. + description: > + Политики (`policies_json`: `local_ipv4`, `local_ipv6`, `local_asn`), neighbor, ASN, + привязка к `bgp_speaker_id` или `null` для всех спикеров. operationId: patchPeer parameters: - $ref: "#/components/parameters/IdempotencyKey" diff --git a/internal/birdfmt/bgp.go b/internal/birdfmt/bgp.go index 6f1b5bc..df0ac55 100644 --- a/internal/birdfmt/bgp.go +++ b/internal/birdfmt/bgp.go @@ -5,6 +5,116 @@ import ( "strings" ) +// BGP template names referenced by generated peers (BIRD 2). +const ( + BGPTemplateNameV4 = "bgp_template" + BGPTemplateNameV6 = "bgp_template_v6" +) + +// BGPTemplatesOptions holds tenant defaults for template bgp bgp_template (+ v6 mirror). +type BGPTemplatesOptions struct { + LocalIPv4 string + LocalIPv6 string + LocalASN uint32 + ExportFilterV4 string + ExportFilterV6 string +} + +// RenderBGPTemplates renders two template bgp blocks (IPv4 and IPv6 AFI). +func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) { + if strings.TrimSpace(opts.LocalIPv4) == "" || strings.TrimSpace(opts.LocalIPv6) == "" { + return "", fmt.Errorf("birdfmt: template local IPv4 and IPv6 are required") + } + if opts.LocalASN == 0 { + return "", fmt.Errorf("birdfmt: template local ASN must be non-zero") + } + exp4 := "all" + if strings.TrimSpace(opts.ExportFilterV4) != "" { + exp4 = "filter " + strings.TrimSpace(opts.ExportFilterV4) + } + exp6 := "all" + if strings.TrimSpace(opts.ExportFilterV6) != "" { + exp6 = "filter " + strings.TrimSpace(opts.ExportFilterV6) + } + var b strings.Builder + fmt.Fprintf(&b, "template bgp %s {\n", BGPTemplateNameV4) + b.WriteString(" local ") + b.WriteString(strings.TrimSpace(opts.LocalIPv4)) + fmt.Fprintf(&b, " as %d;\n", opts.LocalASN) + b.WriteString(" ipv4 {\n") + b.WriteString(" import all;\n") + b.WriteString(" export ") + b.WriteString(exp4) + b.WriteString(";\n") + b.WriteString(" };\n") + b.WriteString("}\n\n") + fmt.Fprintf(&b, "template bgp %s {\n", BGPTemplateNameV6) + b.WriteString(" local ") + b.WriteString(strings.TrimSpace(opts.LocalIPv6)) + fmt.Fprintf(&b, " as %d;\n", opts.LocalASN) + b.WriteString(" ipv6 {\n") + b.WriteString(" import all;\n") + b.WriteString(" export ") + b.WriteString(exp6) + b.WriteString(";\n") + b.WriteString(" };\n") + b.WriteString("}\n") + return b.String(), nil +} + +// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }. +type BGPPeerFromTemplateOptions struct { + ProtocolName string + TemplateName string + NeighborIP string + NeighborASN uint32 + SourceAddress string + // If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer). + OverrideLocalIP string + OverrideLocalASN uint32 +} + +// RenderProtocolBGPFromTemplate renders protocol bgp … from TEMPLATE { neighbor; multihop; source address; passive; }. +func RenderProtocolBGPFromTemplate(opts BGPPeerFromTemplateOptions) (string, error) { + if strings.TrimSpace(opts.ProtocolName) == "" { + return "", fmt.Errorf("birdfmt: protocol name is required") + } + if strings.TrimSpace(opts.TemplateName) == "" { + return "", fmt.Errorf("birdfmt: template name is required") + } + if strings.TrimSpace(opts.NeighborIP) == "" || strings.TrimSpace(opts.SourceAddress) == "" { + return "", fmt.Errorf("birdfmt: neighbor and source address are required") + } + if opts.NeighborASN == 0 { + return "", fmt.Errorf("birdfmt: neighbor ASN must be non-zero") + } + if opts.OverrideLocalIP != "" && opts.OverrideLocalASN == 0 { + return "", fmt.Errorf("birdfmt: override local ASN is required when override local IP is set") + } + + var b strings.Builder + b.WriteString("protocol bgp ") + b.WriteString(strings.TrimSpace(opts.ProtocolName)) + b.WriteString(" from ") + b.WriteString(strings.TrimSpace(opts.TemplateName)) + b.WriteString(" {\n") + if ip := strings.TrimSpace(opts.OverrideLocalIP); ip != "" { + b.WriteString(" local ") + b.WriteString(ip) + fmt.Fprintf(&b, " as %d;\n", opts.OverrideLocalASN) + } + b.WriteString(" neighbor ") + b.WriteString(strings.TrimSpace(opts.NeighborIP)) + fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN) + b.WriteString(" multihop;\n") + b.WriteString(" source address ") + b.WriteString(strings.TrimSpace(opts.SourceAddress)) + b.WriteString(";\n") + b.WriteString(" passive;\n") + b.WriteString("}\n") + return b.String(), nil +} + // BGPPeerIPv4Options describes a single BGP session (BIRD 2, IPv4 AF). type BGPPeerIPv4Options struct { ProtocolName string // e.g. evobgp_peer_uplink diff --git a/internal/birdfmt/bgp_test.go b/internal/birdfmt/bgp_test.go index 20e346f..c8c8101 100644 --- a/internal/birdfmt/bgp_test.go +++ b/internal/birdfmt/bgp_test.go @@ -27,3 +27,50 @@ func TestRenderProtocolBGPIPv4_ExportAll(t *testing.T) { t.Fatal(got) } } + +func TestRenderBGPTemplates_Validation(t *testing.T) { + _, err := RenderBGPTemplates(BGPTemplatesOptions{}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRenderProtocolBGPFromTemplate_MultihopPassive(t *testing.T) { + got, err := RenderProtocolBGPFromTemplate(BGPPeerFromTemplateOptions{ + ProtocolName: "evobgp_p_x", + TemplateName: BGPTemplateNameV4, + NeighborIP: "94.142.140.141", + NeighborASN: 65002, + SourceAddress: "77.232.38.173", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "from bgp_template") { + t.Fatal(got) + } + if !strings.Contains(got, "multihop;") || !strings.Contains(got, "passive;") { + t.Fatal(got) + } + if !strings.Contains(got, "source address 77.232.38.173;") { + t.Fatal(got) + } +} + +func TestRenderProtocolBGPFromTemplate_OverrideLocal(t *testing.T) { + got, err := RenderProtocolBGPFromTemplate(BGPPeerFromTemplateOptions{ + ProtocolName: "p", + TemplateName: BGPTemplateNameV4, + NeighborIP: "192.0.2.2", + NeighborASN: 2, + SourceAddress: "10.0.0.1", + OverrideLocalIP: "10.0.0.1", + OverrideLocalASN: 65099, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "local 10.0.0.1 as 65099;") { + t.Fatal(got) + } +} diff --git a/internal/birdfmt/doc.go b/internal/birdfmt/doc.go index 9fb4bd7..c4506a2 100644 --- a/internal/birdfmt/doc.go +++ b/internal/birdfmt/doc.go @@ -1,13 +1,14 @@ // Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include -// fragments under bird.d/, export filters, static route protocols, and minimal BGP peer -// blocks. Runtime helpers parse-check configs (bird -p) and reload (birdc configure). +// fragments under bird.d/, export filters, static route protocols, BGP templates, and +// BGP peer protocols (from template or standalone). Runtime helpers parse-check configs +// (bird -p) and reload (birdc configure). // // # Layout // // Operator keeps a stable bird.conf (or generated skeleton) next to EvoBGP fragments: // // bird.conf — router id, protocol device, protocol direct, include lines -// bird.d/evobgp_*.conf — generated prefixes, filters, peers (names from constants) +// bird.d/evobgp_*.conf — generated prefixes, filters, BGP templates, peers (names from constants) // // Include order should list filter definitions before protocols that reference them // (see StandardIncludeFragments). diff --git a/internal/birdfmt/layout.go b/internal/birdfmt/layout.go index 81c1ac1..4e6b25d 100644 --- a/internal/birdfmt/layout.go +++ b/internal/birdfmt/layout.go @@ -9,11 +9,12 @@ import ( const ( DirBirdD = "bird.d" - FragmentPrefixesV4 = "evobgp_prefixes_v4.conf" - FragmentPrefixesV6 = "evobgp_prefixes_v6.conf" - FragmentFiltersV4 = "evobgp_filters_v4.conf" - FragmentFiltersV6 = "evobgp_filters_v6.conf" - FragmentPeers = "evobgp_peers.conf" + FragmentPrefixesV4 = "evobgp_prefixes_v4.conf" + FragmentPrefixesV6 = "evobgp_prefixes_v6.conf" + FragmentFiltersV4 = "evobgp_filters_v4.conf" + FragmentFiltersV6 = "evobgp_filters_v6.conf" + FragmentBGPTemplate = "evobgp_bgp_template.conf" + FragmentPeers = "evobgp_peers.conf" ) // FragmentIncludePath returns a POSIX include path such as bird.d/evobgp_prefixes_v4.conf. @@ -24,11 +25,12 @@ func FragmentIncludePath(fragmentBaseName string) string { return DirBirdD + "/" + fragmentBaseName } -// StandardIncludeFragments is the recommended order: filters before peers that reference them. +// StandardIncludeFragments is the recommended order: filters before BGP templates and peers. func StandardIncludeFragments() []string { return []string{ FragmentIncludePath(FragmentFiltersV4), FragmentIncludePath(FragmentFiltersV6), + FragmentIncludePath(FragmentBGPTemplate), FragmentIncludePath(FragmentPrefixesV4), FragmentIncludePath(FragmentPrefixesV6), FragmentIncludePath(FragmentPeers), diff --git a/internal/birdfmt/standard_layout_test.go b/internal/birdfmt/standard_layout_test.go index 4e9c5c4..715c313 100644 --- a/internal/birdfmt/standard_layout_test.go +++ b/internal/birdfmt/standard_layout_test.go @@ -29,13 +29,24 @@ func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) { staticV6 := RenderStaticIPv6Protocol("evobgp_prefixes_v6", nil) assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf", staticV6) - peer, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{ - ProtocolName: "evobgp_peer_ci", - LocalIP: "192.0.2.1", - LocalASN: 65001, - NeighborIP: "192.0.2.2", - NeighborASN: 65002, - ExportFilter: "evobgp_export_v4", + tpl, err := RenderBGPTemplates(BGPTemplatesOptions{ + LocalIPv4: "192.0.2.1", + LocalIPv6: "2001:db8::1", + LocalASN: 65001, + ExportFilterV4: "evobgp_export_v4", + ExportFilterV6: "evobgp_export_v6", + }) + if err != nil { + t.Fatal(err) + } + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_bgp_template.conf", tpl) + + peer, err := RenderProtocolBGPFromTemplate(BGPPeerFromTemplateOptions{ + ProtocolName: "evobgp_peer_ci", + TemplateName: BGPTemplateNameV4, + NeighborIP: "192.0.2.2", + NeighborASN: 65002, + SourceAddress: "192.0.2.1", }) if err != nil { t.Fatal(err) diff --git a/internal/birdfmt/testdata/golden/main_bird_skeleton.golden b/internal/birdfmt/testdata/golden/main_bird_skeleton.golden index 243e238..8b2a658 100644 --- a/internal/birdfmt/testdata/golden/main_bird_skeleton.golden +++ b/internal/birdfmt/testdata/golden/main_bird_skeleton.golden @@ -2,6 +2,7 @@ router id 192.0.2.1; include "bird.d/evobgp_filters_v4.conf"; include "bird.d/evobgp_filters_v6.conf"; +include "bird.d/evobgp_bgp_template.conf"; include "bird.d/evobgp_prefixes_v4.conf"; include "bird.d/evobgp_prefixes_v6.conf"; include "bird.d/evobgp_peers.conf"; diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf index 801e927..4cd16a3 100644 --- a/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf @@ -5,6 +5,7 @@ router id 192.0.2.1; include "bird.d/evobgp_filters_v4.conf"; include "bird.d/evobgp_filters_v6.conf"; +include "bird.d/evobgp_bgp_template.conf"; include "bird.d/evobgp_prefixes_v4.conf"; include "bird.d/evobgp_prefixes_v6.conf"; include "bird.d/evobgp_peers.conf"; diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_bgp_template.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_bgp_template.conf new file mode 100644 index 0000000..43eda1c --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_bgp_template.conf @@ -0,0 +1,15 @@ +template bgp bgp_template { + local 192.0.2.1 as 65001; + ipv4 { + import all; + export filter evobgp_export_v4; + }; +} + +template bgp bgp_template_v6 { + local 2001:db8::1 as 65001; + ipv6 { + import all; + export filter evobgp_export_v6; + }; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf index 366c775..8acc7a7 100644 --- a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf @@ -1,8 +1,6 @@ -protocol bgp evobgp_peer_ci { - local 192.0.2.1 as 65001; +protocol bgp evobgp_peer_ci from bgp_template { neighbor 192.0.2.2 as 65002; - ipv4 { - import all; - export filter evobgp_export_v4; - }; + multihop; + source address 192.0.2.1; + passive; } diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index cf1e3e9..820987d 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -236,6 +236,16 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6) locals := birdLocalsFromStore(st, tenantID) + tplBody, err := birdfmt.RenderBGPTemplates(birdfmt.BGPTemplatesOptions{ + LocalIPv4: locals.localV4, + LocalIPv6: locals.localV6, + 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 @@ -252,6 +262,7 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri 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) @@ -260,6 +271,7 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri "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, @@ -346,6 +358,30 @@ type peerPolicyJSON struct { 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 differs from tenant defaults in the BGP template. +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 @@ -362,29 +398,22 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals) continue } pol := parsePeerPolicies(p.PoliciesJSON) - lv4 := loc.localV4 - if strings.TrimSpace(pol.LocalIPv4) != "" { - lv4 = strings.TrimSpace(pol.LocalIPv4) - } - lv6 := loc.localV6 - if strings.TrimSpace(pol.LocalIPv6) != "" { - lv6 = strings.TrimSpace(pol.LocalIPv6) - } - asn := loc.localASN - if pol.LocalASN >= 1 && pol.LocalASN <= 4294967295 { - asn = uint32(pol.LocalASN) - } + lv4, lv6, asn := effectivePeerLocals(loc, pol) proto := peerProtocolName(p.ID) ra := uint32(p.RemoteASN) if addr.Is4() { - s, err := birdfmt.RenderProtocolBGPIPv4(birdfmt.BGPPeerIPv4Options{ - ProtocolName: proto, - LocalIP: lv4, - LocalASN: asn, - NeighborIP: addr.String(), - NeighborASN: ra, - ExportFilter: birdFilterNameV4, - }) + opts := birdfmt.BGPPeerFromTemplateOptions{ + ProtocolName: proto, + TemplateName: birdfmt.BGPTemplateNameV4, + NeighborIP: addr.String(), + NeighborASN: ra, + SourceAddress: lv4, + } + if peerNeedsLocalOverride(loc, lv4, asn, true) { + opts.OverrideLocalIP = lv4 + opts.OverrideLocalASN = asn + } + s, err := birdfmt.RenderProtocolBGPFromTemplate(opts) if err != nil { return "", err } @@ -392,14 +421,18 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals) continue } if addr.Is6() { - s, err := birdfmt.RenderProtocolBGPIPv6(birdfmt.BGPPeerIPv6Options{ - ProtocolName: proto, - LocalIP: lv6, - LocalASN: asn, - NeighborIP: addr.String(), - NeighborASN: ra, - ExportFilter: birdFilterNameV6, - }) + opts := birdfmt.BGPPeerFromTemplateOptions{ + ProtocolName: proto, + TemplateName: birdfmt.BGPTemplateNameV6, + NeighborIP: addr.String(), + NeighborASN: ra, + SourceAddress: lv6, + } + if peerNeedsLocalOverride(loc, lv6, asn, false) { + opts.OverrideLocalIP = lv6 + opts.OverrideLocalASN = asn + } + s, err := birdfmt.RenderProtocolBGPFromTemplate(opts) if err != nil { return "", err }