Skip to content

Commit 757928e

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 757928e

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

internal/client/storage.go

Lines changed: 94 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

@@ -150,6 +154,12 @@ type Storage struct {
150154
// arpDB is used to update [SourceARP] runtime client information.
151155
arpDB arpdb.Interface
152156

157+
// ndpCache maps IPv6 addresses to MAC addresses from the kernel NDP
158+
// neighbor table. Used to identify persistent clients querying over IPv6.
159+
ndpCache map[netip.Addr]net.HardwareAddr
160+
ndpCacheMu sync.RWMutex
161+
ndpCacheAt time.Time
162+
153163
// done is the shutdown signaling channel.
154164
done chan struct{}
155165

@@ -182,6 +192,7 @@ func NewStorage(ctx context.Context, conf *StorageConfig) (s *Storage, err error
182192
dhcp: conf.DHCP,
183193
etcHosts: conf.EtcHosts,
184194
arpDB: conf.ARPDB,
195+
ndpCache: make(map[netip.Addr]net.HardwareAddr),
185196
done: make(chan struct{}),
186197
allowedTags: tags,
187198
arpClientsUpdatePeriod: conf.ARPClientsUpdatePeriod,
@@ -241,6 +252,75 @@ func (s *Storage) ReloadARP(ctx context.Context) {
241252
if s.arpDB != nil {
242253
s.addFromSystemARP(ctx)
243254
}
255+
256+
s.refreshNDP()
257+
}
258+
259+
// refreshNDP reads the IPv6 neighbor table from the kernel and caches the
260+
// IPv6 address to MAC address mappings.
261+
func (s *Storage) refreshNDP() {
262+
out, err := exec.Command("ip", "-6", "neigh").Output()
263+
if err != nil {
264+
return
265+
}
266+
267+
cache := make(map[netip.Addr]net.HardwareAddr)
268+
sc := bufio.NewScanner(bytes.NewReader(out))
269+
for sc.Scan() {
270+
fields := strings.Fields(sc.Text())
271+
if len(fields) < 5 {
272+
continue
273+
}
274+
275+
ip, parseErr := netip.ParseAddr(fields[0])
276+
if parseErr != nil {
277+
continue
278+
}
279+
280+
for i, f := range fields {
281+
if f == "lladdr" && i+1 < len(fields) {
282+
mac, macErr := net.ParseMAC(fields[i+1])
283+
if macErr == nil {
284+
cache[ip] = mac
285+
}
286+
287+
break
288+
}
289+
}
290+
}
291+
292+
s.ndpCacheMu.Lock()
293+
s.ndpCache = cache
294+
s.ndpCacheAt = time.Now()
295+
s.ndpCacheMu.Unlock()
296+
}
297+
298+
// macFromNDP returns the MAC address for the given IPv6 address from the NDP
299+
// neighbor cache. If the cache is stale (>30s) and the address is not found,
300+
// it refreshes the cache and retries once.
301+
func (s *Storage) macFromNDP(ip netip.Addr) net.HardwareAddr {
302+
if !ip.Is6() {
303+
return nil
304+
}
305+
306+
s.ndpCacheMu.RLock()
307+
mac := s.ndpCache[ip]
308+
stale := time.Since(s.ndpCacheAt) > 30*time.Second
309+
s.ndpCacheMu.RUnlock()
310+
311+
if mac != nil {
312+
return mac
313+
}
314+
315+
if stale {
316+
s.refreshNDP()
317+
318+
s.ndpCacheMu.RLock()
319+
mac = s.ndpCache[ip]
320+
s.ndpCacheMu.RUnlock()
321+
}
322+
323+
return mac
244324
}
245325

246326
// addFromSystemARP adds the IP-hostname pairings from the output of the arp -a
@@ -568,6 +648,12 @@ func (s *Storage) findByIP(addr netip.Addr) (p *Persistent, ok bool) {
568648
}
569649

570650
foundMAC := s.dhcp.MACByIP(addr)
651+
if foundMAC == nil {
652+
// Fall back to the NDP neighbor table for IPv6 addresses that
653+
// don't have a corresponding DHCPv4 lease.
654+
foundMAC = s.macFromNDP(addr)
655+
}
656+
571657
if foundMAC != nil {
572658
return s.index.findByMAC(foundMAC)
573659
}
@@ -594,6 +680,10 @@ func (s *Storage) FindLoose(ip netip.Addr, id string) (p *Persistent, ok bool) {
594680
}
595681

596682
foundMAC := s.dhcp.MACByIP(ip)
683+
if foundMAC == nil {
684+
foundMAC = s.macFromNDP(ip)
685+
}
686+
597687
if foundMAC != nil {
598688
return s.index.findByMAC(foundMAC)
599689
}
@@ -775,6 +865,10 @@ func (s *Storage) ApplyClientFiltering(id string, addr netip.Addr, setts *filter
775865

776866
if !ok {
777867
foundMAC := s.dhcp.MACByIP(addr)
868+
if foundMAC == nil {
869+
foundMAC = s.macFromNDP(addr)
870+
}
871+
778872
if foundMAC != nil {
779873
c, ok = s.index.findByMAC(foundMAC)
780874
}

0 commit comments

Comments
 (0)