diff --git a/internal/frr/frr.go b/internal/frr/frr.go index 9e78e017..ef5a3696 100644 --- a/internal/frr/frr.go +++ b/internal/frr/frr.go @@ -4,7 +4,10 @@ package frr import ( "context" + "encoding/binary" "fmt" + "hash/crc32" + "net" "os" "strings" "sync" @@ -32,10 +35,11 @@ type Status struct { } type FRR struct { - reloadConfig chan reloadEvent - logLevel string - Status Status - onStatusChanged StatusChanged + reloadConfig chan reloadEvent + logLevel string + Status Status + onStatusChanged StatusChanged + fallbackRouterID string sync.Mutex } @@ -54,10 +58,42 @@ func (f *FRR) ApplyConfig(config *Config) error { // TODO add internal wrapper config.Loglevel = f.logLevel config.Hostname = hostname + if f.fallbackRouterID != "" { + for _, r := range config.Routers { + if r.RouterID == "" { + r.RouterID = f.fallbackRouterID + } + } + } f.reloadConfig <- reloadEvent{config: config} return nil } +var netInterfaceAddrs = net.InterfaceAddrs + +func hasIPv4Address() bool { + addrs, err := netInterfaceAddrs() + if err != nil { + return false + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { + return true + } + } + return false +} + +func hashRouterID() (string, error) { + hostname, err := osHostname() + if err != nil { + return "", err + } + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, crc32.ChecksumIEEE([]byte(hostname))) + return net.IP(b).String(), nil +} + var debounceTimeout = 3 * time.Second var failureTimeout = time.Second * 5 @@ -71,6 +107,17 @@ func NewFRR(ctx context.Context, onStatusChanged StatusChanged, logger log.Logge return generateAndReloadConfigFile(config, logger) } + // On IPv6-only nodes, FRR defaults router-id to 0.0.0.0 (RFC 6286 violation). + if !hasIPv4Address() { + routerID, err := hashRouterID() + if err != nil { + level.Error(logger).Log("op", "startup", "error", fmt.Errorf("failed to generate fallback router-id: %w", err)) + } else { + res.fallbackRouterID = routerID + level.Info(logger).Log("op", "startup", "msg", "no IPv4 address found, using fallback router-id", "routerID", res.fallbackRouterID) + } + } + debouncer(ctx, reload, res.reloadConfig, debounceTimeout, failureTimeout, logger) res.pollStatus(ctx, logger) return res diff --git a/internal/frr/frr_test.go b/internal/frr/frr_test.go index 871e8b80..e26cac2d 100644 --- a/internal/frr/frr_test.go +++ b/internal/frr/frr_test.go @@ -6,6 +6,7 @@ import ( "context" "flag" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -80,6 +81,11 @@ func testSetup(t *testing.T) { osHostname = testOsHostname } +func testNewFRR(t *testing.T, ctx context.Context) *FRR { + t.Helper() + return NewFRR(ctx, emptyCB, log.NewNopLogger(), logging.LevelInfo) +} + func testCheckConfigFile(t *testing.T) { configFile, goldenFile := testGenerateFileNames(t) @@ -939,6 +945,82 @@ func TestSingleUnnumberedSession(t *testing.T) { testCheckConfigFile(t) } +func TestSingleSessionExplicitRouterID(t *testing.T) { + testSetup(t) + ctx, cancel := context.WithCancel(context.Background()) + frr := testNewFRR(t, ctx) + defer cancel() + + config := Config{ + Routers: []*RouterConfig{ + { + MyASN: 65000, + RouterID: "10.10.10.1", + Neighbors: []*NeighborConfig{ + { + IPFamily: ipfamily.IPv4, + ASN: "65001", + Addr: "192.168.1.2", + Port: ptr.To[uint16](4567), + Outgoing: AllowedOut{ + PrefixesV4: []string{ + "192.169.1.0/24", + }, + }, + }, + }, + IPV4Prefixes: []string{"192.169.1.0/24"}, + }, + }, + } + err := frr.ApplyConfig(&config) + if err != nil { + t.Fatalf("Failed to apply config: %s", err) + } + + testCheckConfigFile(t) +} + +func TestSingleSessionIPv6OnlyNode(t *testing.T) { + testSetup(t) + netInterfaceAddrs = func() ([]net.Addr, error) { + return nil, nil + } + t.Cleanup(func() { netInterfaceAddrs = net.InterfaceAddrs }) + + ctx, cancel := context.WithCancel(context.Background()) + frr := testNewFRR(t, ctx) + defer cancel() + + config := Config{ + Routers: []*RouterConfig{ + { + MyASN: 65000, + Neighbors: []*NeighborConfig{ + { + IPFamily: ipfamily.IPv6, + ASN: "65001", + Addr: "2001:db8::1", + Port: ptr.To[uint16](179), + Outgoing: AllowedOut{ + PrefixesV6: []string{ + "2001:db8:1::/64", + }, + }, + }, + }, + IPV6Prefixes: []string{"2001:db8:1::/64"}, + }, + }, + } + err := frr.ApplyConfig(&config) + if err != nil { + t.Fatalf("Failed to apply config: %s", err) + } + + testCheckConfigFile(t) +} + func communityPrefixListFor(neigID, comm string, ipFamily string, prefixes ...string) CommunityPrefixList { community, err := community.New(comm) if err != nil { diff --git a/internal/frr/testdata/TestSingleSessionExplicitRouterID.golden b/internal/frr/testdata/TestSingleSessionExplicitRouterID.golden new file mode 100644 index 00000000..90258077 --- /dev/null +++ b/internal/frr/testdata/TestSingleSessionExplicitRouterID.golden @@ -0,0 +1,54 @@ +log stdout informational +log timestamp precision 3 +hostname dummyhostname +ip nht resolve-via-default +ipv6 nht resolve-via-default + + + +ip prefix-list 192.168.1.2-allowed-ipv4 seq 1 permit 192.169.1.0/24 + + +ipv6 prefix-list 192.168.1.2-allowed-ipv6 seq 1 deny any + +route-map 192.168.1.2-out permit 1 + match ip address prefix-list 192.168.1.2-allowed-ipv4 + +route-map 192.168.1.2-out permit 2 + match ipv6 address prefix-list 192.168.1.2-allowed-ipv6 + + + + + +ip prefix-list 192.168.1.2-inpl-ipv4 seq 1 deny any + +ipv6 prefix-list 192.168.1.2-inpl-ipv4 seq 2 deny any +route-map 192.168.1.2-in permit 3 + match ip address prefix-list 192.168.1.2-inpl-ipv4 +route-map 192.168.1.2-in permit 4 + match ipv6 address prefix-list 192.168.1.2-inpl-ipv4 + +router bgp 65000 + no bgp ebgp-requires-policy + no bgp network import-check + no bgp default ipv4-unicast + bgp graceful-restart preserve-fw-state + + bgp router-id 10.10.10.1 + neighbor 192.168.1.2 remote-as 65001 + neighbor 192.168.1.2 port 4567 + + + + + address-family ipv4 unicast + neighbor 192.168.1.2 activate + neighbor 192.168.1.2 route-map 192.168.1.2-in in + neighbor 192.168.1.2 route-map 192.168.1.2-out out + exit-address-family + address-family ipv4 unicast + network 192.169.1.0/24 + exit-address-family + + diff --git a/internal/frr/testdata/TestSingleSessionIPv6OnlyNode.golden b/internal/frr/testdata/TestSingleSessionIPv6OnlyNode.golden new file mode 100644 index 00000000..6d72c231 --- /dev/null +++ b/internal/frr/testdata/TestSingleSessionIPv6OnlyNode.golden @@ -0,0 +1,55 @@ +log stdout informational +log timestamp precision 3 +hostname dummyhostname +ip nht resolve-via-default +ipv6 nht resolve-via-default + + + +ip prefix-list 2001:db8::1-allowed-ipv4 seq 1 deny any + + +ipv6 prefix-list 2001:db8::1-allowed-ipv6 seq 1 permit 2001:db8:1::/64 + +route-map 2001:db8::1-out permit 1 + match ip address prefix-list 2001:db8::1-allowed-ipv4 + +route-map 2001:db8::1-out permit 2 + match ipv6 address prefix-list 2001:db8::1-allowed-ipv6 + + + + + +ip prefix-list 2001:db8::1-inpl-ipv6 seq 1 deny any + +ipv6 prefix-list 2001:db8::1-inpl-ipv6 seq 2 deny any +route-map 2001:db8::1-in permit 3 + match ip address prefix-list 2001:db8::1-inpl-ipv6 +route-map 2001:db8::1-in permit 4 + match ipv6 address prefix-list 2001:db8::1-inpl-ipv6 + +router bgp 65000 + no bgp ebgp-requires-policy + no bgp network import-check + no bgp default ipv4-unicast + bgp graceful-restart preserve-fw-state + + bgp router-id 167.62.253.30 + neighbor 2001:db8::1 remote-as 65001 + neighbor 2001:db8::1 port 179 + + + + neighbor 2001:db8::1 disable-connected-check + + address-family ipv6 unicast + neighbor 2001:db8::1 activate + neighbor 2001:db8::1 route-map 2001:db8::1-in in + neighbor 2001:db8::1 route-map 2001:db8::1-out out + exit-address-family + address-family ipv6 unicast + network 2001:db8:1::/64 + exit-address-family + +