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 }