有IPv6地址,为什么外面还是连不进来?让同一台服务分别卡在四个位置
电脑已经有IPv6地址,地址也填对了。从另一张网络连接自己的服务,却一直超时。
容易想到的一句话是:IPv6不用像以前那样做端口映射,那不是应该直接能访问吗?
先把"直接"放一边。一个连接要到达程序,得先有人把包送来,沿途允许它经过,程序还得接收这种地址的连接,最后把回答送回去。地址只给出了目标,并没有替这些动作全部盖章。
我们保留同一台服务、同一个IPv6地址,一次只改一个条件,看看连接会在哪一步停下。
先走通一条小路,再把它拆开
这里不直接修改家庭路由器。Linux里新建三个网络命名空间:外侧客户端E、路由器R、服务端S,各自有独立的接口、路由和防火墙。
text
客户端E
2001:db8:2::2
|
路由器R的外侧:2001:db8:2::1
路由器R的内侧:2001:db8:1::1
|
服务端S
2001:db8:1::2,端口18080
服务端运行一个很小的TCP程序:收到hello,返回IPV6_OK。客户端必须实际建立IPv6连接、发出数据,再收到这个标记,才算本轮成功。
两边路由先配完整:E去服务端子网,经R的外侧地址;S回客户端,经R的内侧地址。R开启IPv6转发。
基准结果确实是IPV6_OK。新建接口最初还有邻居发现等控制流量,第一次探测不一定立即成功,所以程序先等待基准连接真正走通,再开始改条件;不是把"等了几秒"当成已经可达。
这里的2001:db8::/32是文档示例前缀,只能用于这类隔离演示,不能抄到家里当公网地址。这条小路也没有接入互联网,后面所说的"外侧",只是路由器另一侧的实验节点。
地址还在,程序却只接IPv4
先只替换服务端程序的监听方式:从IPv6 socket改成IPv4 socket,绑定0.0.0.0:18080。服务端接口上的IPv6地址没有删除,路由也没有改。
此时检查监听:
bash
ss -lnt4
ss -lnt6
IPv4列表有18080,IPv6列表没有。E再次连接同一个IPv6地址,实际得到ConnectionRefusedError。
服务"启动了"与"接收IPv6连接",到这里就分开了。0.0.0.0是IPv4通配监听地址,不会因为网卡另外有一个IPv6地址,就自动变成IPv6监听。
把服务改回IPv6 socket,绑定[::]:18080,连接又成功。实验还显式设置IPV6_V6ONLY=1,只讨论IPv6,不借助IPv4映射地址的兼容行为。Linux IPv6接口说明描述了这些socket和地址的区别。
读自己的服务配置时,也要分清::1和:::前者是IPv6回环地址,服务若只绑定它,来自其他机器的连接不能按网卡地址进入。同样,看到监听行还不够,得核对绑定地址、地址族和端口。
服务在听,路由器却没有替它转发
恢复IPv6监听后,单独关闭R的IPv6转发。E有去服务端的路由,S也仍在监听,但E的连接失败了。本次程序的1.2秒等待预算内表现为超时。
text
E:我要去2001:db8:1::2
按路由交给R
R:收到包
这不是发给我自己的地址
但当前不做IPv6转发
S:仍在监听,但连接没完成
再只恢复R的转发,收到IPV6_OK。
Linux的net.ipv6.conf.all.forwarding控制IPv6接口间转发,相关行为见内核文档。本实验只在R自己的命名空间内修改,不建议把这条设置直接粘到正在使用的家庭路由器上。真实路由设备还会涉及前缀、路由通告和固件配置。
这组对照想说明的是:终端有地址、应用在监听,并不能替路由器完成转发。连得上路由器自身,也未必能穿过它连接后面的服务。
不做NAT,防火墙也可以拦
把转发恢复,再在R的forward链添加一条很窄的规则:只丢弃发往服务端IPv6地址、TCP目标端口18080的包。规则放在自建的inet lab表里,没有"清空现有防火墙"。
这次客户端仍超时。与此同时,规则计数器确实增加,说明有包碰到了这条丢弃规则。删除自己的实验表之后,同一连接再次成功。
text
R知道怎么去S,也允许转发
|
v
防火墙:目标是S的18080?
是 → 计数增加,丢弃
NAT处理地址转换,防火墙决定流量是否允许通过。实验里完全没有NAT,仍然能拦住连接,所以"没有端口映射这一步"不能推出"任何入站请求都放行"。
还有一个位置容易选错:本次访问的是R后面的S,包走R的forward链;若访问的是R自己运行的服务,才应看相应的input路径。只在路由器上放开一个"本机入站端口",不一定放行了去内网机器的转发流量。服务端自己的防火墙也要另查。
这里只通过丢弃规则计数和删除后的成功作对照,没有保存全路径抓包,因此不把每个超时都写成"抓到了某个SYN"。1.2秒是验证器的等待上限,也不是IPv6规定的连接超时。
去程能走,不等于回答能回来
最后恢复防火墙,只删除S的默认路由。S自己的直连子网路由还在,地址和监听也都保留。
E在另一个子网。S要给E回答,需要跨子网的回程路由;删掉这条默认路由后,本次连接又超时。补回原来的默认路由,立即重新测试成功。
text
去程:E → R → S
S准备回答E
E不在S的直连子网
但默认路由被删除了
回答找不到应走的下一跳
所以在服务器上,只检查"别人到我的路由"还不够,还得检查"我到那个客户端的路由"。Linux可以用:
bash
ip -6 route get <客户端的IPv6地址>
这里要填实际客户端地址,不要只查同子网的路由器地址。多网卡、多个出口时,选出的源地址和接口也值得一起看。单条route get是选路证据,是否完整收发仍需用实际连接核对。
回到家里的IPv6,先查哪一个?
这四个故障里,服务端的IPv6地址一直保留着。但实际结果分别是:IPv4独占监听时拒绝,关闭转发、规则丢弃和缺少回程时在本次预算内超时;每项单独恢复后,连接都重新成功。
因此,"超时"本身不足以选出唯一原因。它只是告诉我们,在给定时间内没有完成这个操作。想往下判断,需要监听、路由、规则计数或抓包等别的证据。
现场可以先按这个顺序缩小问题:
- 服务端到底是什么地址?
fe80::链路本地地址不用于跨路由访问;ULA也不等于公网可路由地址。即便显示scope global,也不能仅凭这两个字证明互联网知道怎么到你这里。 - 先在同一局域网用该IPv6地址和端口连接服务,核对真实监听;不要只测试localhost。地址与端口写进URL时,IPv6字面地址需要方括号,例如
http://[你的地址]:端口/。 - 再换到一个确实有IPv6连通性的外部网络,用同一地址、同一端口测试。普通IPv4网络不能凭一个IPv6字面地址直接建立IPv6连接。
- 内部成功、外部失败,再沿路由器的转发与防火墙、服务端回程、上游路由继续查。若还使用域名,另核对AAAA记录,先别把DNS结果与直连IP的结果混在一起。
地址类型的边界可查IPv6地址体系。真实家庭网络还可能涉及运营商是否分配和路由了前缀、路由器是否向LAN通告前缀等;本实验手工建立静态地址与路由,没有验证DHCPv6-PD、SLAAC或运营商公网入站策略。
这篇能帮助你把"有地址却不通"分成几个可以单独验证的问题。它不能代替一次真实外部网络测试,也不需要为了试通,把整台路由器的防火墙关掉。
附录:程序如何保证不是实验本来就坏了?
程序先等待IPv6基准连接成功,然后每次只引入一项故障,每项后都恢复并确认成功。四个故障加上基准、四次恢复,共九次正式检查;地址、IPv4/IPv6监听、选路和邻居状态也保存下来。防火墙组额外断言丢弃计数大于零。
需要Linux、root权限、Python 3、iproute2(含ss)、nftables;不依赖Python第三方包。脚本只创建自己的三个命名空间和veth,不接物理网卡,不改宿主路由或防火墙;退出时结束自己启动的服务并删除自己的空间。程序选择的18080端口在隔离空间内使用。
若nft安装在标准路径,可这样运行:
bash
sudo env BLOG_NETWORK_TOOLS=/ python3 verify_ipv6.py
解包工具则让BLOG_NETWORK_TOOLS指向包含usr/sbin/nft及其依赖库的根目录。默认值对应本次Ubuntu-24.04已有工具缓存。结果为程序旁的ipv6-results.json,详细日志留在新建v6-目录。
完整程序保存为verify_ipv6.py:
python
"""Root-only isolated IPv6 routing/listener/firewall/return-route experiment."""
import json
import os
from pathlib import Path
import socket
import subprocess as sp
import sys
import tempfile
import time
import uuid
base = Path(__file__).resolve().parent
if len(sys.argv)>1 and sys.argv[1]=='--serve':
family = socket.AF_INET if sys.argv[2]=='4' else socket.AF_INET6
with socket.socket(family,socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
if family==socket.AF_INET6: server.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
server.bind(('0.0.0.0' if family==socket.AF_INET else '::',18080));server.listen()
Path(sys.argv[3]).write_text('ready')
while True:
conn,_=server.accept()
with conn:
conn.settimeout(2)
if conn.recv(64)==b'hello': conn.sendall(b'IPV6_OK')
elif len(sys.argv)>1 and sys.argv[1]=='--probe':
result={'ok':False}
try:
with socket.socket(socket.AF_INET6,socket.SOCK_STREAM) as s:
s.settimeout(1.2);s.connect(('2001:db8:1::2',18080))
s.sendall(b'hello');body=s.recv(64).decode()
result.update(ok=body=='IPV6_OK',body=body,peer=s.getpeername()[0])
except OSError as e: result.update(error=type(e).__name__,errno=e.errno)
print(json.dumps(result))
else:
assert os.geteuid()==0,'Run as root to create isolated network namespaces.'
run=Path(tempfile.mkdtemp(prefix='v6-',dir=base))
tools=Path(os.environ.get('BLOG_NETWORK_TOOLS','/mnt/d/blog-ops/tmp/dhcp-tools-20261004/extracted'))
env=dict(os.environ,LD_LIBRARY_PATH=str(tools/'usr/lib/x86_64-linux-gnu')+':'+str(tools/'lib/x86_64-linux-gnu'))
token='v6'+uuid.uuid4().hex[:7];names={k:token+k for k in ['s','r','e']}
created=[];server=None;logs=[];record={'passed':False,'run':str(run),'cases':[]}
def cmd(*args,input=None):
p=sp.run(args,input=input,text=True,capture_output=True,env=env,timeout=15)
assert p.returncode==0,(args,p.stdout,p.stderr)
return p.stdout.strip()
def ni(n,*args):return cmd('ip','-n',names[n],*args)
def ne(n,*args,**kwargs):return cmd('ip','netns','exec',names[n],*args,**kwargs)
def link(a,an,b,bn):
ni(a,'link','add',an,'type','veth','peer','name',bn,'netns',names[b])
ni(a,'link','set',an,'up');ni(b,'link','set',bn,'up')
def address(n,iface,value):ni(n,'-6','addr','add',value,'dev',iface,'nodad')
def start(version):
global server
if server:server.terminate();server.wait(timeout=5)
ready=run/('ready'+version)
ready.unlink(missing_ok=True)
log=open(run/('server'+version+'.log'),'wb');logs.append(log)
server=sp.Popen(['ip','netns','exec',names['s'],'python3',str(Path(__file__).resolve()),'--serve',version,str(ready)],stdout=log,stderr=sp.STDOUT)
for _ in range(50):
assert server.poll() is None
if ready.exists():break
time.sleep(.05)
else:raise TimeoutError('server readiness')
def probe():return json.loads(ne('e','python3',str(Path(__file__).resolve()),'--probe'))
def check(name,ok):
r=probe()
row={'name':name,'probe':r,'serverIPv6':ni('s','-6','addr','show','dev','eth0'),
'v4Listeners':ne('s','ss','-lnt4'),'v6Listeners':ne('s','ss','-lnt6'),
'externalRoute':ni('e','-6','route','get','2001:db8:1::2'),
'serverRoutes':ni('s','-6','route','show')}
row['neighbors']={n:ni(n,'-6','neigh','show') for n in names}
record['cases'].append(row);assert r['ok']==ok,(name,row);return row
try:
for name in names.values():
cmd('ip','netns','add',name);created.append(name)
cmd('ip','netns','exec',name,'sysctl','-qw','net.ipv6.conf.all.disable_ipv6=0','net.ipv6.conf.default.disable_ipv6=0')
cmd('ip','-n',name,'link','set','lo','up')
link('s','eth0','r','lan');link('r','wan','e','eth0')
address('s','eth0','2001:db8:1::2/64');address('r','lan','2001:db8:1::1/64')
address('r','wan','2001:db8:2::1/64');address('e','eth0','2001:db8:2::2/64')
ni('e','-6','route','add','default','via','2001:db8:2::1')
ni('s','-6','route','add','default','via','2001:db8:1::1')
ne('r','sysctl','-qw','net.ipv6.conf.all.forwarding=1')
start('6')
# Allow newly created links to complete their initial IPv6 control traffic.
deadline=time.monotonic()+10
while not probe()['ok']:
assert time.monotonic()<deadline,'IPv6 baseline did not become reachable'
time.sleep(.3)
check('baseline',True)
start('4');r=check('ipv4_listener_only',False)
assert r['probe']['error']=='ConnectionRefusedError'
assert '18080' in r['v4Listeners'] and '18080' not in r['v6Listeners']
start('6')
# New server has rebound, so verify recovery before changing routing.
check('listener_restored',True)
ne('r','sysctl','-qw','net.ipv6.conf.all.forwarding=0');check('forwarding_off',False)
ne('r','sysctl','-qw','net.ipv6.conf.all.forwarding=1');check('forwarding_restored',True)
nft=str(tools/'usr/sbin/nft')
rules='table inet lab {\n chain forward {\n type filter hook forward priority 0; policy accept;\n ip6 daddr 2001:db8:1::2 tcp dport 18080 counter drop;\n }\n}'
ne('r',nft,'-f','-',input=rules+'\n')
r=check('router_firewall_drop',False)
r['firewall']=json.loads(ne('r',nft,'-j','list','table','inet','lab'))
counts=[e['counter']['packets'] for n in r['firewall']['nftables'] for e in n.get('rule',{}).get('expr',[]) if 'counter' in e]
assert counts and sum(counts)>0,r
ne('r',nft,'delete','table','inet','lab');check('firewall_restored',True)
ni('s','-6','route','del','default');check('return_route_missing',False)
ni('s','-6','route','add','default','via','2001:db8:1::1');check('return_route_restored',True)
record['passed']=True
finally:
if server and server.poll() is None:server.terminate();server.wait(timeout=5)
for f in logs:f.close()
for name in reversed(created):cmd('ip','netns','del',name)
record['serverStopped']=not server or server.poll() is not None
left=cmd('ip','netns','list');record['namespacesRemoved']=all(n not in left for n in created)
(base/'ipv6-results.json').write_text(json.dumps(record,indent=2))
print(json.dumps({'passed':record['passed'],'cases':[(c['name'],c['probe']) for c in record['cases']],'serverStopped':record['serverStopped'],'namespacesRemoved':record['namespacesRemoved']}))