关于IPv6 邻居发现的标题卡,配有箭头连接的通用 Observe、Decide、Act 面板。

IPv6 先用 ICMPv6 解析邻居,TCP 才能正常通信

以太网链路上的 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 相连,不修改宿主邻居表或防火墙规则: ...

十一月 16, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明ARP 解析 IPv4 下一跳。

ARP 把 IPv4 下一跳映射到链路层地址

IPv4 转发选出了下一跳,但以太网仍然需要目的 MAC 地址。地址解析协议(ARP)向本地链路询问哪个网卡拥有某个 IPv4 地址,然后把答案缓存为邻居条目。这种映射是学来的而非永久配置,所以会老化,会在网卡迁移后刷新,也可能被同一广播域里的另一台主机伪造。 在本地链路上发问 假设主机要向直连网络中的 192.0.2.20 发送数据。它广播一条 ARP 请求,带上自己的 IP/MAC,并询问谁拥有目标 IP。属主用单播应答自己的硬件地址。发送方随后才能组装以太网帧。目的地不在本地链路时,主机解析所选网关;每一段以太网链路使用自己的下一跳映射,而不是远端主机的 MAC。 这个请求很小,而且只在本地发生。它不是 DNS 查询,也不会穿过路由器。请求尚未完成时,条目可能是 incomplete;近期流量确认可达时是 reachable;计时器到期后变成 stale;解析失败则可能进入 failed。Linux 用 ip neigh 展示这些状态,较旧的 arp(8) 只呈现更简单的地址到硬件映射。计时器和状态转换取决于内核与网卡策略,不能把“默认 60 秒”当成所有系统的承诺。 网卡是答案的一部分 同一个 IPv4 地址可以出现在不同链路,所以 ARP 记录必须和设备一起理解。ip neigh show dev eth0 只询问一张网卡;arp -an 也把接口列在每条映射旁边。虚拟机迁移或更换网卡后,邻居可能还缓存着旧 MAC。免费 ARP 通告能让邻居更快更新,但它仍是没有签名的声明,在敏感网络上应当监控而不能盲信。 ...

十一月 15, 2026 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

路径 MTU 发现失效时,握手和小请求仍可能成功

路径 MTU 是某条具体路由上各链路 MTU 的最小值。发送端不能只根据自身接口确定它,因为中间链路可能只能承载更小的数据包。经典 IPv4 路径 MTU 发现设置禁止分片标志,通过 ICMP“需要分片”反馈降低估计包大小。路由能够到达目的地,与发送大小适合整条路径,是两项不同条件,前者成立不能自动证明后者。 坏掉的样子 隧道或其他受限中间链路,可能使大传输失败,而 TCP 握手与小请求仍然成功。小报文能够通过瓶颈,因此这些成功没有验证完整大小的数据。反复只测短 ping 或微小健康响应,就可能漏掉影响上传、大响应或持续传输的路径 MTU 问题。应让检查包含真实负载中会触发限制的大小,避免健康探针与实际业务长期脱节。 过滤必要 ICMP 反馈,会让过大报文进入黑洞。发送端观察到没有进展,却缺少指出更小 MTU 的经典信号。其他探测机制可能提供帮助,取决于传输协议和配置,因此不能断言所有 TCP 实现遇到这种过滤都会永久卡住。作出结论前,应查明实际启用了哪一种发现或黑洞恢复机制,不能把某个实验控制条件省略后推广到所有环境。 路径信息还可能被缓存。成功发现之后进行的测试,即使防火墙改变,也可能与全新客户端表现不同。复用已有状态会隐藏反馈路径损坏。路由变化和非对称返回路径进一步增加复杂性,所以需要记录方向、来源、目的地,以及估计是在什么条件下学到的。配置变化前后的结果,只有在这些前提清楚时才适合直接比较。 快速验证 以下命名空间、抓包与套接字集成原样保留,本轮未重跑;观察属于历史记录,离线检查不能复现网络结果。 将脚本保存为 check-path-mtu.py,准备 Docker 后运行 python3 check-path-mtu.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间,两台路由器通过新建 veth 连接端点,不修改宿主路由或接口: ...

十一月 14, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示TIME-WAIT 跟踪连接四元组。

TIME-WAIT 保护连接实例,不是独占一个端口号

