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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
|
||||
"large_prefix_list",
|
||||
"minimal",
|
||||
"mixed_static_bgp",
|
||||
"standard_layout",
|
||||
"static_ipv4",
|
||||
"with_include",
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
filter evobgp_export_v4 {
|
||||
if net ~ [ 203.0.113.0/24 ] then accept;
|
||||
reject;
|
||||
}
|
||||
+3
@@ -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;
|
||||
};
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
protocol static evobgp_prefixes_v4 {
|
||||
ipv4;
|
||||
route 203.0.113.0/24 unreachable;
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
protocol static evobgp_prefixes_v6 {
|
||||
ipv6;
|
||||
}
|
||||
Reference in New Issue
Block a user