客户端默认路由保持有效,服务器缺少回复路由仍会令通信失败。

默认路由匹配最宽范围,不会挽救所有失败路径

IPv4 默认路由是前缀 0.0.0.0/0,匹配所有 IPv4 目的地,但具体程度最低。在已经选择的路由表中,更长的匹配前缀通常优先。默认路由不是一个“其他具体路径失败后再重试”的过程。路由策略决定相关表,最终路由描述怎样转发,而不保证完整往返一定成功。匹配范围与路径健康是两类不同属性。 坏掉的样子 没有默认路由的机器,仍能访问直连或明确路由覆盖的目的地;反过来,添加默认路由也不证明能够访问互联网。下一跳需要可达,转发与过滤要允许流量,回复还需要返回路径。显示一个网关,只满足契约的一部分。真正配置是否共同工作,应由实际应用通信证明,而不是从路由文件中有一行 default 就推导全部能力。 更具体的拒绝路由会胜过可工作的默认路由。Linux 的 unreachable 等路由类型,会有意停止匹配目的地的转发。选定网关的邻居查询失败,也不意味着内核自动寻找更健康、却更宽泛的路径。静态路由优先级和 metric 不是服务健康监控或自动故障转移机制,不能把配置顺序当成逐个尝试可用出口的流程。 返回路径错误尤其容易误导。客户端选中预期网关并把流量送往监听中的服务器,服务器却可能无法把回复路由回客户端。反复修改客户端默认路由,可能破坏其他正常目的地,却没有修复缺失的反向路径。将所有连接超时归因于发送方网关之前,应查看两个方向和真实源地址。请求能到与响应能回,需要分别成立。 快速验证 将脚本保存为 check-default-route.py,准备 Docker 后运行 python3 check-default-route.py。Python 3.11.16 和 iproute2 6.1.0 在一次性 Linux 容器内建立客户端、路由器、目标拓扑。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 routed(setting): wire('client','c0','router','r0',['10.201.1.2/30'],['10.201.1.1/30']) wire('router','r1','target','t0',['10.201.2.1/30'],['10.201.2.2/30']) setting('router','net.ipv4.ip_forward',1) ip('client','route','add','default','via','10.201.1.1') ip('target','route','add','default','via','10.201.2.1') with lab(['client','router','target']) as (root,stack,setting): routed(setting);ip('client','route','delete','default') ip('target','addr','add','198.51.100.9/32','dev','lo');ip('router','route','add','198.51.100.9/32','via','10.201.2.2') daemon=server(stack,'target','198.51.100.9') connected=ns('client','ping','-n','-c','1','-W','1','10.201.1.1');assert connected.returncode==0 missing=request('client','198.51.100.9');assert not missing['ok'] and missing['errno']==101 ip('client','route','add','default','via','10.201.1.1') chosen=json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0];assert chosen['gateway']=='10.201.1.1' healthy=request('client','198.51.100.9');assert healthy['ok'] ip('client','route','add','unreachable','198.51.100.9/32') specific=request('client','198.51.100.9');assert not specific['ok'] and specific['errno']==113 ip('client','route','delete','unreachable','198.51.100.9/32') recovered=request('client','198.51.100.9');assert recovered['ok'] ip('target','route','delete','default') bad_return=request('client','198.51.100.9');assert not bad_return['ok'] and bad_return['type']=='TimeoutError' assert json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0]['gateway']=='10.201.1.1' ip('target','route','add','default','via','10.201.2.1') final=request('client','198.51.100.9');assert final['ok'] print(json.dumps({'connected_ping_without_default':0,'no_default':missing,'chosen_route':chosen,'with_default':healthy, 'specific_unreachable_route':specific,'specific_removed':recovered,'missing_return_route':bad_return,'return_route_restored':final})) """ 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) 客户端使用 10.201.1.2,路由邻居为 10.201.1.1。目标位于路由器后的另一条链路,在回环接口拥有服务地址 198.51.100.9。路由器有到该服务的具体路由,目标起初具有返回默认路由。文档保留地址仅用于隔离拓扑,没有联系公网服务器。每项地址和下一跳都在脚本中明确配置,避免依赖宿主偶然存在的路由。 ...

九月 14, 2027 · 2 分钟 · XB Field Notes
接口地址不变,前缀变化仍可能改变内核选中的下一跳。

子网前缀影响直连路由,最终路径由路由表选择

IPv4 前缀长度表示地址中有多少前导位标识网络。对于 192.0.2.5/24,网段是 192.0.2.0/24,普通广播地址是 192.0.2.255。配置接口通常会建立相应直连路由,但前缀不是完整路由算法。内核还要依据路由策略与路由表,为具体目的地选择路径,不能只看地址是否位于某个网段就推断全部行为。 坏掉的样子 前缀太短,会让主机把原本远端的目的地当成直接相连。保留地址 192.0.2.5,却把 /24 改成 /16,就把 192.0.3.9 纳入直连范围。在真实以太网中,这种错误可能让主机尝试解析一个根本不在当前链路上的邻居。于是地址看起来正确,下一跳决策却已经改变;反复核对地址字符串本身无法发现问题。 “不在自己的子网就走默认网关”也不完整。更具体的路由可以选择其他网关或接口;在已经选择的路由表内,更长的匹配前缀优先。明确的主机路由甚至可以覆盖本来位于接口子网内的目的地。应检查实际查询结果,而不是仅凭接口地址重新猜测内核会怎么做。默认路由只处理没有更具体匹配的相应流量。 地址数量计算也需要上下文。普通 /24 的网络地址与定向广播地址通常不用于主机分配,但“减二”不是统一公式。RFC 3021 允许点到点 /31 链路的两个地址都用作端点,/32 则标识单个地址。这些规则解释地址语义,没有证明配置中的两端能够通过当前物理拓扑交换数据。可用地址集合和实际通信仍须区分。 快速验证 将脚本保存为 check-subnet.py,准备 Docker 后执行 python3 check-subnet.py。程序给固定 Python 镜像安装 iproute2 6.1.0,再只向一次性容器授予 NET_ADMIN;容器没有外部网络接入。脚本创建虚拟 dummy 接口并修改该容器的路由,不改变宿主接口、路由或生产网关。构建下载与运行时路由实验也处于不同阶段: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='''FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84 RUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 && rm -rf /var/lib/apt/lists/* ''' program=r''' from ipaddress import IPv4Address,IPv4Interface,IPv4Network import json,subprocess def ip(*args): result=subprocess.run(['ip',*args],capture_output=True,text=True,timeout=3) assert result.returncode==0,result.stderr return result.stdout def route(address):return json.loads(ip('-j','route','get',address))[0] interface=IPv4Interface('192.0.2.5/24');network=interface.network assert str(network.network_address)=='192.0.2.0' and str(network.broadcast_address)=='192.0.2.255' assert IPv4Address('192.0.2.77') in network and IPv4Address('192.0.3.9') not in network try:IPv4Network('192.0.2.5/24');raise AssertionError('host bits accepted in strict network') except ValueError:strict_rejected=True point_to_point=[str(a) for a in IPv4Network('192.0.2.0/31').hosts()] assert point_to_point==['192.0.2.0','192.0.2.1'] ip('link','add','lab0','type','dummy');ip('link','set','lab0','up') ip('address','add','192.0.2.5/24','dev','lab0');ip('route','add','default','via','192.0.2.1','dev','lab0') ip('route','add','203.0.113.0/24','via','192.0.2.2','dev','lab0') direct=route('192.0.2.77');specific=route('203.0.113.9');default=route('198.51.100.9');before=route('192.0.3.9') assert direct['dev']=='lab0' and 'gateway' not in direct assert specific['gateway']=='192.0.2.2' and default['gateway']==before['gateway']=='192.0.2.1' ip('route','add','192.0.2.77/32','via','192.0.2.1','dev','lab0');overridden=route('192.0.2.77') assert overridden['gateway']=='192.0.2.1' ip('address','del','192.0.2.5/24','dev','lab0');ip('address','add','192.0.2.5/16','dev','lab0') ip('route','replace','default','via','192.0.2.1','dev','lab0');wrong=route('192.0.3.9') assert wrong['dev']=='lab0' and 'gateway' not in wrong print(json.dumps({'interface':str(interface),'network':str(network),'broadcast':str(network.broadcast_address), 'strict_host_bits_rejected':strict_rejected,'point_to_point_31_hosts':point_to_point, 'connected':direct,'more_specific_route':specific,'default':default,'host_route_overrides_subnet':overridden, 'destination_before_widening':before,'destination_after_wrong_16':wrong,'iproute_version':ip('-V').strip()})) ''' name='xbcatm-subnet-'+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', '--memory','128m','--pids-limit','64',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) Python 区分接口 192.0.2.5/24 与其网段 192.0.2.0/24。严格网络解析拒绝前者,因为主机位非零;/31 示例则列出 192.0.2.0 和 192.0.2.1 两个可用端点。这个计算确认地址集合,实验没有配置可工作的点到点链路,也没有测试这两个地址之间的转发。不能把库返回两个值,写成真实链路已经验证成功。 ...

九月 13, 2027 · 2 分钟 · XB Field Notes
设备从中心服务器续租一个短命地址。

DHCP 只把地址租给你

DHCP 发的是带时长的租约而不是永久的地址。租期驱动续约机制,也让服务器收回设备已不再使用的地址。 借用而非拥有地址 DHCP 并不给设备一个永久的地址,而是把地址借给它一段有限时间。设备入网时广播去发现服务器,服务器用网络预先配置的租期给出一份租约。正因为分配是临时的,服务器随时知道哪些地址有效,可以当作资源池来统计。这份「临时」恰恰是大型、频繁变动的网络能管理起来的根本:地址是服务器可以分发、续期、收回的货币,而不是某个主机永远占着的财产。 续约让租约保持存活 在租约到期之前,客户端会主动请求续约——通常在租期过半时才提出,不够再续——于是健康设备可以在无人干预的情况下长期保有同一地址。这种「半衰期续租」让地址跨越重启与会话保持稳定,对语音、部分 VPN 这类地址一变就断的服务尤其重要。租期是一个治理旋钮:租期短,地址回收快,但续约流量更密;租期长,地址更稳定,却把早已离开的地址长期拴着不放。 过期即可回收 租约到期又没有续约时,服务器会收回该地址,再把它发给另一台主机。一台许多天前就离开大楼的笔记本,就因此不会一直占着一个新设备正需要的地址。放弃租约的设备下一次入网会拿到一个新地址——对临时上网没问题,对期望固定身份的服务却不友好。调整租约就是在换手率与可用性之间找平衡:拥挤或流动的网络用短租期,内部稳定、身份重要的场景用长租期。 坏掉的样子 地址池耗尽时最安静。新客户端广播 DISCOVER,却收不到 OFFER,或者 REQUEST 换来一个 NAK,笔记本只能拿到 169.254.x.x 的链路本地地址,哪儿也去不了。这时不要让客户端盲目重试:应该扩大地址池,在人员流动大的网络里缩短租期,让离开的设备尽快归还地址,同时清理服务器上因冲突被标记为废弃的绑定。 相反的错误,是把租期设得太短,短到变成风暴。一间有上千台主机的办公室,如果租期只有五分钟,就会有约一半设备每隔几分钟在 T1 时刻单播续约,掉队的还要在 T2 时刻广播重绑,服务器一旦重启,正好撞上续约洪峰。应当保持 RFC 2131 的默认值,T1 取租期一半,T2 取租期的八分之七,先把过短的租期调长,再把开机潮错峰,而不是把续约流量当成免费的。 流氓服务器靠抢答获胜。一台私接的家用路由器或者恶意主机,会抢在真正的服务器之前回答 DISCOVER,给出一个看起来合法、网关或 DNS 却被掉包的地址,把流量引到自己身上。症状会游走:有的租约正常,有的解析异常,受害的客户端随着每次竞速不断变化。对策是固定合法服务器的标识,在交换机上开启 DHCP 监听,只允许受信任端口发送 OFFER 和 ACK,并把流氓设备清走,而不是调定时器。 时钟偏斜破坏的是那份安静的契约。线上传输的是相对秒数,可一旦服务器用绝对时间戳保存过期时刻,或者笔记本一睡就睡过 T1 和 T2,就会提前过期或者超期占用。睡着的机器醒来时还配着旧地址,而服务器早已把它收回转租,两个主机就会争用同一个 IP。应当把长时间睡眠当作一次重绑,排查时把 NTP 和 DHCP 日志放在一起看,并保留冲突检测与 DECLINE、RELEASE 的处理,不要假设时钟永远不会跳变。 ...

九月 7, 2027 · 3 分钟 · XB Field Notes