A 从同一个 UDP socket 向 STUN 服务器发送请求,得到外部地址 203.0.113.10:62000,把它告诉 B。B 发来的包却没有到达 A:可能是 A 向 B 通信时使用了另一份映射,也可能映射相同但入站过滤不允许 B。NAT 穿透需要同时处理这两个问题,并通过实际连通性检查确认候选路径。
| 机制 | 提供什么 | 不保证什么 |
| 本地端口分配 | 区分 socket/传输端点 | 所有连接全局独占一个端口号 |
| NAT 映射 | 内外地址与端口的对应状态 | 任意外部来源都可入站 |
| STUN | 观察映射及构建连通性检查 | 自动给所有对端打开路径 |
| TURN | 分配并维护中继承载 | 中继永远可达、无资源或认证失败 |
| ICE | 收集候选、检查候选对并提名 | 直接测量并选出全网最低 RTT 路径 |
端口容量:先说明哪些字段固定
TCP 连接通常由源/目的地址和端口在相应网络上下文中区分。同一本地端口可能用于连接不同远端,因此“有 28232 个临时端口,就只能总共建立 28232 条 TCP 连接”不成立。显式 bind、不同 socket 用法、目标集中程度与 NAT 分配策略会改变复用条件。Cloudflare:临时端口与连接四元组实验
若临时范围是 32768~60999,含两端共有 28232 个号。对固定源 IP、固定目的 IP:port,暂不考虑保留端口、并发存活和安全复用,若每次短连接让四元组占用约 60 s,则量级估算为 28232/60≈470 次新连接每秒。它是受限条件下的容量预算,不是机器全局性能上限。
扩展源地址、复用连接、减少无效短连接可能改变预算;增大端口范围也不修复文件描述符、内存、conntrack 或上游 NAT 端口配额的限制。先确定失败点,再解释 EADDRNOTAVAIL 等错误,不能一见错误就无条件修改 TIME_WAIT 参数。
cat /proc/sys/net/ipv4/ip_local_port_range
cat /proc/sys/net/ipv4/ip_local_reserved_ports
ss -s
ss -tan state time-wait
UDP 没有 TCP TIME_WAIT 状态,但 NAT/conntrack 为 UDP 保留的映射与过滤状态仍可能占资源、超时或被回收,二者不能混为一个计时器。
映射与过滤:两条独立分类轴
设内部端点固定为 I=192.168.1.10:50000,先后访问 S1=198.51.100.1:3478、S2=198.51.100.2:3478、S3=198.51.100.2:9999。
| 映射行为 | 是否把目的纳入映射复用条件 | 本例理解 |
| Endpoint-Independent Mapping,EIM | 不依赖外部目的端点 | I 向三者可复用相同映射 |
| Address-Dependent Mapping,ADM | 依赖外部目的 IP | S2/S3 可复用,S1 不属同组 |
| Address and Port-Dependent Mapping,APDM | 依赖目的 IP 与端口 | 三个目的分别处理 |
分类描述映射的依赖关系,不要求每次必然分配一个从未出现过的外部端口,更不表示 APDM 端口一定随机且不可预测。端口保留、冲突处理与映射分类是不同维度。RFC 4787:UDP NAT 映射
| 过滤行为 | 已有映射时允许哪些外部来源 |
| Endpoint-Independent Filtering | 不因此前联系的外部端点限制来源 |
| Address-Dependent Filtering | 来源 IP 需符合此前出站通信建立的条件 |
| Address and Port-Dependent Filtering | 来源 IP 和端口都需符合相应条件 |
EIM 可以和严格过滤同时存在;旧式 Cone/Symmetric 标签把多个行为压在一个名字里,不适合精确诊断。也不能仅凭“家用”或“移动网络”标签就断言行为和成功率。RFC 4787:过滤
相同公网端口,为什么回包仍可能被挡?
先只联系 A:3478,切换过滤轴观察另外两个来源的准入。再联系 B:3478,切换映射轴观察是否新建外部端点。每行的允许列表只对应那一行映射;不能拿另一映射上的通信历史替它放行。
正在呈现知识画面
相同公网端口,为什么回包仍可能被挡?映射回答外部端点怎样复用,过滤回答哪些来源可以进入已有映射。它们不是同一条轴;STUN观察到映射也不证明任意对端能进来。
{"id":"nat-mapping-filtering","title":"相同公网端口,为什么回包仍可能被挡?","summary":"映射回答外部端点怎样复用,过滤回答哪些来源可以进入已有映射。它们不是同一条轴;STUN观察到映射也不证明任意对端能进来。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"form-field\"><span data-i18n=\"mapping\"></span><select id=\"mapping\"><option value=\"ei\">Endpoint-independent</option><option value=\"ad\">Address-dependent</option><option value=\"ap\">Address+port-dependent</option></select></label><label class=\"form-field\"><span data-i18n=\"filter\"></span><select id=\"filter\"><option value=\"ei\">Endpoint-independent</option><option value=\"ad\">Address-dependent</option><option value=\"ap\">Address+port-dependent</option></select></label><label class=\"check\"><input type=\"checkbox\" id=\"a\" checked><span data-i18n=\"a\"></span></label><label class=\"check\"><input type=\"checkbox\" id=\"b\"><span data-i18n=\"b\"></span></label><label class=\"check\"><input type=\"checkbox\" id=\"c\"><span data-i18n=\"c\"></span></label><section class=\"panel\"><h3 data-i18n=\"records\"></h3><div id=\"records\"></div></section><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#records .tr{grid-template-columns:1fr 1.4fr 1.8fr}#records .tr>*{min-height:8em;white-space:pre-line}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}const remote=[['A',3478],['B',3478],['B',9999]];function draw(){const map=$('#mapping').value,filter=$('#filter').value,groups=new Map;['a','b','c'].forEach((id,i)=>{if($('#'+id).checked){const key=map==='ei'?'I':map==='ad'?remote[i][0]:remote[i].join(':');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(i)}});const rows=[['public','contacts','inbound']],result=[];[...groups].forEach(([key,seen],j)=>{const allowed=remote.map((r,i)=>filter==='ei'||seen.some(n=>remote[n][0]===r[0]&&(filter==='ad'||remote[n][1]===r[1])));rows.push([62000+j,seen.map(i=>remote[i].join(':')).join('\\n'),remote.map((r,i)=>r.join(':')+' '+(allowed[i]?'✓':'✗')).join('\\n')]);result.push({port:62000+j,seen,allowed})});while(rows.length<4)rows.push(['—','—','—']);table('records',rows);$('#records').dataset.snapshot=JSON.stringify(result);}document.querySelectorAll('input,select').forEach(e=>e.onchange=draw);draw();","audio":false,"strings":{"a":"A:3478","b":"B:3478","c":"B:9999","contacts":"已向这些端点发送","filter":"过滤行为","heading":"相同公网端口,为什么回包仍可能被挡?","intro":"分别选择映射和过滤规则,再选择内网主机联系过的远端。","mapping":"映射行为","records":"映射及可进入的远端","reset":"重置","scope":"固定一个内网UDP端点,三个远端A:3478、B:3478、B:9999;为不同映射分配不同示例端口。过滤对每个映射的实际联系记录计算,忽略超时和端口复用。","scopeLabel":"模型范围与约定"}}
STUN:服务器告诉你它看到的来源
Binding Request 经过 NAT 后,STUN 服务器看到转换后的源端点,在响应的 XOR-MAPPED-ADDRESS 中返回。STUN 固定头为 20 B,含消息类型、属性区长度、magic cookie 和事务 ID;后续属性有类型、长度及对齐规则。XOR 是编码方式,不是对地址保密的加密。RFC 8489:STUN
对于 IPv4 XOR-MAPPED-ADDRESS,端口与 cookie 高 16 bit 异或,地址与完整 32 bit cookie 异或;IPv6 的掩码还结合事务 ID。末尾代码会验证本例 203.0.113.10:62000 的往返编解码。
得到的是服务器在这次事务中看到的地址。若映射依赖目的,发往 B 的外部端点可能不同;若状态超时,旧地址也可能失效。测试必须使用实际通信所需的 socket 和上下文,不能随便开另一个端口探测后拿结果替代原 socket。
UDP 打洞为何要双方配合
A、B 通过应用自己的信令通道交换候选端点,并分别向对方候选发送检查包。出站包可能建立相应映射及过滤许可,后续对端包才有机会通过。首次检查丢弃并不必然失败,重试、时序和状态寿命都有关。
STUN 不负责替应用实现这条信令通道。打洞也不是“STUN 服务器转发后续全部数据”;直连建立后,应用数据可直接在两端之间流动。多层 NAT、不同映射行为、hairpin 支持和出站 UDP 限制都会影响结果。
TURN:明确区分中继两侧的协议
客户端先通过 Allocate 获取中继地址并维护 allocation,再为对端建立相应 permission。发送可用 Send Indication,建立 channel binding 后也可使用更紧凑的 ChannelData;它们不是服务器向普通对端原样转发的应用载荷。RFC 8656:TURN
B 向 relay 地址发普通 UDP,服务器按 permission 等条件接收,再通过 Data Indication 或 ChannelData 给 A。Data Indication 属于 TURN 服务器到客户端的一侧,不能放到普通 UDP 对端的一侧。客户端到 TURN 可以选择 UDP、TCP、TLS 等支持的承载,但不意味着普通 relay 对端也自动改用相同协议。
allocation、permission、channel binding 有各自寿命与刷新规则。凭据、配额、服务器资源和网络策略都会导致中继失败,因此 TURN 提供备用承载而非“永远能通”。TLS 到中继只保护对应链路;业务需要端到端保密时仍依赖相应端到端加密。
延迟与带宽怎样预算
假设单向 A→R=15 ms、R→B=20 ms,忽略排队和处理,则该方向中继路径约 35 ms;若反向恰好对称,RTT 约 70 ms。不能拿两个 RTT 相加再乘一个不明的 N 当作普遍公式,也不能保证中继一定比所有直连更慢。
若 A→B 应用载荷 2 Mbit/s、B→A 为 1 Mbit/s,中继需接收 3 Mbit/s、发出 3 Mbit/s,RX+TX 合计 6 Mbit/s,再加封装和协议开销。网卡、服务商计费与容量限制口径不同,应说明测的是入口、出口还是两者之和。
ICE:候选对要检查,也要提名
候选包括 host(本地端点)、server-reflexive(从 STUN 观察到)、relayed(TURN 分配),检查时还可能发现 peer-reflexive 候选。双方通过信令交换候选与 ICE 凭据,形成可比较的候选对,安排 STUN 连通性检查。RFC 8445:ICE
候选优先级常按 2²⁴×type_pref + 2⁸×local_pref + (256−component_id) 构造。type preference 是策略偏好,不是实测 RTT。检查成功产生可用候选对;controlling 角色通过 nomination 确定使用的对,controlled 角色配合,角色冲突也有处理规则。
候选对不只存在 host↔host、srflx↔srflx、relay↔relay 三种整齐组合。排序、检查调度、触发检查和提名构成完整流程,不能画成“所有直连完全失败之后才开始考虑任何中继”的固定串行阶梯。应用也可出于隐私或网络策略限制候选类型。
Trickle ICE 允许增量传递候选,让收集与检查重叠进行;不会免去认证、检查或提名。网络改变后还需相应重启或持续性机制,初次成功不保证候选永久可用。RFC 8838:Trickle ICE
Tailscale 作为实现案例
Tailscale 有直连与中继路径,官方文档还区分 DERP 和 peer relay。系统可先使用可用中继维持连接,同时尝试更合适的直连;中继通信和直连发现可以重叠进行。
DERP 也不是 TURN 的另一个名字;具体路径建立、WireGuard 加密承载和中继服务应按产品文档理解。随机端口分配会增加探测难度,但“随机就数学上绝不可能命中”同样不成立;不应靠无限端口试探代替可靠的中继设计。Tailscale 连接类型
诊断顺序:定位状态在哪一层丢失
| 已知现象 | 优先核对 |
| 本地 socket 建立失败 | 端口、绑定方式、FD/内存及错误码 |
| STUN 无响应 | 服务器可达、事务匹配、协议与过滤 |
| 有 srflx 但直连失败 | 相同 socket、目的相关映射、双向过滤、状态寿命 |
| TURN allocation 成功但无数据 | permission、channel、relay 地址和对端路径 |
| ICE checks 成功但应用无数据 | 提名结果、应用映射、加密状态和后续路径变化 |
IPv6 可减少 IPv4 地址转换需求,但有全局地址不代表防火墙允许入站;ICE 仍可用于选择和验证路径。端口映射工具则是在可管理的网关上明确请求映射,和观察 NAT 行为的 STUN 是不同做法。
本地算例:XOR 地址与容量
from ipaddress import IPv4Address
import struct
cookie = 0x2112A442
port = 62000
address = int(IPv4Address('203.0.113.10'))
value = struct.pack('!BBHI',0,1,port^(cookie>>16),address^cookie)
reserved,family,xport,xaddress = struct.unpack('!BBHI',value)
assert reserved == 0 and family == 1
assert xport^(cookie>>16) == port
assert str(IPv4Address(xaddress^cookie)) == '203.0.113.10'
assert value.hex() == '0001d322ea12d548'
assert 60999-32768+1 == 28232
assert 470 < 28232/60 < 471
def priority(type_pref,local_pref,component):
return (1<<24)*type_pref+(1<<8)*local_pref+256-component
assert priority(126,65535,1) == 2130706431
assert priority(126,65535,1)>priority(100,65535,1)>priority(0,65535,1)
assert 2*(2+1) == 6
print('STUN IPv4 XOR 属性、端口预算、ICE 优先级与中继流量检查通过')
示例不含完整 STUN 事务、认证或 ICE 状态机;未发起打洞、端口扫描、中继分配或网关配置。
继续查阅