TIME-WAIT 是 TCP 在某些有序关闭之后继续保留的协议状态。它为必要时重发最后确认、完成关闭交换留出时间,也帮助避免早先连接实例的延迟报文被误认成新连接数据。相关身份包含两端地址和端口,不能把一个 TIME-WAIT 条目简单理解成对某个本地端口号的全局锁。连接四元组和单独端口,是不同范围的概念。 坏掉的样子 普通关闭中,首先发起关闭的一端通常在确认对端 FIN 后进入 TIME-WAIT。这一端可能是客户端,也可能是服务器;同时关闭还可能涉及双方。假定 TIME-WAIT 永远属于服务器,会掩盖真正制造连接周转的工作负载。应查看状态及两端四元组,不能从应用角色推断谁先关闭,也不能把客户端保留该状态直接视为异常。 高频连接周转可能限制到某个目的地的新连接,但 TIME-WAIT 数量很大,本身不证明资源耗尽或应用文件描述符泄漏。应用关闭套接字之后,操作系统仍可保留协议状态。源地址、可用端口、目的地多样性、显式绑定、复用选项与内核行为,都会影响实际限制。诊断资源耗尽之前,应同时测量连接失败与状态分布,而不是只盯一个总数。 混淆 TIME-WAIT 和 FIN-WAIT-2 会导致无效调参。Linux 的 tcp_fin_timeout 控制另一种孤立套接字情形,不是通用的 TIME-WAIT 时长开关。关于 tcp_tw_recycle 的旧建议,也不适用于当前 Linux。复用受到协议安全条件约束,其中包含时间戳相关检查;如果不了解真实套接字路径,仅修改一个参数,就可能产生错误的行为预期。 快速验证 将脚本保存为 check-time-wait.py,准备 Docker 后运行 python3 check-time-wait.py。一次性环境使用 Python 3.11.16 和 iproute2 6.1.0,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间及临时 proc 挂载。客户端与目标通过新建 veth 相连,参数变更只作用于这些范围,不影响宿主服务或接口: ...

十一月 13, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示每个 TCP 发送端选择自己的拥塞策略。

TCP 拥塞控制由每个发送端决定,两端可以使用不同算法

