Skip to content

Commit 26f2f8c

Browse files
AndyHazzclaude
andcommitted
client: use NDP neighbor table for IPv6 client identification
Currently, persistent clients configured with a MAC address can only be identified when they query over IPv4 (via DHCP lease lookup). When the same device queries over IPv6, AGH cannot resolve the source IPv6 address back to a MAC address, so the client appears unidentified and per-client filtering rules don't apply. Add an NDP (Neighbor Discovery Protocol) cache to Storage that periodically reads the kernel's IPv6 neighbor table (`ip -6 neigh`) and maps IPv6 addresses to MAC addresses. The cache is refreshed alongside ARP updates and on-demand when an address is not found and the cache is stale (>30s). The NDP lookup is used as a fallback in findByIP, FindLoose, and ApplyClientFiltering when the DHCP lease lookup returns no result. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a165cdb commit 26f2f8c

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package client
2+
3+
import (
4+
"net"
5+
"net/netip"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestParseNDPNeigh(t *testing.T) {
13+
t.Parallel()
14+
15+
testCases := []struct {
16+
name string
17+
data string
18+
want map[netip.Addr]net.HardwareAddr
19+
}{{
20+
name: "typical",
21+
data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" +
22+
"2001:db8::1 dev eth0 lladdr 11:22:33:44:55:66 STALE\n",
23+
want: map[netip.Addr]net.HardwareAddr{
24+
netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
25+
netip.MustParseAddr("2001:db8::1"): {0x11, 0x22, 0x33, 0x44, 0x55, 0x66},
26+
},
27+
}, {
28+
name: "no_lladdr",
29+
data: "fe80::1 dev eth0 FAILED\n",
30+
want: map[netip.Addr]net.HardwareAddr{},
31+
}, {
32+
name: "short_line",
33+
data: "fe80::1 dev eth0\n",
34+
want: map[netip.Addr]net.HardwareAddr{},
35+
}, {
36+
name: "bad_ip",
37+
data: "not-an-ip dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n",
38+
want: map[netip.Addr]net.HardwareAddr{},
39+
}, {
40+
name: "bad_mac",
41+
data: "fe80::1 dev eth0 lladdr not-a-mac REACHABLE\n",
42+
want: map[netip.Addr]net.HardwareAddr{},
43+
}, {
44+
name: "router_flag",
45+
data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff router REACHABLE\n",
46+
want: map[netip.Addr]net.HardwareAddr{
47+
netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
48+
},
49+
}, {
50+
name: "empty",
51+
data: "",
52+
want: map[netip.Addr]net.HardwareAddr{},
53+
}, {
54+
name: "mixed_valid_invalid",
55+
data: "fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" +
56+
"bad line\n" +
57+
"fe80::2 dev eth0 FAILED\n" +
58+
"fe80::3 dev eth0 lladdr 11:22:33:44:55:66 DELAY\n",
59+
want: map[netip.Addr]net.HardwareAddr{
60+
netip.MustParseAddr("fe80::1"): {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
61+
netip.MustParseAddr("fe80::3"): {0x11, 0x22, 0x33, 0x44, 0x55, 0x66},
62+
},
63+
}}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
t.Parallel()
68+
69+
got := parseNDPNeigh([]byte(tc.data))
70+
require.Len(t, got, len(tc.want))
71+
72+
for addr, wantMAC := range tc.want {
73+
gotMAC, ok := got[addr]
74+
require.True(t, ok, "missing address %s", addr)
75+
76+
assert.Equal(t, wantMAC, gotMAC)
77+
}
78+
})
79+
}
80+
}

internal/client/storage.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package client
22

