144 lines
3.9 KiB
Go
144 lines
3.9 KiB
Go
|
|
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,
|
||
|
|
}
|
||
|
|
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})
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|