以太网链路上的 IPv6 主机,需要先取得邻居的链路层目的地址,才能发送相应帧。邻居发现通过 ICMPv6 的邻居请求和邻居通告完成地址解析,同时还涵盖路由器发现、邻居可达性检测等超出 IPv4 ARP 的功能。把它简单理解为“地址更长的 ARP”,会掩盖配置 IPv6 网络时必须考虑的依赖关系,尤其是控制报文与应用通信之间的联系。
坏掉的样子
拥有有效 IPv6 地址和路由,不代表能够解析下一跳的以太网地址。如果必要的请求或通告被过滤,TCP 服务可以一直正常监听,客户端却在交换应用数据前就失败。只开放应用 TCP 端口,无法修复这个较低层依赖。排查时应把邻居表、相关 ICMPv6 流量和服务监听一起观察,不能只从端口是否开放判断整条路径。
缓存状态还可能隐藏错误。已有可用邻居条目的主机,在破坏性规则安装后可能继续通信;新客户端或条目失效之后,故障才暴露。反过来,STALE 状态也不自动意味着损坏:邻居发现可以利用已有链路地址,随后再检查可达性。应理解状态转换,不能要求每个正常条目永远显示 REACHABLE,否则会把正常缓存生命周期误判为故障。
另一类混淆,是在本地以太网段上解析远端应用服务器地址。目的地位于链路之外时,主机通常解析选中的下一跳路由器,而不是远端服务器。判断邻居表应出现哪个地址之前,需要先确认选路。捕获接口错误,或者已有缓存使本次无需重新发送请求,也能解释空白抓包结果,不能据此证明邻居发现根本没有发生。
快速验证
将脚本保存为 check-neighbor-discovery.py,准备 Docker 后运行 python3 check-neighbor-discovery.py。此前 Linux 实验使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3;本轮没有重跑网络实验,以下结果保留为历史观察。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间和临时 proc 挂载。两个端点通过新建 veth 相连,不修改宿主邻居表或防火墙规则:
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)
脚本为两端配置 IPv4 与 IPv6 文档保留地址。首先捕获一次真实 IPv4 ARP 请求和回复,作为抓包配置有效的正向对照;然后清理客户端 IPv6 邻居缓存并发送 IPv6 echo 请求。第二段捕获包含邻居请求、邻居通告以及 echo 往返,没有 ARP 帧。这样的比较比“没有抓到 ARP,所以 IPv6 一定正常”更完整。
针对 2001:db8:1::2 的请求发往被请求节点组播地址 ff02::1:ff00:2。回复提供目标链路层地址,脚本将其与目标接口真实 MAC 以及客户端邻居条目比较。随后 HTTP 请求返回预期服务器身份。这把控制消息、学到的信息与应用结果连接起来,而不只是展示一组配置命令。MAC 由本次虚拟接口产生,不要求所有运行得到相同数值。
接着,客户端丢弃收到的邻居通告。它的缓存被清理,避免旧映射隐藏变更。为让测试在有限时间内结束,发现重传设置仅在客户端网络命名空间内缩短。此时 ping 失败,HTTP 得到 Linux EHOSTUNREACH,错误号一百一十三;邻居条目进入 FAILED,且没有解析出的链路地址。过滤计数器增长,证明通告确实到达丢弃规则。
删除该规则并清理失败条目后,HTTP 恢复成功。整个对照中,目标服务和地址保持不变,因此可以将结果归因于地址解析,而不是应用停止或缺少 TCP 放行规则。具体等待时间与报文数量受发现重试参数影响,所以示例检查失败状态和恢复结果,不把某一次执行时间规定成所有系统通用的超时。调整参数与清缓存都限定在实验环境内。
区分邻居发现、地址配置和身份验证
脚本使用静态 IPv6 地址,并在设置时关闭重复地址检测。它验证的是邻居解析,没有验证无状态地址自动配置、路由器通告处理或重复地址检测。这些功能需要各自的观测和策略审阅。通过这个小实验,不能证明生产网络的所有 ICMPv6 依赖都已经满足;RFC 4890 提供了更广泛运行环境中的过滤要求,配置策略时仍须结合实际角色。
通告携带一个 MAC 地址,也不应被当作经过认证的地址归属证明。协议中的本地链路检查限制了消息来源范围,却不会使同一链路上的每个节点都可信。诊断时记录接口、选中的下一跳、邻居状态、过滤规则及对应应用结果,才能把未解析邻居与无关服务故障区分开。解决控制层依赖之后,仍应通过实际应用请求确认通信已经恢复。
来源
- RFC 4861 Neighbor Discovery,请求、通告、地址解析与可达性状态,已核验 2026-10-15。
- Linux ip-neighbour(8),查看与清理 IPv6 邻居条目,已核验 2026-10-15。
- RFC 4890 ICMPv6 filtering,保留 IPv6 网络依赖的 ICMPv6 功能,已核验 2026-10-15。