两个私网客户端共用转换地址,路由器用可区分的源端口维护连接映射。

NAT 通过独立连接映射共享一个地址

网络地址转换会在数据包经过边界时改写地址信息。常见的多个客户端共用一个地址,实际采用网络地址和端口转换:路由器维护映射,区分同时存在的流量。远端 TCP 服务器可能看到相同源 IP,却看到不同源端口。这个共享地址代表转换边界,不一定代表同一台计算机、同一个账号或同一个人,不能直接替代身份判断。 坏掉的样子 转换不能代替路由。客户端仍需要到网关的路径,网关必须允许转发,转换后的地址也要具有返回路径。反过来,在能够路由相应前缀的内部网络中,使用私有地址并不必然要求 NAT。缺少返回路由和缺少转换规则都可能表现为超时,但两者对应不同配置决策。应先确定目标看到什么来源,再检查回复如何返回。 有状态 NAT 还会使规则变更中的已有连接与新连接表现不同。nftables 查询 NAT 链以建立流量映射,后续数据包复用保存的绑定。因此,移除 SNAT 规则不一定撤销已经建立的转换连接。这依赖连接跟踪继续工作、绑定仍然有效,不能把一次规则编辑理解成无条件清除全部会话。测试对象是否复用了旧连接,会直接影响结论。 端口映射与连接跟踪容量都是有限资源。资源耗尽、状态到期,或者故障切换设备缺少相关状态,都可能在对外地址未变时中断通信。可用转换源端口需要结合协议和相关连接元组判断,不能用一个对所有目的地、所有负载都通用的端口总数估算容量。NAT 与决定哪些流量被允许的防火墙策略,也属于不同职责。 快速验证 将脚本保存为 check-nat.py,准备 Docker 后运行 python3 check-nat.py。它构建包含 Python 3.11.16 和 nftables 1.0.6 的一次性环境,通过 NET_ADMIN 与 SYS_ADMIN 创建四个嵌套网络命名空间及临时 proc 挂载。实验在 Linux 6.12.76 上执行;Docker 共享宿主内核,不会修改宿主接口或已有防火墙规则: 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 with lab(['a','b','router','target']) as (root,stack,setting): wire('a','a0','router','ra',['10.203.1.2/30'],['10.203.1.1/30']) wire('b','b0','router','rb',['10.203.2.2/30'],['10.203.2.1/30']) wire('router','wan','target','t0',['192.0.2.1/30'],['192.0.2.2/30']) setting('router','net.ipv4.ip_forward',1) ip('a','route','add','default','via','10.203.1.1');ip('b','route','add','default','via','10.203.2.1') server_code=r''' import json,socketserver class Handler(socketserver.StreamRequestHandler): def handle(self): for raw in self.rfile: data=json.loads(raw);data['peer']=self.client_address self.wfile.write(json.dumps(data).encode()+b'\n');self.wfile.flush() class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True server=Server(('192.0.2.2',18080),Handler);print('ready',flush=True);server.serve_forever() ''' daemon=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(daemon).strip()=='ready' client_code=r''' import json,socket,sys label,port=sys.argv[1],int(sys.argv[2]) try: with socket.socket() as stream: stream.settimeout(.7);stream.bind(('0.0.0.0',port));stream.connect(('192.0.2.2',18080)) with stream.makefile('rb') as incoming: def exchange(phase): stream.sendall(json.dumps({'label':label,'phase':phase}).encode()+b'\n') print(json.dumps({'ok':True,'reply':json.loads(incoming.readline())}),flush=True) exchange('initial') for command in sys.stdin:exchange(command.strip()) except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}),flush=True) ''' def once(name,port):return json.loads(ns(name,'python3','-u','-c',client_code,name,str(port),input='').stdout) before=once('a',39000);assert not before['ok'] and before['type']=='TimeoutError' nft('router','''table ip translation { chain postrouting { type nat hook postrouting priority srcnat; policy accept; ip saddr 10.203.0.0/16 oifname "wan" counter snat to 192.0.2.1 comment "new_mapping" } } table inet tracking { chain forward { type filter hook forward priority filter; policy accept; ct state established,related counter accept comment "established" } } ''') clients={};initial={} for name in ['a','b']: clients[name]=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',client_code,name,'40000']) initial[name]=json.loads(line(clients[name]));assert initial[name]['ok'] and initial[name]['reply']['label']==name peers=[value['reply']['peer'] for value in initial.values()] assert all(peer[0]=='192.0.2.1' for peer in peers) and peers[0][1]!=peers[1][1] mapped=counters('router');assert mapped['new_mapping']==2 nft('router','flush chain ip translation postrouting') retained={} for name,process in clients.items(): process.stdin.write('after-rule-removal\n');process.stdin.flush();retained[name]=json.loads(line(process)) assert retained[name]['ok'] and retained[name]['reply']['peer']==initial[name]['reply']['peer'],(name,retained[name],initial[name]) new_flow=once('a',40001);assert not new_flow['ok'] and new_flow['type']=='TimeoutError' ip('target','route','add','10.203.1.0/30','via','192.0.2.1') routed_without_nat=once('a',40003);assert routed_without_nat['ok'] and routed_without_nat['reply']['peer'][0]=='10.203.1.2' nft('router','delete table inet tracking') clients['a'].stdin.write('after-tracking-removal\n');clients['a'].stdin.flush() tracking_removed=json.loads(line(clients['a']));assert not tracking_removed['ok'],tracking_removed print(json.dumps({'without_nat_or_return_route':before,'both_clients_bind_port':40000,'translated_connections':initial, 'nat_first_packet_counter':mapped,'existing_after_rule_removal':retained,'new_after_rule_removal':new_flow, 'explicit_return_route_without_nat':routed_without_nat,'last_tracking_reference_removed':tracking_removed})) """ 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) 客户端 a 与 b 位于路由器后的不同私网子网。目标起初只有通往路由器外侧接口 192.0.2.1 的直连路由。这里的文档保留地址始终位于实验内部,没有连接公网服务器。即使路由器已经启用转发,在既无转换、也无到客户端子网的明确返回路由时,第一次 TCP 交互仍然超时,证明启用转发并不足以保证往返。 ...

十一月 18, 2026 · 3 分钟 · XB Field Notes