Skip to content

Commit e5f42d6

Browse files
AndyHazzclaude
andcommitted
client: identify persistent clients via IPv6 NDP neighbor table
When a DNS query arrives from an IPv6 address, AdGuard Home currently cannot identify the client by MAC address because the DHCPv4 lease table has no entry for IPv6 addresses. This means per-client filtering rules (e.g. blocking YouTube for specific devices) don't work when clients query over IPv6. This patch adds an NDP (Neighbor Discovery Protocol) neighbor table lookup as a fallback. When the DHCP lease lookup returns no MAC for a source IP, the IPv6 neighbor table is consulted. The kernel's NDP cache maps IPv6 addresses to MAC addresses for all devices on the local network, so persistent clients configured with MAC identifiers are correctly matched regardless of whether they query via IPv4 or IPv6. The NDP cache is refreshed alongside the existing ARP refresh cycle, with an additional on-demand refresh (at most every 30 seconds) when an unknown IPv6 address is encountered. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 42a2653 commit e5f42d6

1 file changed

Lines changed: 90 additions & 0 deletions

File tree

internal/client/storage.go

Lines changed: 90 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
}

0 commit comments

Comments
 (0)