有IPv6地址,为什么外面还是连不进来?让同一台服务分别卡在四个位置

有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独占监听时拒绝,关闭转发、规则丢弃和缺少回程时在本次预算内超时;每项单独恢复后,连接都重新成功。

因此,"超时"本身不足以选出唯一原因。它只是告诉我们,在给定时间内没有完成这个操作。想往下判断,需要监听、路由、规则计数或抓包等别的证据。

现场可以先按这个顺序缩小问题:

  1. 服务端到底是什么地址?fe80::链路本地地址不用于跨路由访问;ULA也不等于公网可路由地址。即便显示scope global,也不能仅凭这两个字证明互联网知道怎么到你这里。
  2. 先在同一局域网用该IPv6地址和端口连接服务,核对真实监听;不要只测试localhost。地址与端口写进URL时,IPv6字面地址需要方括号,例如http://[你的地址]:端口/。
  3. 再换到一个确实有IPv6连通性的外部网络,用同一地址、同一端口测试。普通IPv4网络不能凭一个IPv6字面地址直接建立IPv6连接。
  4. 内部成功、外部失败,再沿路由器的转发与防火墙、服务端回程、上游路由继续查。若还使用域名,另核对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']}))
相关推荐
流浪00128 分钟前
计算机网络篇22:网络层——公网的三级地址划分与逐跳转发,从私网出口替换到 IP 分片组装
tcp/ip·计算机网络·路由器·公网搭建·报文分篇
CodeYp201929 分钟前
第3讲:网络命令
linux·网络·命令
2035去旅行13 小时前
bootcmd 详细讲解
linux·嵌入式·arm·soc
陈希12313 小时前
Linux 防火墙配置实战:firewalld、iptables 与云安全组
linux·运维·服务器
女神下凡14 小时前
芯参谋(95):嵌入式文件系统_开发指南
linux·服务器·arm开发·驱动开发·设计规范
Java成神之路-15 小时前
TCP Socket 与 UDP Socket
tcp/ip·计算机网络·udp
且从容.17 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道18 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
且从容.21 小时前
ubuntu安装flink
linux·ubuntu·flink