2026-04-24 22:33:48 -05:00
|
|
|
package agent
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1"
|
|
|
|
|
"code.fritzlab.net/fritzlab/flock/pkg/routing/bird"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// BirdManager renders bird.conf and triggers birdc reload. Writes are
|
|
|
|
|
// debounced so a burst of NodeConfig / anycast changes coalesces.
|
|
|
|
|
type BirdManager struct {
|
|
|
|
|
NodeName string
|
|
|
|
|
ConfigPath string // /etc/flock/bird/bird.conf
|
|
|
|
|
BirdcSocket string // /run/flock/bird6.ctl (BIRD2 single-socket default)
|
|
|
|
|
BirdctlPath string // "birdc" — overridable for tests
|
|
|
|
|
Logger *slog.Logger
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
last string // last rendered output (de-dup)
|
|
|
|
|
debounce *time.Timer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render writes the config from a NodeConfig + anycast set. Idempotent —
|
|
|
|
|
// if the rendered content matches what we last wrote, no birdc reload.
|
|
|
|
|
func (b *BirdManager) Render(nc *flockv1alpha1.NodeConfig, anycast6, anycast4 []string, routerID string) error {
|
|
|
|
|
if nc == nil {
|
|
|
|
|
return fmt.Errorf("no NodeConfig")
|
|
|
|
|
}
|
|
|
|
|
in := bird.NodeBGP{
|
|
|
|
|
NodeName: b.NodeName,
|
|
|
|
|
RouterID: routerID,
|
|
|
|
|
LocalASN: nc.Spec.BGP.ASN,
|
|
|
|
|
CIDR6: nc.Spec.CIDR6,
|
|
|
|
|
CIDR4: nc.Spec.CIDR4,
|
|
|
|
|
Anycast6: anycast6,
|
|
|
|
|
Anycast4: anycast4,
|
|
|
|
|
}
|
2026-04-25 07:45:51 -05:00
|
|
|
// Pick a local source address per family that's on the same subnet as
|
|
|
|
|
// the BGP peer. crt001 rejects IPv6 advertisements whose next-hop is
|
|
|
|
|
// link-local-only; an explicit `source address` makes BIRD use a
|
|
|
|
|
// global next-hop self, which Cisco accepts.
|
2026-04-24 22:33:48 -05:00
|
|
|
for _, p := range nc.Spec.BGP.Peers {
|
|
|
|
|
fam := bird.FamilyOf(p.Address)
|
|
|
|
|
if fam == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
in.Peers = append(in.Peers, bird.Peer{Family: fam, Address: p.Address, ASN: p.ASN})
|
2026-04-25 07:45:51 -05:00
|
|
|
if local := localAddrSameSubnet(p.Address); local != "" {
|
|
|
|
|
if fam == "v6" && in.LocalV6 == "" {
|
|
|
|
|
in.LocalV6 = local
|
|
|
|
|
}
|
|
|
|
|
if fam == "v4" && in.LocalV4 == "" {
|
|
|
|
|
in.LocalV4 = local
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 22:33:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := bird.Render(in)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
if cfg == b.last {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(b.ConfigPath), 0o755); err != nil {
|
|
|
|
|
return fmt.Errorf("mkdir bird config dir: %w", err)
|
|
|
|
|
}
|
|
|
|
|
tmp := b.ConfigPath + ".tmp"
|
|
|
|
|
if err := os.WriteFile(tmp, []byte(cfg), 0o644); err != nil {
|
|
|
|
|
return fmt.Errorf("write bird.conf: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Rename(tmp, b.ConfigPath); err != nil {
|
|
|
|
|
return fmt.Errorf("rename bird.conf: %w", err)
|
|
|
|
|
}
|
|
|
|
|
b.last = cfg
|
|
|
|
|
b.scheduleReload()
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// scheduleReload coalesces birdc reload calls into ~500ms windows.
|
|
|
|
|
func (b *BirdManager) scheduleReload() {
|
|
|
|
|
if b.debounce != nil {
|
|
|
|
|
b.debounce.Stop()
|
|
|
|
|
}
|
|
|
|
|
b.debounce = time.AfterFunc(500*time.Millisecond, b.reload)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *BirdManager) reload() {
|
|
|
|
|
birdctl := b.BirdctlPath
|
|
|
|
|
if birdctl == "" {
|
|
|
|
|
birdctl = "birdc"
|
|
|
|
|
}
|
|
|
|
|
socket := b.BirdcSocket
|
|
|
|
|
if socket == "" {
|
|
|
|
|
socket = "/run/flock/bird.ctl"
|
|
|
|
|
}
|
|
|
|
|
cmd := exec.Command(birdctl, "-s", socket, "configure")
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
// First-run case: bird may not be ready yet — retry on next change.
|
|
|
|
|
if errors.Is(err, exec.ErrNotFound) || os.IsNotExist(err) {
|
|
|
|
|
b.Logger.Warn("birdc not available", "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
b.Logger.Warn("birdc reload failed", "err", err, "out", string(out))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
b.Logger.Info("birdc configure ok", "out", string(out))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SummaryRoutes installs blackhole kernel routes for each NodeConfig CIDR.
|
|
|
|
|
// BIRD's protocol kernel imports them so they get advertised. Idempotent.
|
|
|
|
|
func (b *BirdManager) SummaryRoutes(nc *flockv1alpha1.NodeConfig) error {
|
|
|
|
|
if nc == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
for _, c := range nc.Spec.CIDR6 {
|
|
|
|
|
if err := installBlackhole(c); err != nil {
|
|
|
|
|
b.Logger.Warn("blackhole route v6", "cidr", c, "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, c := range nc.Spec.CIDR4 {
|
|
|
|
|
if err := installBlackhole(c); err != nil {
|
|
|
|
|
b.Logger.Warn("blackhole route v4", "cidr", c, "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 07:45:51 -05:00
|
|
|
// localAddrSameSubnet finds an IP on a local interface that's in the same
|
|
|
|
|
// /64 (v6) or /24 (v4) as `peer`. Returns "" if none. Used to derive the
|
|
|
|
|
// `source address` for a BGP session.
|
|
|
|
|
func localAddrSameSubnet(peer string) string {
|
|
|
|
|
pip := net.ParseIP(peer)
|
|
|
|
|
if pip == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
addrs, err := net.InterfaceAddrs()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
v4 := pip.To4() != nil
|
|
|
|
|
for _, a := range addrs {
|
|
|
|
|
ipn, ok := a.(*net.IPNet)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
ip := ipn.IP
|
|
|
|
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (ip.To4() != nil) != v4 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Use the peer's mask (assume same subnet) for membership test.
|
|
|
|
|
var mask net.IPMask
|
|
|
|
|
if v4 {
|
|
|
|
|
mask = net.CIDRMask(24, 32)
|
|
|
|
|
} else {
|
|
|
|
|
mask = net.CIDRMask(64, 128)
|
|
|
|
|
}
|
|
|
|
|
peerSubnet := &net.IPNet{IP: pip, Mask: mask}
|
|
|
|
|
if peerSubnet.Contains(ip) {
|
|
|
|
|
if v4 {
|
|
|
|
|
return ip.To4().String()
|
|
|
|
|
}
|
|
|
|
|
return ip.To16().String()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:33:48 -05:00
|
|
|
func installBlackhole(cidr string) error {
|
|
|
|
|
// Use `ip` rather than netlink so this file stays portable for non-Linux
|
|
|
|
|
// builds (the agent on macOS just no-ops). The agent only runs in
|
|
|
|
|
// Kubernetes pods on Linux nodes, so the exec is fine.
|
|
|
|
|
_, _, err := net.ParseCIDR(cidr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
cmd := exec.Command("ip", "route", "replace", "blackhole", cidr)
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("ip route replace blackhole %s: %w (%s)", cidr, err, string(out))
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|