以太网链路上的 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 地址,也不应被当作经过认证的地址归属证明。协议中的本地链路检查限制了消息来源范围,却不会使同一链路上的每个节点都可信。诊断时记录接口、选中的下一跳、邻居状态、过滤规则及对应应用结果,才能把未解析邻居与无关服务故障区分开。解决控制层依赖之后,仍应通过实际应用请求确认通信已经恢复。

来源