An IPv6 host on an Ethernet link needs a link-layer destination before it can send a frame to a neighbor. Neighbor Discovery performs that address-resolution work using ICMPv6 Neighbor Solicitation and Neighbor Advertisement messages. It also covers functions beyond IPv4 ARP, including router discovery and neighbor reachability detection. Calling it merely “ARP with longer addresses” hides dependencies that matter when configuring an IPv6 network.

When it breaks

A valid IPv6 address and route do not prove that the next hop’s Ethernet address can be resolved. If the required solicitation or advertisement is filtered, a TCP service can be listening correctly while clients fail before exchanging application data. Opening only the application’s TCP port does not repair this lower-layer dependency. Inspect the neighbor table and relevant ICMPv6 traffic alongside the service listener.

Cached state can conceal the error. A host with a usable neighbor entry may continue communicating after a damaging rule is installed, while a fresh client or an expired entry exposes the failure. Conversely, a STALE entry is not automatically broken: Neighbor Discovery can use existing link-layer information and subsequently check reachability. Interpret the state machine rather than requiring every healthy entry to remain REACHABLE forever.

Another common confusion is resolving the remote application’s address on the local Ethernet segment. For an off-link destination, the host normally resolves its selected next-hop router. Confirm routing before deciding which neighbor should appear in the table. A capture on the wrong interface, or a cached entry requiring no new solicitation, can also explain an empty trace without proving that discovery is absent.

Check it quickly

Save this as check-neighbor-discovery.py and run python3 check-neighbor-discovery.py with Docker. The disposable Linux environment uses Python 3.11.16, iproute2 6.1.0, nftables 1.0.6, and tcpdump 4.99.3. NET_ADMIN and SYS_ADMIN support its nested namespaces and temporary proc mount. Two endpoints share a newly created veth link; no host neighbor table or firewall rule is changed:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time

def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,'child exited without a response'
    return value
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
        stack.callback(run,'umount',str(proc))
        for name in names:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
            ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found

def server(stack,name,address,port=18080,label='server'):
    code=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json,socket,sys
address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3]
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode()
        self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
    def log_message(self,*args):pass
class Server(ThreadingHTTPServer):
    address_family=socket.AF_INET6 if ':' in address else socket.AF_INET
    def server_bind(self):
        if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
        super().server_bind()
service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever()
'''
    process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label])
    assert line(process).strip()=='ready';return process
def request(name,address,port=18080):
    code=r'''
import http.client,json,sys
client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7)
try:
    client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read())
    assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body}))
