feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
CI / changes (push) Successful in 4s
CI / go (push) Failing after 6s
CI / bird2 (push) Has been skipped
CI / openapi (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-05 14:07:45 +07:00
parent 272542b92a
commit bf52b21150
131 changed files with 9222 additions and 17 deletions
+114
View File
@@ -0,0 +1,114 @@
package birdfmt
import (
"fmt"
"strings"
)
// BGPPeerIPv4Options describes a single BGP session (BIRD 2, IPv4 AF).
type BGPPeerIPv4Options struct {
ProtocolName string // e.g. evobgp_peer_uplink
LocalIP string // e.g. 192.0.2.1
LocalASN uint32
NeighborIP string
NeighborASN uint32
// ExportFilter is a filter name, or empty for "export all".
ExportFilter string
// ImportFilter is a filter name, or empty for "import all".
ImportFilter string
}
// RenderProtocolBGPIPv4 renders a protocol bgp { … ipv4 { … } } block.
func RenderProtocolBGPIPv4(opts BGPPeerIPv4Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv4 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
// BGPPeerIPv6Options describes a BGP session for the IPv6 AF.
type BGPPeerIPv6Options struct {
ProtocolName string
LocalIP string
LocalASN uint32
NeighborIP string
NeighborASN uint32
ExportFilter string
ImportFilter string
}
// RenderProtocolBGPIPv6 renders a protocol bgp block with ipv6 { import/export }.
func RenderProtocolBGPIPv6(opts BGPPeerIPv6Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv6 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package birdfmt
import (
"strings"
"testing"
)
func TestRenderProtocolBGPIPv4_Validation(t *testing.T) {
_, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{})
if err == nil {
t.Fatal("expected error")
}
}
func TestRenderProtocolBGPIPv4_ExportAll(t *testing.T) {
got, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{
ProtocolName: "p",
LocalIP: "192.0.2.1",
LocalASN: 1,
NeighborIP: "192.0.2.2",
NeighborASN: 2,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "export all;") {
t.Fatal(got)
}
}
+71
View File
@@ -0,0 +1,71 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// BirdCtl runs bird(8) and birdc(8) for parse checks and configure reload.
type BirdCtl struct {
// Bird is the bird binary path (default "bird").
Bird string
// Birdc is the birdc binary path (default "birdc").
Birdc string
// Socket is optional birdc control socket (-s); empty uses birdc default.
Socket string
}
func (c *BirdCtl) birdBin() string {
if strings.TrimSpace(c.Bird) != "" {
return c.Bird
}
return "bird"
}
func (c *BirdCtl) birdcBin() string {
if strings.TrimSpace(c.Birdc) != "" {
return c.Birdc
}
return "birdc"
}
// ParseCheck runs `bird -c <mainConfigPath> -p` to validate syntax without starting the daemon.
func (c *BirdCtl) ParseCheck(ctx context.Context, mainConfigPath string) error {
mainConfigPath = strings.TrimSpace(mainConfigPath)
if mainConfigPath == "" {
return fmt.Errorf("birdfmt: main config path is required for parse check")
}
cmd := exec.CommandContext(ctx, c.birdBin(), "-c", mainConfigPath, "-p")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("bird -p: %w: %s", err, msg)
}
return fmt.Errorf("bird -p: %w", err)
}
return nil
}
// Configure runs `birdc configure` to load the current config from disk (BIRD 2).
func (c *BirdCtl) Configure(ctx context.Context) error {
args := []string{"configure"}
if s := strings.TrimSpace(c.Socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, c.birdcBin(), args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("birdc configure: %w: %s", err, msg)
}
return fmt.Errorf("birdc configure: %w", err)
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
// 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).
//
// # 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)
//
// Include order should list filter definitions before protocols that reference them
// (see StandardIncludeFragments).
//
// # Apply workflow
//
// 1. Write new fragment files to a staging directory, run bird -c <bird.conf> -p.
// 2. Atomically swap staging → live config dir, then birdc configure (see BirdCtl).
package birdfmt
+79
View File
@@ -0,0 +1,79 @@
package birdfmt
import (
"fmt"
"net/netip"
"sort"
"strings"
)
// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix
// is in prefixes (exact CIDR match via set membership), and rejects others.
func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is4() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject).
func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
func TestRenderExportFilterIPv4_Empty(t *testing.T) {
got, err := RenderExportFilterIPv4("evobgp_x", nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "filter evobgp_x") {
t.Fatal(got)
}
if !strings.Contains(got, "reject;") {
t.Fatal(got)
}
}
func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
v6 := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6})
if err != nil {
t.Fatal(err)
}
if strings.Contains(got, "2001:db8") {
t.Fatal("v6 prefix should be skipped in v4 filter")
}
}
func TestRenderExportFilterIPv6(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p})
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile("testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(got) != strings.TrimSpace(string(want)) {
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want))
}
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"fmt"
"strings"
)
// ManagedBanner returns a comment block marking EvoBGP-generated files (optional first line in bird.d/*.conf).
func ManagedBanner(revisionHint string) string {
rev := strings.TrimSpace(revisionHint)
var b strings.Builder
b.WriteString("# EvoBGP generated — do not edit by hand.\n")
if rev != "" {
b.WriteString("# Revision: ")
b.WriteString(rev)
b.WriteByte('\n')
}
return b.String()
}
// JoinFragments concatenates non-empty text blocks with a blank line between them.
func JoinFragments(parts ...string) string {
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n\n") + "\n"
}
// ValidateIncludePath rejects paths that could break out of the config directory.
func ValidateIncludePath(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return fmt.Errorf("birdfmt: include path is empty")
}
if strings.Contains(p, "..") {
return fmt.Errorf("birdfmt: include path must not contain '..': %q", p)
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package birdfmt
import "testing"
func TestValidateIncludePath(t *testing.T) {
if err := ValidateIncludePath("bird.d/x.conf"); err != nil {
t.Fatal(err)
}
if err := ValidateIncludePath("../etc/passwd"); err == nil {
t.Fatal("expected error for ..")
}
}
func TestJoinFragments(t *testing.T) {
got := JoinFragments("a", "", "b")
if got != "a\n\nb\n" {
t.Fatalf("%q", got)
}
if JoinFragments() != "" {
t.Fatal("empty join should be empty")
}
}
+95
View File
@@ -0,0 +1,95 @@
package birdfmt
import (
"fmt"
"strings"
)
// Directory and fragment file names for EvoBGP-generated includes (relative to bird.conf).
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"
)
// FragmentIncludePath returns a POSIX include path such as bird.d/evobgp_prefixes_v4.conf.
func FragmentIncludePath(fragmentBaseName string) string {
if fragmentBaseName == "" {
return DirBirdD + "/"
}
return DirBirdD + "/" + fragmentBaseName
}
// StandardIncludeFragments is the recommended order: filters before peers that reference them.
func StandardIncludeFragments() []string {
return []string{
FragmentIncludePath(FragmentFiltersV4),
FragmentIncludePath(FragmentFiltersV6),
FragmentIncludePath(FragmentPrefixesV4),
FragmentIncludePath(FragmentPrefixesV6),
FragmentIncludePath(FragmentPeers),
}
}
// MainBirdConfOptions describes the top-level bird.conf skeleton EvoBGP expects beside bird.d/.
type MainBirdConfOptions struct {
// RouterID is the BIRD router id (IPv4 dotted quad recommended).
RouterID string
// Includes are paths as in include "…" (e.g. bird.d/evobgp_prefixes_v4.conf).
Includes []string
// Preamble is optional comment lines (each line prefixed with #), no trailing newline required.
Preamble string
}
// RenderMainBirdConf returns a BIRD 2 main config: device, direct, include lines.
// RouterID must be non-empty.
func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) {
if strings.TrimSpace(opts.RouterID) == "" {
return "", fmt.Errorf("birdfmt: router id is required")
}
var b strings.Builder
pre := strings.TrimSpace(opts.Preamble)
if pre != "" {
for _, line := range strings.Split(pre, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
b.WriteByte('\n')
continue
}
if !strings.HasPrefix(line, "#") {
b.WriteString("# ")
}
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
}
b.WriteString("router id ")
b.WriteString(strings.TrimSpace(opts.RouterID))
b.WriteString(";\n\n")
for _, inc := range opts.Includes {
inc = strings.TrimSpace(inc)
if inc == "" {
continue
}
b.WriteString("include \"")
b.WriteString(inc)
b.WriteString("\";\n")
}
if len(opts.Includes) > 0 {
b.WriteByte('\n')
}
b.WriteString(`protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`)
return b.String(), nil
}
+55
View File
@@ -0,0 +1,55 @@
package birdfmt
import (
_ "embed"
"strings"
"testing"
)
//go:embed testdata/golden/main_bird_skeleton.golden
var goldenMainBirdSkeleton string
func TestRenderMainBirdConf_Golden(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
})
if err != nil {
t.Fatal(err)
}
want := strings.TrimSuffix(goldenMainBirdSkeleton, "\n")
got = strings.TrimSuffix(got, "\n")
if got != want {
t.Fatalf("golden mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestRenderMainBirdConf_Preamble(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: nil,
Preamble: "operator note\n# already commented",
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, "# operator note\n") {
t.Fatalf("expected preamble prefix, got:\n%s", got)
}
if !strings.Contains(got, "# # already commented") {
t.Fatal(got)
}
}
func TestRenderMainBirdConf_Errors(t *testing.T) {
_, err := RenderMainBirdConf(MainBirdConfOptions{})
if err == nil {
t.Fatal("expected error for empty router id")
}
}
func TestFragmentIncludePath(t *testing.T) {
if p := FragmentIncludePath(FragmentPrefixesV4); p != "bird.d/evobgp_prefixes_v4.conf" {
t.Fatal(p)
}
}
+52
View File
@@ -0,0 +1,52 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// ShowProtocols runs `birdc [-s socket] show protocols all` and returns stdout (BIRD 2).
func ShowProtocols(ctx context.Context, socket, birdcBin string) (string, error) {
if birdcBin == "" {
birdcBin = "birdc"
}
args := []string{"show", "protocols", "all"}
if s := strings.TrimSpace(socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, birdcBin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("birdc show protocols: %w: %s", err, msg)
}
return "", fmt.Errorf("birdc show protocols: %w", err)
}
return stdout.String(), nil
}
// CountEstablishedBGPSessions counts BGP protocol rows whose line contains "Established"
// (heuristic for `birdc show protocols` / `show protocols all` output).
func CountEstablishedBGPSessions(showProtocolsOutput string) int {
lines := strings.Split(showProtocolsOutput, "\n")
n := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "name") || strings.HasPrefix(strings.ToLower(line), "table") {
continue
}
if !strings.Contains(strings.ToLower(line), "bgp") {
continue
}
if strings.Contains(strings.ToLower(line), "established") {
n++
}
}
return n
}
+16
View File
@@ -0,0 +1,16 @@
package birdfmt
import "testing"
func TestCountEstablishedBGPSessions(t *testing.T) {
sample := `
BIRD 2.14 ready.
Name Proto Table State Since Info
device1 Device --- up 10:00:00
uplink4 BGP --- up 10:00:05 Established
uplink6 BGP --- start 10:00:06 Active
`
if got := CountEstablishedBGPSessions(sample); got != 1 {
t.Fatalf("got %d want 1", got)
}
}
+1
View File
@@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
"large_prefix_list",
"minimal",
"mixed_static_bgp",
"standard_layout",
"static_ipv4",
"with_include",
}
+72
View File
@@ -0,0 +1,72 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
// Ensures generator output matches the standard_layout scenario files (CI bird -p).
func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) {
p4 := netip.MustParsePrefix("203.0.113.0/24")
f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4)
f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil)
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf", f6)
staticV4 := RenderStaticIPv4Protocol("evobgp_prefixes_v4", []netip.Prefix{p4})
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf", staticV4)
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",
})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf", peer)
main, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
Preamble: "tags: layout, include, filter, peer\nStandard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).",
})
if err != nil {
t.Fatal(err)
}
// Fixture has two header comment lines; RenderMainBirdConf adds # per line.
wantMain, err := os.ReadFile("testdata/scenarios/standard_layout/bird.conf")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(main) != strings.TrimSpace(string(wantMain)) {
t.Fatalf("main bird.conf mismatch\n--- got ---\n%s\n--- want ---\n%s", main, wantMain)
}
}
func assertFileEquals(t *testing.T, path, content string) {
t.Helper()
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(content) != strings.TrimSpace(string(want)) {
t.Fatalf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, content, string(want))
}
}
+31
View File
@@ -37,3 +37,34 @@ func RenderStaticIPv4Protocol(protocolName string, prefixes []netip.Prefix) stri
b.WriteString("}\n")
return b.String()
}
// RenderStaticIPv6Protocol renders a BIRD 2 `protocol static` block for IPv6 prefixes.
func RenderStaticIPv6Protocol(protocolName string, prefixes []netip.Prefix) string {
if protocolName == "" {
protocolName = "evobgp_static_v6"
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
uniq[p.String()] = p.Masked()
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("protocol static ")
b.WriteString(protocolName)
b.WriteString(" {\n ipv6;\n")
for _, k := range keys {
b.WriteString(" route ")
b.WriteString(k)
b.WriteString(" unreachable;\n")
}
b.WriteString("}\n")
return b.String()
}
+11
View File
@@ -32,3 +32,14 @@ func TestRenderStaticIPv4Protocol_Empty(t *testing.T) {
t.Fatal(got)
}
}
func TestRenderStaticIPv6Protocol_Dedup(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got := RenderStaticIPv6Protocol("evobgp_v6", []netip.Prefix{p, p})
if !strings.Contains(got, "2001:db8::/32") {
t.Fatal(got)
}
if !strings.Contains(got, "ipv6") {
t.Fatal(got)
}
}
@@ -0,0 +1,15 @@
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,18 @@
# tags: layout, include, filter, peer
# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,4 @@
filter evobgp_export_v4 {
if net ~ [ 203.0.113.0/24 ] then accept;
reject;
}
@@ -0,0 +1,3 @@
filter evobgp_export_v6 {
reject;
}
@@ -0,0 +1,8 @@
protocol bgp evobgp_peer_ci {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export filter evobgp_export_v4;
};
}
@@ -0,0 +1,4 @@
protocol static evobgp_prefixes_v4 {
ipv4;
route 203.0.113.0/24 unreachable;
}
@@ -0,0 +1,3 @@
protocol static evobgp_prefixes_v6 {
ipv6;
}