33
import (
4+
"bufio"
5+
"bytes"
46
"context"
57
"fmt"
68
"log/slog"
79
"net"
810
"net/netip"
11+
"os/exec"
912
"slices"
13+
"strings"
1014
"sync"
1115
"time"
1216

@@ -114,6 +118,10 @@ type StorageConfig struct {
114118
// configuration file. Each client must not be nil.
115119
InitialClients []*Persistent
116120

121+
// NDPData returns the raw output of the IPv6 neighbor table. If nil,
122+
// defaults to running "ip -6 neigh".
123+
NDPData func() ([]byte, error)
124+
117125
// ARPClientsUpdatePeriod defines how often [SourceARP] runtime client
118126
// information is updated.
119127
ARPClientsUpdatePeriod time.Duration
@@ -150,6 +158,15 @@ type Storage struct {
150158
// arpDB is used to update [SourceARP] runtime client information.
151159
arpDB arpdb.Interface
152160

161+
// ndpData returns the raw output of the IPv6 neighbor table.
162+
ndpData func() ([]byte, error)
163+
164+
// ndpCache maps IPv6 addresses to MAC addresses from the kernel NDP
165+
// neighbor table. Used to identify persistent clients querying over IPv6.
166+
ndpCache map[netip.Addr]net.HardwareAddr
167+
ndpCacheMu sync.RWMutex
168+
ndpCacheAt time.Time
169+
153170
// done is the shutdown signaling channel.
154171
done chan struct{}
155172

@@ -173,6 +190,11 @@ func NewStorage(ctx context.Context, conf *StorageConfig) (s *Storage, err error
173190
tags := slices.Clone(allowedTags)
174191
slices.Sort(tags)
175192

193+
ndpData := conf.NDPData
194+
if ndpData == nil {
195+
ndpData = defaultNDPData
196+
}
197+
176198
s = &Storage{
177199
logger: conf.Logger,
178200
mu: &sync.Mutex{},
@@ -182,6 +204,8 @@ func NewStorage(ctx context.Context, conf *StorageConfig) (s *Storage, err error
182204
dhcp: conf.DHCP,
183205
etcHosts: conf.EtcHosts,
184206
arpDB: conf.ARPDB,
207+
ndpData: ndpData,
208+
ndpCache: make(map[netip.Addr]net.HardwareAddr),
185209
done: make(chan struct{}),
186210
allowedTags: tags,
187211
arpClientsUpdatePeriod: conf.ARPClientsUpdatePeriod,
@@ -241,6 +265,91 @@ func (s *Storage) ReloadARP(ctx context.Context) {
241265
if s.arpDB != nil {
242266
s.addFromSystemARP(ctx)
243267
}
268+
269+
s.refreshNDP()
270+
}
271+
272+
// defaultNDPData returns the output of "ip -6 neigh".
273+
func defaultNDPData() ([]byte, error) {
274+
return exec.Command("ip", "-6", "neigh").Output()
275+
}
276+
277+
// refreshNDP reads the IPv6 neighbor table and caches the IPv6 address to MAC
278+
// address mappings.
279+
func (s *Storage) refreshNDP() {
280+
out, err := s.ndpData()
281+
if err != nil {
282+
return
283+
}
284+
285+
cache := parseNDPNeigh(out)
286+
287+
s.ndpCacheMu.Lock()
288+
s.ndpCache = cache
289+
s.ndpCacheAt = time.Now()
290+
s.ndpCacheMu.Unlock()
291+
}
292+
293+
// parseNDPNeigh parses the output of "ip -6 neigh" and returns a map of IPv6
294+
// addresses to MAC addresses. The expected input format:
295+
//
296+
// fe80::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
297+
func parseNDPNeigh(data []byte) (cache map[netip.Addr]net.HardwareAddr) {
298+
cache = make(map[netip.Addr]net.HardwareAddr)
299+
sc := bufio.NewScanner(bytes.NewReader(data))
300+
301+
for sc.Scan() {
302+
fields := strings.Fields(sc.Text())
303+
if len(fields) < 5 {
304+
continue
305+
}
306+
307+
ip, parseErr := netip.ParseAddr(fields[0])
308+
if parseErr != nil {
309+
continue
310+
}
311+
312+
for i, f := range fields {
313+
if f == "lladdr" && i+1 < len(fields) {
314+
mac, macErr := net.ParseMAC(fields[i+1])
315+
if macErr == nil {
316+
cache[ip] = mac
317+
}
318+
319+
break
320+
}
321+
}
322+
}
323+
324+
return cache
325+
}
326+
327+
// macFromNDP returns the MAC address for the given IPv6 address from the NDP
328+
// neighbor cache. If the cache is stale (>30s) and the address is not found,
329+
// it refreshes the cache and retries once.
330+
func (s *Storage) macFromNDP(ip netip.Addr) net.HardwareAddr {
331+
if !ip.Is6() {
332+
return nil
333+
}
334+
335+
s.ndpCacheMu.RLock()
336+
mac := s.ndpCache[ip]
337+
stale := time.Since(s.ndpCacheAt) > 30*time.Second
338+
s.ndpCacheMu.RUnlock()
339+
340+
if mac != nil {
341+
return mac
342+
}
343+
344+
if stale {
345+
s.refreshNDP()
346+
347+
s.ndpCacheMu.RLock()
348+
mac = s.ndpCache[ip]
349+
s.ndpCacheMu.RUnlock()
350+
}
351+
352+
return mac
244353
}
245354

246355
// 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) {
568677
}
569678

570679
foundMAC := s.dhcp.MACByIP(addr)
680+
if foundMAC == nil {
681+
// Fall back to the NDP neighbor table for IPv6 addresses that
682+
// don't have a corresponding DHCPv4 lease.
683+
foundMAC = s.macFromNDP(addr)
684+
}
685+
571686
if foundMAC != nil {
572687
return s.index.findByMAC(foundMAC)
573688
}
@@ -594,6 +709,10 @@ func (s *Storage) FindLoose(ip netip.Addr, id string) (p *Persistent, ok bool) {
594709
}
595710

596711
foundMAC := s.dhcp.MACByIP(ip)
712+
if foundMAC == nil {
713+
foundMAC = s.macFromNDP(ip)
714+
}
715+
597716
if foundMAC != nil {
598717
return s.index.findByMAC(foundMAC)
599718
}
@@ -775,6 +894,10 @@ func (s *Storage) ApplyClientFiltering(id string, addr netip.Addr, setts *filter
775894

776895
if !ok {
777896
foundMAC := s.dhcp.MACByIP(addr)
897+
if foundMAC == nil {
898+
foundMAC = s.macFromNDP(addr)
899+
}
900+
778901
if foundMAC != nil {
779902
c, ok = s.index.findByMAC(foundMAC)
780903
}

internal/client/storage_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,122 @@ func TestStorage_FindLoose(t *testing.T) {
10171017
}
10181018
}
10191019

1020+
func TestStorage_NDP(t *testing.T) {
1021+
const prsCliName = "ndp-client"
1022+
1023+
var (
1024+
// IPv6 address that is NOT in the DHCP lease table.
1025+
cliIPv6 = netip.MustParseAddr("2001:db8::1")
1026+
1027+
// MAC that the NDP table maps the IPv6 address to.
1028+
cliMAC = errors.Must(net.ParseMAC("AA:BB:CC:DD:EE:FF"))
1029+
1030+
// An IPv4 address that is not known via any mechanism.
1031+
unknownIPv4 = netip.MustParseAddr("10.0.0.99")
1032+
1033+
// An IPv6 address that is not in the NDP table.
1034+
unknownIPv6 = netip.MustParseAddr("2001:db8::dead")
1035+
)
1036+
1037+
ndpOutput := []byte(
1038+
"2001:db8::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" +
1039+
"fe80::1 dev eth0 lladdr 11:22:33:44:55:66 STALE\n",
1040+
)
1041+
1042+
dhcp := &testDHCP{
1043+
OnLeases: func() (ls []*dhcpsvc.Lease) { return nil },
1044+
OnHostBy: func(_ netip.Addr) (host string) { return "" },
1045+
OnMACBy: func(_ netip.Addr) (mac net.HardwareAddr) { return nil },
1046+
}
1047+
1048+
ctx := testutil.ContextWithTimeout(t, testTimeout)
1049+
storage, err := client.NewStorage(ctx, &client.StorageConfig{
1050+
BaseLogger: testLogger,
1051+
Logger: testLogger,
1052+
DHCP: dhcp,
1053+
NDPData: func() ([]byte, error) { return ndpOutput, nil },
1054+
ARPClientsUpdatePeriod: testTimeout / 10,
1055+
})
1056+
require.NoError(t, err)
1057+
1058+
// Add a persistent client identified by MAC address.
1059+
err = storage.Add(ctx, &client.Persistent{
1060+
Name: prsCliName,
1061+
UID: client.MustNewUID(),
1062+
MACs: []net.HardwareAddr{cliMAC},
1063+
})
1064+
require.NoError(t, err)
1065+
1066+
// Trigger NDP cache population.
1067+
storage.ReloadARP(ctx)
1068+
1069+
t.Run("find_by_ipv6", func(t *testing.T) {
1070+
params := &client.FindParams{}
1071+
err = params.Set(cliIPv6.String())
1072+
require.NoError(t, err)
1073+
1074+
p, ok := storage.Find(params)
1075+
require.True(t, ok)
1076+
1077+
assert.Equal(t, prsCliName, p.Name)
1078+
})
1079+
1080+
t.Run("find_loose_by_ipv6", func(t *testing.T) {
1081+
p, ok := storage.FindLoose(cliIPv6, "nonexistent-id")
1082+
require.True(t, ok)
1083+
1084+
assert.Equal(t, prsCliName, p.Name)
1085+
})
1086+
1087+
t.Run("ipv4_not_in_ndp", func(t *testing.T) {
1088+
params := &client.FindParams{}
1089+
err = params.Set(unknownIPv4.String())
1090+
require.NoError(t, err)
1091+
1092+
_, ok := storage.Find(params)
1093+
assert.False(t, ok)
1094+
})
1095+
1096+
t.Run("unknown_ipv6_not_found", func(t *testing.T) {
1097+
params := &client.FindParams{}
1098+
err = params.Set(unknownIPv6.String())
1099+
require.NoError(t, err)
1100+
1101+
_, ok := storage.Find(params)
1102+
assert.False(t, ok)
1103+
})
1104+
1105+
t.Run("ndp_error_graceful", func(t *testing.T) {
1106+
ctx2 := testutil.ContextWithTimeout(t, testTimeout)
1107+
errStorage, err2 := client.NewStorage(ctx2, &client.StorageConfig{
1108+
BaseLogger: testLogger,
1109+
Logger: testLogger,
1110+
DHCP: dhcp,
1111+
NDPData: func() ([]byte, error) {
1112+
return nil, errors.Error("no ip command")
1113+
},
1114+
ARPClientsUpdatePeriod: testTimeout / 10,
1115+
})
1116+
require.NoError(t, err2)
1117+
1118+
err2 = errStorage.Add(ctx2, &client.Persistent{
1119+
Name: prsCliName,
1120+
UID: client.MustNewUID(),
1121+
MACs: []net.HardwareAddr{cliMAC},
1122+
})
1123+
require.NoError(t, err2)
1124+
1125+
errStorage.ReloadARP(ctx2)
1126+
1127+
params := &client.FindParams{}
1128+
err2 = params.Set(cliIPv6.String())
1129+
require.NoError(t, err2)
1130+
1131+
_, ok := errStorage.Find(params)
1132+
assert.False(t, ok)
1133+
})
1134+
}
1135+
10201136
func TestStorage_Update(t *testing.T) {
10211137
const (
10221138
clientName = "client_name"

0 commit comments

Comments
 (0)