except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}))
finally:client.close()
'''
    return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout)

def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],
        stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process)
    deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'tcpdump exited before listening'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('tcpdump did not become ready')
def captured(process,path,ascii=False):
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3)
    assert process.returncode==0,diagnostic
    result=run('tcpdump','-nn','-vv',*(['-A'] if ascii else []),'-r',str(path))
    return result.stdout

with lab(['client','target']) as (root,stack,setting):
    wire('client','c0','target','t0',['192.0.2.1/30','2001:db8:1::1/64'],['192.0.2.2/30','2001:db8:1::2/64'])
    server(stack,'target','2001:db8:1::2')
    ip('client','-4','neigh','flush','dev','c0');ip('target','-4','neigh','flush','dev','t0')
    arp_process,arp_file=capture(stack,root,'client','c0','arp','arp')
    ns('client','ping','-4','-n','-c','1','-W','1','192.0.2.2')
    arp=captured(arp_process,arp_file);assert 'ARP,' in arp and 'Request who-has' in arp and 'Reply' in arp,arp
    ip('client','-6','neigh','flush','dev','c0');ip('target','-6','neigh','flush','dev','t0')
    nd_process,nd_file=capture(stack,root,'client','c0','icmp6 or arp','nd')
    ns('client','ping','-6','-n','-c','1','-W','1','2001:db8:1::2')
    nd=captured(nd_process,nd_file)
    assert 'neighbor solicitation' in nd and 'neighbor advertisement' in nd and 'ARP,' not in nd,nd
    mac=json.loads(ip('target','-j','link','show','t0').stdout)[0]['address']
    neighbors=json.loads(ip('client','-6','-j','neigh','show','2001:db8:1::2').stdout)
    assert len(neighbors)==1 and neighbors[0]['lladdr']==mac
    healthy=request('client','2001:db8:1::2');assert healthy['ok']
    setting('client','net.ipv6.neigh.c0.retrans_time_ms',200);setting('client','net.ipv6.neigh.c0.mcast_solicit',3)
    nft('client','''table inet block_nd {
 chain input { type filter hook input priority 0; policy accept;
  icmpv6 type nd-neighbor-advert counter drop comment "blocked_advertisements"
 }
}
''')
    ip('client','-6','neigh','flush','dev','c0')
    failed=ns('client','ping','-6','-n','-c','1','-W','1','2001:db8:1::2',check=False);assert failed.returncode!=0
    broken=request('client','2001:db8:1::2');assert not broken['ok']
    blocked=counters('client');assert blocked['blocked_advertisements']>0
    failed_neighbors=json.loads(ip('client','-6','-j','neigh','show','2001:db8:1::2').stdout)
    assert failed_neighbors and all('lladdr' not in entry for entry in failed_neighbors)
    nft('client','delete table inet block_nd');ip('client','-6','neigh','flush','dev','c0')
    recovered=request('client','2001:db8:1::2');assert recovered['ok']
    print(json.dumps({'ipv4_arp_capture':arp,'ipv6_capture':nd,'resolved_neighbor':neighbors,'target_mac':mac,
        'healthy_http':healthy,'blocked_ping_exit':failed.returncode,'blocked_http':broken,'blocked_counters':blocked,
        'unresolved_neighbor':failed_neighbors,'recovered_http':recovered}))
"""
name='xbcatm-netpath-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
            '--memory','256m','--pids-limit','128',name,'python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

The script assigns IPv4 and IPv6 documentation addresses to both endpoints. It first captures a real IPv4 ARP request and reply as a positive control for the capture setup. It then clears the client’s IPv6 neighbor cache and sends an IPv6 echo request. The trace contains Neighbor Solicitation, Neighbor Advertisement, and echo traffic, with no ARP frames in this IPv6 exchange.

The solicitation for 2001:db8:1::2 is sent to solicited-node multicast address ff02::1:ff00:2. The reply provides the target’s link-layer address, which the script compares with the target interface’s actual MAC address and the client’s neighbor entry. An HTTP request then returns the expected server identity. This joins the control exchange, learned information, and application outcome instead of relying on configuration text alone.

Next, the client drops incoming neighbor advertisements. Its cache is cleared so an older mapping cannot hide the change. Discovery retransmission settings are shortened only inside the client namespace to keep the test bounded. Ping fails; an HTTP attempt reports Linux EHOSTUNREACH, errno 113; and the neighbor entry becomes FAILED without a resolved link-layer address. The filter counter confirms that advertisements reached the drop rule.

Removing that rule and clearing the failed entry restores HTTP success. The target service and address stay unchanged throughout the comparison. This isolates address-resolution failure from a stopped application or a missing TCP allow rule. The exact timing and packet count depend on the configured discovery retries, so the example checks the failure state and recovery rather than prescribing one universal timeout.

Keep discovery, addressing, and authentication separate

The fixture uses static IPv6 addresses with duplicate-address detection disabled for setup. It demonstrates neighbor resolution, not SLAAC, Router Advertisement processing, or Duplicate Address Detection. Those functions require their own observations and policy review. Passing this small test cannot certify every ICMPv6 dependency in a production network; RFC 4890 discusses filtering requirements in broader operational contexts.

An advertisement also should not be treated as authenticated proof of ownership merely because it supplies a MAC address. The protocol’s local-link checks limit where messages can originate but do not make every node on that link trustworthy. When debugging, record interface, selected next hop, neighbor state, filters, and the corresponding application result. That evidence distinguishes an unresolved neighbor from an unrelated service failure.

Sources