// Package bird renders BIRD2 configuration for flock-agent. The agent // writes the rendered file to a shared volume; the bird sidecar reads it, // and the agent calls birdc reload (over the shared birdc unix socket) on // changes. package bird import ( "bytes" "fmt" "net" "sort" "strings" "text/template" ) // NodeBGP describes the inputs needed to render a node's BIRD config. type NodeBGP struct { NodeName string RouterID string // IPv4 (any usable v4 on the node, typically the host's) LocalASN uint32 Peers []Peer // LocalV6 / LocalV4 are this node's local source addresses on the // same subnet as the v6 / v4 BGP peers. Used as `source address` in // each BGP protocol stanza (Cisco rejects v6 advertisements whose // next-hop is link-local-only — explicit source forces a global next- // hop self that crt001 accepts). LocalV6 string LocalV4 string // CIDR6 / CIDR4 are the per-node summary aggregates the agent wants // advertised. The agent installs blackhole kernel routes for each so // BIRD's protocol kernel imports them. CIDR6 []string CIDR4 []string // Anycast6/4 are the currently-Ready anycast /128 and /32 addresses. Anycast6 []string Anycast4 []string } type Peer struct { // Family is "v6" or "v4". Family string Address string ASN uint32 } const tpl = `# Generated by flock-agent. DO NOT EDIT. log syslog all; router id {{.RouterID}}; protocol device { scan time 10; } protocol direct { interface "lo"; } protocol kernel kernel6 { learn; ipv6 { import all; # Do NOT push BIRD static routes back to the kernel; the agent owns # the kernel host routes for anycast. BIRD static is for advertise only. export filter { if source = RTS_STATIC then reject; accept; }; }; } protocol kernel kernel4 { learn; ipv4 { import all; export filter { if source = RTS_STATIC then reject; accept; }; }; } protocol static static6 { ipv6; {{range $cidr := .CIDR6}}route {{$cidr}} blackhole; {{end}}{{range $a := .Anycast6}}route {{$a}}/128 blackhole; {{end}} } protocol static static4 { ipv4; {{range $cidr := .CIDR4}}route {{$cidr}} blackhole; {{end}}{{range $a := .Anycast4}}route {{$a}}/32 blackhole; {{end}} } {{range $i, $p := .Peers}}{{if eq $p.Family "v6"}} protocol bgp upstream6_{{$i}} { local{{if $.LocalV6}} {{$.LocalV6}}{{end}} as {{$.LocalASN}}; neighbor {{$p.Address}} as {{$p.ASN}}; graceful restart; ipv6 { import all; next hop self; export filter { {{range $cidr := $.CIDR6}}if net = {{$cidr}} then accept; {{end}}{{range $a := $.Anycast6}}if net = {{$a}}/128 then accept; {{end}}reject; }; }; } {{else if eq $p.Family "v4"}} protocol bgp upstream4_{{$i}} { local{{if $.LocalV4}} {{$.LocalV4}}{{end}} as {{$.LocalASN}}; neighbor {{$p.Address}} as {{$p.ASN}}; graceful restart; ipv4 { import all; next hop self; export filter { {{range $cidr := $.CIDR4}}if net = {{$cidr}} then accept; {{end}}{{range $a := $.Anycast4}}if net = {{$a}}/32 then accept; {{end}}reject; }; }; } {{end}}{{end}}` // Render produces the bird.conf text. // // The output is deterministic: the same NodeBGP input always produces the // same string. CIDR lists, anycast lists, and peer lists are sorted before // templating so that the only way the rendered config changes is when // semantically meaningful inputs change. This stability matters because // BirdManager compares Render output against the last-written config to // avoid superfluous birdc reloads. // // Render validates every operator-supplied value that flows into the // templated output (peer addresses, CIDRs, anycast IPs, source addresses) // so a malformed NodeConfig or annotation cannot produce a malformed // bird.conf — even one that BIRD would later reject. func Render(in NodeBGP) (string, error) { if in.RouterID == "" { return "", fmt.Errorf("bird render: RouterID is required") } if net.ParseIP(in.RouterID) == nil { return "", fmt.Errorf("bird render: RouterID %q is not a valid IP", in.RouterID) } if in.LocalASN == 0 { return "", fmt.Errorf("bird render: LocalASN is required") } if err := validateLocalSource(in.LocalV6, "v6"); err != nil { return "", err } if err := validateLocalSource(in.LocalV4, "v4"); err != nil { return "", err } for i, p := range in.Peers { if err := validatePeer(p); err != nil { return "", fmt.Errorf("bird render: peer[%d]: %w", i, err) } } if err := validateCIDRs(in.CIDR6, "v6"); err != nil { return "", fmt.Errorf("bird render: cidr6: %w", err) } if err := validateCIDRs(in.CIDR4, "v4"); err != nil { return "", fmt.Errorf("bird render: cidr4: %w", err) } if err := validateAnycastIPs(in.Anycast6, "v6"); err != nil { return "", fmt.Errorf("bird render: anycast6: %w", err) } if err := validateAnycastIPs(in.Anycast4, "v4"); err != nil { return "", fmt.Errorf("bird render: anycast4: %w", err) } in = normalize(in) t, err := template.New("bird").Parse(tpl) if err != nil { return "", fmt.Errorf("bird template parse: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, in); err != nil { return "", fmt.Errorf("bird template execute: %w", err) } return buf.String(), nil } // validatePeer checks that a peer entry has a parseable IP whose family // matches its declared Family field, and a non-zero ASN. func validatePeer(p Peer) error { if p.ASN == 0 { return fmt.Errorf("ASN must be non-zero") } ip := net.ParseIP(p.Address) if ip == nil { return fmt.Errorf("address %q is not a valid IP", p.Address) } isV4 := ip.To4() != nil switch p.Family { case "v6": if isV4 { return fmt.Errorf("address %q is IPv4 but Family is v6", p.Address) } case "v4": if !isV4 { return fmt.Errorf("address %q is IPv6 but Family is v4", p.Address) } default: return fmt.Errorf("Family %q must be v6 or v4", p.Family) } return nil } // validateCIDRs parses each entry as a CIDR and rejects family mismatches. // fam must be "v6" or "v4". func validateCIDRs(cidrs []string, fam string) error { for _, c := range cidrs { _, n, err := net.ParseCIDR(c) if err != nil { return fmt.Errorf("invalid CIDR %q: %w", c, err) } isV4 := n.IP.To4() != nil if fam == "v6" && isV4 { return fmt.Errorf("CIDR %q is IPv4, expected IPv6", c) } if fam == "v4" && !isV4 { return fmt.Errorf("CIDR %q is IPv6, expected IPv4", c) } } return nil } // validateAnycastIPs parses each entry as a literal IP (no prefix) and rejects // family mismatches. func validateAnycastIPs(ips []string, fam string) error { for _, s := range ips { ip := net.ParseIP(s) if ip == nil { return fmt.Errorf("invalid IP %q", s) } isV4 := ip.To4() != nil if fam == "v6" && isV4 { return fmt.Errorf("IP %q is IPv4, expected IPv6", s) } if fam == "v4" && !isV4 { return fmt.Errorf("IP %q is IPv6, expected IPv4", s) } } return nil } // validateLocalSource validates an optional LocalV6/LocalV4 source address. // Empty is allowed (BIRD picks its own); non-empty must be a parseable IP of // the matching family. func validateLocalSource(s, fam string) error { if s == "" { return nil } ip := net.ParseIP(s) if ip == nil { return fmt.Errorf("bird render: Local%s %q is not a valid IP", strings.ToUpper(fam), s) } isV4 := ip.To4() != nil if fam == "v6" && isV4 { return fmt.Errorf("bird render: LocalV6 %q is IPv4", s) } if fam == "v4" && !isV4 { return fmt.Errorf("bird render: LocalV4 %q is IPv6", s) } return nil } func normalize(in NodeBGP) NodeBGP { cp := in cp.CIDR6 = sortedUnique(in.CIDR6) cp.CIDR4 = sortedUnique(in.CIDR4) cp.Anycast6 = sortedUnique(in.Anycast6) cp.Anycast4 = sortedUnique(in.Anycast4) cp.Peers = append([]Peer(nil), in.Peers...) sort.SliceStable(cp.Peers, func(i, j int) bool { if cp.Peers[i].Family != cp.Peers[j].Family { return cp.Peers[i].Family < cp.Peers[j].Family } return cp.Peers[i].Address < cp.Peers[j].Address }) return cp } func sortedUnique(s []string) []string { if len(s) == 0 { return nil } cp := append([]string(nil), s...) sort.Strings(cp) out := cp[:0] for i, v := range cp { if i == 0 || v != cp[i-1] { out = append(out, v) } } return out } // FamilyOf returns "v6" or "v4" for a peer address string. func FamilyOf(addr string) string { ip := net.ParseIP(addr) if ip == nil { return "" } if ip.To4() != nil { return "v4" } return "v6" }