TCP 拥塞控制限制发送端向容量有限、可能被共享的网络注入数据。拥塞窗口约束未确认数据量,采用 pacing 时还会控制发送在时间上的分布。接收端通告窗口施加的是另一类流量控制限制。这些机制相互作用,却描述不同约束,不能笼统合并成一个“发送速度设置”,否则很难判断真正限制当前连接的是哪一部分。 坏掉的样子 两端不需要选择完全相同的拥塞控制算法。每端负责控制自己发送的流量,因此 Reno 客户端能够与 CUBIC 服务器通信。TCP 建立过程协商传输选项,没有要求双方发送算法名称一致。把不同控制器名称诊断成协议不匹配,会分散注意力,掩盖真正的路由、缓冲或应用故障。算法选择需要按方向和具体套接字理解。 算法是否可用,也是需要核对的运行时事实。配置字符串写了某个控制器,不证明内核支持它或允许调用进程选择它。Linux 的 TCP_CONGESTION 按套接字设置,受可用和允许算法约束。应检查调用结果,并回读实际名称,不能默认请求标签已生效。选择失败与选择成功后性能不合适,属于不同问题,需要不同处理。 丢失恢复不存在适用于所有情况的统一曲线。重传超时、快速恢复、显式拥塞通知和基于模型的估计,会因控制器不同而产生不同响应。丢失可能来自拥塞,也可能来自其他原因,发送端仍须按算法规则作出反应。增加缓冲并不能自动修复持续丢包或等待确认的连接,也不能替代对具体恢复状态和触发信号的检查。 快速验证 将脚本保存为 check-congestion-control.py,准备 Docker 后运行 python3 check-congestion-control.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0 和 nftables 1.0.6,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间。新建 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',process.stderr.read()) 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 info_code=r''' import struct def tcp_info(stream): raw=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_INFO,104);assert len(raw)==104 offsets={'snd_mss':16,'unacked':24,'rtt_us':68,'snd_ssthresh':76,'snd_cwnd':80,'total_retrans':100} return {'ca_state':raw[1],**{key:struct.unpack_from('=I',raw,offset)[0] for key,offset in offsets.items()}} ''' with lab(['client','target']) as (root,stack,setting): wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30']) available=ns('client','sysctl','-n','net.ipv4.tcp_available_congestion_control').stdout.split();assert {'reno','cubic'}<=set(available),available for name,interface in [('client','c0'),('target','t0')]: ns(name,'tc','qdisc','add','dev',interface,'root','netem','delay','10ms','limit','10000') server_code=r''' import socket,json,hashlib listener=socket.socket();listener.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'cubic') listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True) with listener.accept()[0] as connection: connection.settimeout(8) print(json.dumps({'algorithm':connection.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode()}),flush=True) for size in [65536,16384]: data=b'' while len(data)<size: chunk=connection.recv(size-len(data));assert chunk;data+=chunk assert data==b'x'*size connection.sendall((hashlib.sha256(data).hexdigest()+'\n').encode()) ''' service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening' client_code=info_code+r''' import socket,json,sys,time,hashlib with socket.socket() as unsupported: try:unsupported.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'xbcatm_absent');raise AssertionError('unexpected available algorithm') except OSError as error:selection_failure={'errno':error.errno,'type':type(error).__name__} assert selection_failure['errno']==2,selection_failure stream=socket.socket();stream.settimeout(8);stream.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'reno') stream.bind(('192.0.2.1',40600));stream.connect(('192.0.2.2',18080)) with stream.makefile('rb') as incoming: stream.sendall(b'x'*65536);assert incoming.readline().decode().strip()==hashlib.sha256(b'x'*65536).hexdigest() baseline=tcp_info(stream);assert baseline['snd_cwnd']>1 algorithm=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode() print(json.dumps({'baseline':baseline,'algorithm':algorithm,'unavailable_algorithm':selection_failure}),flush=True) assert sys.stdin.readline().strip()=='drop';stream.sendall(b'x'*16384) deadline=time.monotonic()+4;samples=[] while time.monotonic()<deadline: state=tcp_info(stream);samples.append(state) if state['total_retrans']>baseline['total_retrans'] and state['snd_cwnd']==1 and state['ca_state']==4:break time.sleep(.01) else:raise AssertionError(('no timeout recovery observed',samples)) print(json.dumps({'during_blackout':state}),flush=True) assert sys.stdin.readline().strip()=='restore' assert incoming.readline().decode().strip()==hashlib.sha256(b'x'*16384).hexdigest() print(json.dumps({'after_restore':tcp_info(stream),'verified_bytes':65536+16384}),flush=True) stream.close() ''' client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code]) baseline=json.loads(line(client));server_result=json.loads(line(service));assert baseline['algorithm']=='reno' and server_result['algorithm']=='cubic' nft('target','''table ip blackout { chain input { type filter hook input priority 0; policy accept; tcp sport 40600 tcp dport 18080 ip length > 60 counter drop comment "data_dropped" } } ''') client.stdin.write('drop\n');client.stdin.flush();loss=json.loads(line(client,5)) count=counters('target');assert count['data_dropped']>0 nft('target','delete table ip blackout');client.stdin.write('restore\n');client.stdin.flush() recovery=json.loads(line(client,8));client.wait(timeout=2);service.wait(timeout=2);assert client.returncode==service.returncode==0 print(json.dumps({'available':available,'client':baseline,'server':server_result,'loss':loss,'drop_counter':count,'recovery':recovery})) """ name='xbcatm-tcpflow-'+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) 脚本首先验证 Reno 与 CUBIC 可用。刻意选择一个不存在的控制器会失败,返回 ENOENT,错误号二,Python 表现为 FileNotFoundError。这个异常指控制器选择不可用,不是应用数据文件丢失。真实客户端明确选择 Reno,服务器监听套接字选择 CUBIC,接受后的套接字报告继承了该选择,分别验证配置意图和最终生效状态。 ...

十一月 12, 2026 · 2 分钟 · XB Field Notes
标题卡写着“UDP 数据报由应用负责恢复”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

UDP 保留数据报边界,恢复和时效由应用负责

UDP 在传输端点之间携带独立数据报,八字节首部包含来源端口、目的端口、长度和校验和字段。它不会加入 TCP 的可靠有序字节流或拥塞控制行为,让应用可以选择自己的交付策略,但不承诺所有负载下延迟都更低。应用确实需要某条内容时,丢失的数据报可能比建立连接的开销更昂贵,必须按完整请求过程评估。 坏掉的样子 发送成功不证明目标应用已经收到或处理消息。UDP 没有内建确认、重传、去重或顺序保证,因此超时不能告诉支付客户端操作是否发生。允许重试时,应用需要能够处理重复请求和结果不确定的协议,不能把没有响应直接解释成业务失败。消息是否到达、动作是否执行、结果是否持久化,也应具有不同含义。 即使本地路径健康,接收缓冲区也能制造失败。数据报大于本次调用提供的缓冲区时,内容可能截断,缺失后缀不会像字节流那样留给下一次读取。Linux 的 recvmsg 可以通过 MSG_TRUNC 报告这一情况。如果把缩短的字节当成完整命令或测量值,就可能在没有明显进程错误的情况下改变业务含义,因此必须检测协议要求的完整性。 UDP 没有内建拥塞控制,意味着应用承担责任,并不意味着可以无限制发送。需要根据部署选择发送节奏、拥塞行为、消息大小和截止时间。分片可能降低大数据报的交付稳健性,过了截止时间的数据也可能已经没有价值。QUIC 等协议能够在 UDP 之上提供可靠流,说明底层载体本身不能决定全部应用可靠性。 快速验证 将脚本保存为 check-udp.py,准备 Docker 后运行 python3 check-udp.py。固定 Python 3.11.16 Linux 容器没有外部网络路径和宿主端口发布。第一部分检查真实套接字接收语义;第二部分使用刻意编写的中继扣留、重复和重排消息,不声称测量自然发生的网络丢包。实验的每个故障输入都在代码中明确可见: import json,subprocess,uuid image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84" program=r""" import json,socket with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as sender,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as receiver: receiver.bind(('127.0.0.1',0));receiver.settimeout(.25);sender.settimeout(1);target=receiver.getsockname() sender.connect(target) for body in [b'A',b'BC',b'']:sender.send(body) boundaries=[receiver.recvfrom(16)[0] for _ in range(3)] assert boundaries==[b'A',b'BC',b''] sender.send(b'abcdefgh');data,ancillary,flags,peer=receiver.recvmsg(3) assert data==b'abc' and flags&socket.MSG_TRUNC try:receiver.recvfrom(16);raise AssertionError('truncated suffix survived') except TimeoutError:tail='discarded' sender.send(b'next');assert receiver.recvfrom(16)[0]==b'next' # A deliberate application relay changes delivery; this is not measured network loss. received=[];applied=[];seen=set();forwarded=[] with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as relay,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as output: relay.bind(('127.0.0.1',0));relay.settimeout(1);output.settimeout(1) sender.connect(relay.getsockname()) for seq in [1,2,3]:sender.send(str(seq).encode()) inputs=[relay.recvfrom(16)[0] for _ in range(3)] for index in [2,0,0]: output.sendto(inputs[index],target);forwarded.append(int(inputs[index])) value=int(receiver.recvfrom(16)[0]);received.append(value) if value not in seen:seen.add(value);applied.append(value) assert received==[3,1,1] and applied==[3,1] and 2 not in seen try:receiver.recvfrom(16);raise AssertionError('missing message arrived by itself') except TimeoutError:no_retransmit=True print(json.dumps({'datagram_lengths':[len(x) for x in boundaries],'empty_datagram_is_message':boundaries[2]==b'', 'short_buffer':data.decode(),'truncation_flag':bool(flags&socket.MSG_TRUNC),'unread_tail':tail, 'relay_inputs':[int(x) for x in inputs],'relay_outputs':forwarded,'receiver':received,'deduplicated':applied, 'missing_sequence':2,'no_automatic_retransmission_during_deadline':no_retransmit})) """ name='xbcatm-socket-'+uuid.uuid4().hex[:10] try: result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none', '--memory','128m','--pids-limit','64',image,'python3','-'],input=program, capture_output=True,text=True,timeout=60) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True)) finally: subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10) 三次发送分别包含 A、BC 和空载荷,三次接收得到长度一、二和零。每次读取消费一条数据报,没有把相邻发送合并成字节流。空结果是合法零长度消息,不代表发送方关闭连接。发送套接字使用 UDP connect 选定对端,这个接口调用不会把 UDP 变成 TCP,也不会凭空增加确认和重传机制。 ...

十一月 11, 2026 · 1 分钟 · XB Field Notes
标题卡写着“Traceroute 显示探测响应”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

Traceroute 展示探测响应,不能完整代表转发健康

Traceroute 发送 TTL 逐步增大的探测包,观察数据包在哪里到期,以及哪些系统报告了到期。IPv4 路由器转发时递减 TTL;到期后,通常由 ICMP Time Exceeded 响应标识一个作出回复的跳点。传统 UDP 跟踪在目标报告端口未使用时结束。输出是一系列关于探测和回复的观察,不保证列出全部转发设备,也不直接等同于应用经过的唯一稳定路径。 坏掉的样子 星号表示某个探测没有在等待时间内获得预期响应。路由器可能仍正常转发普通流量,却过滤、抑制或限制自己生成的 ICMP 回复。回复的返回路径也可能出问题。因此,中间某跳沉默,本身不能证明应用数据包在那里停止;后续跳点仍然回复,甚至会直接反驳这种判断。需要区分转发动作和产生诊断响应这两种行为。 探测协议同样重要。传统高端口 UDP 流量可能遇到与允许服务端口上的 TCP 不同的策略。TCP traceroute 可以让探测更接近被允许的传输方式,但仍没有执行完整应用交互。收到 SYN 响应或重置,可以帮助判断目的地可达,却不能证明 HTTP、认证或某项业务操作成功。协议层面的终点响应必须与应用结果分别解释。 每跳时间是从观察者出发的往返时间,包含响应生成和返回路径。把相邻数值相减,不能可靠测量两台路由器之间的单向链路延迟。负载均衡、返回路径不对称、调度与 ICMP 处理都会影响观察。比较跟踪时,应保留探测方式和测量条件,不能把一次打印序列当作永久拓扑,也不能把后续更短的往返值解释成负延迟。 快速验证 将脚本保存为 check-traceroute.py,准备 Docker 后运行 python3 check-traceroute.py。一次性环境使用 traceroute 2.1.2、nftables 1.0.6 与 Python 3.11.16。NET_ADMIN 和 SYS_ADMIN 支持嵌套命名空间及临时 proc 挂载。客户端、路由器和目标通过新建 veth 相连,全部选路与过滤变更都留在实验内部,不触碰宿主正在使用的网络配置: ...

十一月 5, 2026 · 3 分钟 · XB Field Notes
标题为「双栈地址排序与最终成功的连接选择是不同步骤」的封面,下方是观察、判断与行动三个面板。

双栈地址排序与最终成功的连接选择是不同步骤

发现 A 与 AAAA 记录,只提供可能的目的地,本身不会选出成功连接。地址排序、源地址选择、路由以及应用连接算法都会参与结果。RFC 6724 描述默认源地址和目的地址选择规则,应用仍可作自己的选择。如果程序只请求 IPv4,那么在任何双栈连接策略开始工作前,它就已经缩小了候选范围。解释行为时,应先确认实际调用方请求了什么。 坏掉的样子 地址偏好不是应用健康探测。某个 IPv6 目的地可能符合本地策略和路由条件,远端服务却已经停止,或者更后面的网络跳点会丢包,因此优先候选仍可能失败。如果假定操作系统已经测试了返回列表中每个端点,就会忽略连接截止时间和尝试其他地址的必要性。查询成功只能证明获得候选信息,不能代替服务通信结果。 RFC 6724 的规则是按顺序比较条件,并不是对网络成本或速度打一个总分。相关属性包括可用性、作用域、策略标签、优先顺序和匹配前缀,但它们不测量应用响应时间。目的地址选择还应与路由表最长前缀匹配区分:后者决定已选目的地的数据包如何发送。混用这两个阶段,会把主机偏好错误解释成已经知道整条路径延迟最低。 Happy Eyeballs 属于另一层,通过协调地址发现和错开连接尝试,减少某个地址族出问题时的等待。按顺序逐个尝试地址的客户端,并不自动实现该算法。立即拒绝连接与静默丢弃连接尝试,可能造成非常不同的等待时间,即使最后都能通过 IPv4 成功。不能只看最终用了另一个地址族,就把过程统称为相同的快速竞速机制。 快速验证 将脚本保存为 check-address-selection.py,准备 Docker 后执行 python3 check-address-selection.py。固定环境使用 glibc 2.36、Python 3.11,以及提供 DNS 的 BIND 9.18.49 和 dnsmasq 2.90。两个真实 HTTP 监听分别使用 IPv4 与 IPv6 回环,端口相同。实验没有公网路径,因此观察到的排序明确属于这个本地环境,不作为所有客户端的默认承诺: ...

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