diff --git a/internal/client/ndp_internal_test.go b/internal/client/ndp_internal_test.go new file mode 100644 index 00000000000..2595c891508 --- /dev/null +++ b/internal/client/ndp_internal_test.go @@ -0,0 +1,80 @@ +package client + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseNDPNeigh(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + data string + want map[netip.Addr]net.HardwareAddr + }{{ + name: "typical", + data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" + + "2001:db8::1 dev eth0 lladdr 11:22:33:44:55:66 STALE\n", + want: map[netip.Addr]net.HardwareAddr{ + netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + netip.MustParseAddr("2001:db8::1"): {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}, + }, + }, { + name: "no_lladdr", + data: "fe80::1 dev eth0 FAILED\n", + want: map[netip.Addr]net.HardwareAddr{}, + }, { + name: "short_line", + data: "fe80::1 dev eth0\n", + want: map[netip.Addr]net.HardwareAddr{}, + }, { + name: "bad_ip", + data: "not-an-ip dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n", + want: map[netip.Addr]net.HardwareAddr{}, + }, { + name: "bad_mac", + data: "fe80::1 dev eth0 lladdr not-a-mac REACHABLE\n", + want: map[netip.Addr]net.HardwareAddr{}, + }, { + name: "router_flag", + data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff router REACHABLE\n", + want: map[netip.Addr]net.HardwareAddr{ + netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + }, + }, { + name: "empty", + data: "", + want: map[netip.Addr]net.HardwareAddr{}, + }, { + name: "mixed_valid_invalid", + data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" + + "bad line\n" + + "fe80::2 dev eth0 FAILED\n" + + "fe80::3 dev eth0 lladdr 11:22:33:44:55:66 DELAY\n", + want: map[netip.Addr]net.HardwareAddr{ + netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + netip.MustParseAddr("fe80::3"): {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}, + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := parseNDPNeigh([]byte(tc.data)) + require.Len(t, got, len(tc.want)) + + for addr, wantMAC := range tc.want { + gotMAC, ok := got[addr] + require.True(t, ok, "missing address %s", addr) + + assert.Equal(t, wantMAC, gotMAC) + } + }) + } +} diff --git a/internal/client/storage.go b/internal/client/storage.go index 42953d90ffc..659ea42aed4 100644 --- a/internal/client/storage.go +++ b/internal/client/storage.go @@ -1,12 +1,16 @@ package client import ( + "bufio" + "bytes" "context" "fmt" "log/slog" "net" "net/netip" + "os/exec" "slices" + "strings" "sync" "time" @@ -114,6 +118,10 @@ type StorageConfig struct { // configuration file. Each client must not be nil. InitialClients []*Persistent + // NDPData returns the raw output of the IPv6 neighbor table. If nil, + // defaults to running "ip -6 neigh". + NDPData func() ([]byte, error) + // ARPClientsUpdatePeriod defines how often [SourceARP] runtime client // information is updated. ARPClientsUpdatePeriod time.Duration @@ -150,6 +158,15 @@ type Storage struct { // arpDB is used to update [SourceARP] runtime client information. arpDB arpdb.Interface + // ndpData returns the raw output of the IPv6 neighbor table. + ndpData func() ([]byte, error) + + // ndpCache maps IPv6 addresses to MAC addresses from the kernel NDP + // neighbor table. Used to identify persistent clients querying over IPv6. + ndpCache map[netip.Addr]net.HardwareAddr + ndpCacheMu sync.RWMutex + ndpCacheAt time.Time + // done is the shutdown signaling channel. done chan struct{} @@ -173,6 +190,11 @@ func NewStorage(ctx context.Context, conf *StorageConfig) (s *Storage, err error tags := slices.Clone(allowedTags) slices.Sort(tags) + ndpData := conf.NDPData + if ndpData == nil { + ndpData = defaultNDPData + } + s = &Storage{ logger: conf.Logger, mu: &sync.Mutex{}, @@ -182,6 +204,8 @@ func NewStorage(ctx context.Context, conf *StorageConfig) (s *Storage, err error dhcp: conf.DHCP, etcHosts: conf.EtcHosts, arpDB: conf.ARPDB, + ndpData: ndpData, + ndpCache: make(map[netip.Addr]net.HardwareAddr), done: make(chan struct{}), allowedTags: tags, arpClientsUpdatePeriod: conf.ARPClientsUpdatePeriod, @@ -241,6 +265,91 @@ func (s *Storage) ReloadARP(ctx context.Context) { if s.arpDB != nil { s.addFromSystemARP(ctx) } + + s.refreshNDP() +} + +// defaultNDPData returns the output of "ip -6 neigh". +func defaultNDPData() ([]byte, error) { + return exec.Command("ip", "-6", "neigh").Output() +} + +// refreshNDP reads the IPv6 neighbor table and caches the IPv6 address to MAC +// address mappings. +func (s *Storage) refreshNDP() { + out, err := s.ndpData() + if err != nil { + return + } + + cache := parseNDPNeigh(out) + + s.ndpCacheMu.Lock() + s.ndpCache = cache + s.ndpCacheAt = time.Now() + s.ndpCacheMu.Unlock() +} + +// parseNDPNeigh parses the output of "ip -6 neigh" and returns a map of IPv6 +// addresses to MAC addresses. The expected input format: +// +// fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE +func parseNDPNeigh(data []byte) (cache map[netip.Addr]net.HardwareAddr) { + cache = make(map[netip.Addr]net.HardwareAddr) + sc := bufio.NewScanner(bytes.NewReader(data)) + + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 5 { + continue + } + + ip, parseErr := netip.ParseAddr(fields[0]) + if parseErr != nil { + continue + } + + for i, f := range fields { + if f == "lladdr" && i+1 < len(fields) { + mac, macErr := net.ParseMAC(fields[i+1]) + if macErr == nil { + cache[ip] = mac + } + + break + } + } + } + + return cache +} + +// macFromNDP returns the MAC address for the given IPv6 address from the NDP +// neighbor cache. If the cache is stale (>30s) and the address is not found, +// it refreshes the cache and retries once. +func (s *Storage) macFromNDP(ip netip.Addr) net.HardwareAddr { + if !ip.Is6() { + return nil + } + + s.ndpCacheMu.RLock() + mac := s.ndpCache[ip] + stale := time.Since(s.ndpCacheAt) > 30*time.Second + s.ndpCacheMu.RUnlock() + + if mac != nil { + return mac + } + + if stale { + s.refreshNDP() + + s.ndpCacheMu.RLock() + mac = s.ndpCache[ip] + s.ndpCacheMu.RUnlock() + } + + return mac } // addFromSystemARP adds the IP-hostname pairings from the output of the arp -a @@ -568,6 +677,12 @@ func (s *Storage) findByIP(addr netip.Addr) (p *Persistent, ok bool) { } foundMAC := s.dhcp.MACByIP(addr) + if foundMAC == nil { + // Fall back to the NDP neighbor table for IPv6 addresses that + // don't have a corresponding DHCPv4 lease. + foundMAC = s.macFromNDP(addr) + } + if foundMAC != nil { return s.index.findByMAC(foundMAC) } @@ -594,6 +709,10 @@ func (s *Storage) FindLoose(ip netip.Addr, id string) (p *Persistent, ok bool) { } foundMAC := s.dhcp.MACByIP(ip) + if foundMAC == nil { + foundMAC = s.macFromNDP(ip) + } + if foundMAC != nil { return s.index.findByMAC(foundMAC) } @@ -775,6 +894,10 @@ func (s *Storage) ApplyClientFiltering(id string, addr netip.Addr, setts *filter if !ok { foundMAC := s.dhcp.MACByIP(addr) + if foundMAC == nil { + foundMAC = s.macFromNDP(addr) + } + if foundMAC != nil { c, ok = s.index.findByMAC(foundMAC) } diff --git a/internal/client/storage_test.go b/internal/client/storage_test.go index b98a8264843..59619f58b20 100644 --- a/internal/client/storage_test.go +++ b/internal/client/storage_test.go @@ -1017,6 +1017,122 @@ func TestStorage_FindLoose(t *testing.T) { } } +func TestStorage_NDP(t *testing.T) { + const prsCliName = "ndp-client" + + var ( + // IPv6 address that is NOT in the DHCP lease table. + cliIPv6 = netip.MustParseAddr("2001:db8::1") + + // MAC that the NDP table maps the IPv6 address to. + cliMAC = errors.Must(net.ParseMAC("AA:BB:CC:DD:EE:FF")) + + // An IPv4 address that is not known via any mechanism. + unknownIPv4 = netip.MustParseAddr("10.0.0.99") + + // An IPv6 address that is not in the NDP table. + unknownIPv6 = netip.MustParseAddr("2001:db8::dead") + ) + + ndpOutput := []byte( + "2001:db8::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" + + "fe80::1 dev eth0 lladdr 11:22:33:44:55:66 STALE\n", + ) + + dhcp := &testDHCP{ + OnLeases: func() (ls []*dhcpsvc.Lease) { return nil }, + OnHostBy: func(_ netip.Addr) (host string) { return "" }, + OnMACBy: func(_ netip.Addr) (mac net.HardwareAddr) { return nil }, + } + + ctx := testutil.ContextWithTimeout(t, testTimeout) + storage, err := client.NewStorage(ctx, &client.StorageConfig{ + BaseLogger: testLogger, + Logger: testLogger, + DHCP: dhcp, + NDPData: func() ([]byte, error) { return ndpOutput, nil }, + ARPClientsUpdatePeriod: testTimeout / 10, + }) + require.NoError(t, err) + + // Add a persistent client identified by MAC address. + err = storage.Add(ctx, &client.Persistent{ + Name: prsCliName, + UID: client.MustNewUID(), + MACs: []net.HardwareAddr{cliMAC}, + }) + require.NoError(t, err) + + // Trigger NDP cache population. + storage.ReloadARP(ctx) + + t.Run("find_by_ipv6", func(t *testing.T) { + params := &client.FindParams{} + err = params.Set(cliIPv6.String()) + require.NoError(t, err) + + p, ok := storage.Find(params) + require.True(t, ok) + + assert.Equal(t, prsCliName, p.Name) + }) + + t.Run("find_loose_by_ipv6", func(t *testing.T) { + p, ok := storage.FindLoose(cliIPv6, "nonexistent-id") + require.True(t, ok) + + assert.Equal(t, prsCliName, p.Name) + }) + + t.Run("ipv4_not_in_ndp", func(t *testing.T) { + params := &client.FindParams{} + err = params.Set(unknownIPv4.String()) + require.NoError(t, err) + + _, ok := storage.Find(params) + assert.False(t, ok) + }) + + t.Run("unknown_ipv6_not_found", func(t *testing.T) { + params := &client.FindParams{} + err = params.Set(unknownIPv6.String()) + require.NoError(t, err) + + _, ok := storage.Find(params) + assert.False(t, ok) + }) + + t.Run("ndp_error_graceful", func(t *testing.T) { + ctx2 := testutil.ContextWithTimeout(t, testTimeout) + errStorage, err2 := client.NewStorage(ctx2, &client.StorageConfig{ + BaseLogger: testLogger, + Logger: testLogger, + DHCP: dhcp, + NDPData: func() ([]byte, error) { + return nil, errors.Error("no ip command") + }, + ARPClientsUpdatePeriod: testTimeout / 10, + }) + require.NoError(t, err2) + + err2 = errStorage.Add(ctx2, &client.Persistent{ + Name: prsCliName, + UID: client.MustNewUID(), + MACs: []net.HardwareAddr{cliMAC}, + }) + require.NoError(t, err2) + + errStorage.ReloadARP(ctx2) + + params := &client.FindParams{} + err2 = params.Set(cliIPv6.String()) + require.NoError(t, err2) + + _, ok := errStorage.Find(params) + assert.False(t, ok) + }) +} + func TestStorage_Update(t *testing.T) { const ( clientName = "client_name"