Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions internal/client/ndp_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
123 changes: 123 additions & 0 deletions internal/client/storage.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package client

import (
"bufio"
"bytes"
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"os/exec"
"slices"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}

Expand All @@ -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{},
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
116 changes: 116 additions & 0 deletions internal/client/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down