切换深浅色
第 8 章 代理链与多级中转
“一条链上有两个节点”只说明路径上存在两个中间角色,还没有说明由谁发起第二次代理请求、哪一层在哪里解码、最终目标对哪个节点可见。本章先用已经介绍的 SOCKS5 与 HTTP CONNECT 分析这些问题,不提前依赖 VLESS 的具体格式。
8.1 单级与多级路径:先数连接,再数控制会话
单级 TCP 代理的基本路径是客户端 C、代理 A、目标 T。C—A 与 A—T 是两条独立 TCP。若再加入代理 B,物理数据路径可以变成 C—A—B—T,但控制结构至少有两种。
第一种由客户端驱动:C 请求 A 建立到 B 的通道,然后由 C 在通道内部与 B 协商,并请求 B 连接 T。A 只执行到 B 的代理请求;第二次代理协商由 C 产生、B 接收。A—B 上传递的字节属于 B 的代理协议,但 A 没有因此成为这个内层协议的客户端。
第二种由服务器转接:C 请求 A 连接 T;A 按自身路由决定不用直连,而是作为 B 的代理客户端,请求 B 连接 T。C 只需要理解与 A 的协议,A 负责生成对 B 的控制信息。相同的三个网络区间,具有不同的目标知情范围和身份材料接收者。
| 问题 | 客户端驱动的嵌套链 | A 主动转接给 B |
|---|---|---|
| C 向 A 表达的目标 | B 的服务端点 | 最终目标 T |
| 谁向 B 发代理控制请求 | C,经 A 的通道传送 | A |
| A 是否必须知道最终目标 | 本层请求不要求它知道 | 必须处理 C 提交的 T |
| B 看到的传输对端 | A 的出口端点 | A 的出口端点 |
| B 的认证材料由谁提供 | C 的内层代理客户端 | A 的出站客户端 |
这里“本层请求不要求知道”是协议语义,不是保密保证。如果 C—B 的内层协议是明文 SOCKS5,A 转送时仍然能够检查其中目标字节;只有另外的端到端保护,才可能把内层内容隐藏于 A。不能把控制角色上的分离直接提升为对恶意中间节点的保密性质。
对于上述普通 TCP 路径,网络中有三段连接:C—A、A—B、B—T。客户端逻辑上得到“到 B 的流”和“到 T 的流”,但前者可能由第一段和第二段共同承载,而非 C 与 B 之间另有一条原生 TCP。逻辑流、物理传输连接和代理请求数是三个计数对象。
正在绘制图表…
查看图表原文
flowchart LR
C[客户端 C] -->|TCP 一| A[第一跳 A]
A -->|TCP 二| B[第二跳 B]
B -->|TCP 三| T[目标 T]
沿箭头前进,每次建立下一跳都需要可用的上游服务能力。若第二跳要求 UDP 通道,第一跳只提供 TCP CONNECT,就不能直接满足它;必须有明确的其它承载或转换。若第二跳只需要可靠字节流,则第一跳可以提供这个抽象,即使它的协议头完全不同。
增加节点也不会把所有路径都固定成同一个出口。目标查询由 B 建立连接时,网站通常看到 B 的实际出口;但 C 为连接 A 发出的前置解析、A 为寻找 B 发出的查询,仍可能经过不同网络。分析代理链要逐项追踪控制连接与业务连接,不能只凭一次 IP 查询结果重建全链。
本章的 HTTP 隧道和下一跳转发语义依据 RFC 9110 §9.3.6,SOCKS 寻址与转发依据 RFC 1928 §4—7。路径表是按这两种已定义服务作的机制推导,不宣称任意代理软件都自动提供两种控制架构。
8.2 协议终止与嵌套:并不是每段数据都套着所有请求头
假设 A 是 HTTP 代理,B 是 SOCKS5 服务。客户端先向 A 发送 CONNECT proxy.example:1080;A 成功后,客户端在隧道中向 B 发送 SOCKS5 方法列表,再发送请求最终目标的 SOCKS5 CONNECT。这个过程经常被简画成“HTTP 包 SOCKS 包网站数据”,但那幅静态图容易让人误以为每个业务数据块都重复两个请求头。
传统 CONNECT 在建立阶段消费 HTTP 请求和成功响应,随后连接切换为目标流。本例目标正是 B,所以此后客户端发出的 SOCKS5 字节会被 A 当作隧道负载送到 B。SOCKS5 同样在建立阶段消费自己的控制消息,成功以后继续转送目标业务字节。两种一次性控制格式都不必在每个后续业务块前再次出现。
| 时间阶段 | 客户端发出的对象 | A 的解释 | B 的解释 |
|---|---|---|---|
| 建立到 B 的通道 | HTTP CONNECT | 解析目标 B,准备隧道 | 尚未处理这条请求 |
| B 的方法阶段 | SOCKS5 方法消息 | 原样隧道字节 | 选择方法与执行子协议 |
| B 的目标请求 | SOCKS5 CONNECT | 原样隧道字节 | 解析目标 T |
| 业务阶段 | T 的应用字节 | 原样隧道字节 | 已建流中的应用字节 |
A 终止的是外层 HTTP 代理控制,B 终止的是内层 SOCKS 控制。目标 T 不应收到任何一个代理控制头。客户端要维护各阶段的解析器:先消费 A 的 HTTP 成功响应,之后处理 B 的 SOCKS 消息,最后把 B 成功响应之后的字节交给目标应用。
这里的“嵌套”描述服务依赖:第二个会话的传输通道由第一个会话提供。它不要求所有已消费的头部一直附着在后面的字节上。相反,TLS、WebSocket 或其它有持续记录、帧结构的承载,会在数据阶段继续产生本层封装;是否有逐记录开销必须查具体格式,不能把一次请求头和持续封装混为同一项。
接收顺序还可能与 Socket 分块交错。若 A 的目标服务 B 会主动产生某种字节,A 的成功响应与这些字节可以一次到达客户端。客户端先按 HTTP 消费到正确边界,再把剩余范围交给 B 的会话解释器。两个解释器不应各自直接抢读同一底层 Socket,否则一个解释器可能读走另一层需要的字节,造成难以重现的阶段错误。
在上行,客户端也应在 A 的隧道成功后才按所选路径启动 B 的协议。某些实现允许提前数据,但那需要单独检查接受与失败语义。为了理解正确性的基本条件,没有必要把未定义的提前字节当成普遍优化。
若 C—B 另外采用安全通道,其握手也在 A 的隧道内发生;A 看到的是这个内层通道的传输表示。B 终止内层安全通道,再解释其中代理请求。若 A 自己也要求安全连接,则还有 C—A 的外层保护。在密码学深入之前,只需准确标出哪一个节点终止哪一层,不用提前把所有层笼统称作“双重加密”。
服务器转接的路径不同。A 先完整解释 C 的目标请求,再主动生成一份发给 B 的新请求;这份请求可以使用不同格式、不同用户身份和不同目标表示。它是语义转换,不是把 C 的原始控制头一直封在下游。识别这两种方式,是判断某个参数究竟在改变承载还是改变出站路由的基础。
8.3 客户端代理链与服务端转发:谁拥有下一跳决策
客户端驱动的链把后续节点选择放在客户端。C 知道 A 与 B 的服务端点和必要身份材料,先取得通向 B 的流,再用自己的 B 协议客户端请求最终目标。A 的配置可以仅支持到达 B,不必理解最终出站的用户或规则。若 C 把 B 换成另一台同类服务,A 只需允许新的隧道目标。
服务端转发则把下一跳选择放在 A。C 只请求最终目标,A 按路由决定直连、通过 B,或使用另一条路径。A 作为 B 的客户端,持有适用于 B 的身份材料;C 不一定知道 B 的存在。更换 B 可能只改变 A 的出站配置,而不改变 C 的控制请求。
这不是谁更“高级”的区别,而是管理边界与信任边界不同。客户端链允许用户控制后续节点和内层保护;服务端转发让集中入口统一处理策略与运维。前者让客户端承担更多依赖和连接建立状态,后者让中转服务承担更多目标信息与凭据管理责任。
| 情况 | 客户端嵌套 | 服务端转接 |
|---|---|---|
| B 拒绝身份 | C 的 B 会话直接得到其结果 | A 得到结果,再映射给 C |
| B 返回私有错误细节 | C 可按 B 协议读取 | C 只看到 A 能表达和愿意传递的信息 |
| 路由 T 的决策者 | C 选择 B;B 决定其出口 | A 决定是否经 B;B 决定其出口 |
| B 的凭据更新 | 更新 C 持有的内层材料 | 更新 A 的出站材料 |
| A 崩溃 | 经 A 的内层会话失去通道 | A 管理的入站和出站会话受影响 |
失败结果转换尤其值得留意。假设 C—A 使用 SOCKS5,A—B 使用 HTTP CONNECT。B 返回 407,A 可以记录这是上游认证失败,但不能把整条 HTTP 错误响应当作 SOCKS5 成功后的目标字节发给 C。A 必须在 C 所处的协议阶段,用适当的 SOCKS5 失败表达或连接失败处理。映射通常会损失细节,不能只根据 C 的通用错误还原 B 的全部原始状态。
成功结果也不该无条件提前。若 A 在 B 尚未完成目标请求时就回复 C 成功,它改变了 C 看到的承诺:C 开始发送目标业务,随后才可能发现下游失败。这种设计可能用于特定优化,但必须分析提前数据缓存、失败传播和客户端行为,不能默认为普通规范的自然结果。本书的基本案例等下游请求真正成功,再向上游报告成功。
服务端“转发”也可能指完全不同的三层路由或 NAT。若 A 只是把 IP 包路由到 B,并没有终止 C 的代理协议,那么它不属于上面“解析 T 后重新生成代理请求”的控制结构。文章里都称作“中转”的拓扑,可能分别是端口转发、IP 隧道、客户端嵌套或应用代理转接;需要从实际输入输出对象识别。
代理组的选择又是实现层的一项决策。组名本身不在通用 SOCKS5 或 CONNECT 报文中;它可以在发起出站前解析成具体节点。一个组在不同时间选中不同节点,通常影响新建连接;已有连接是否迁移、是否断开,要看承载与实现,不能从“动态选择”推导所有活跃流都会即时换路。
于是一个实用的分析方法是逐跳记录三个主体:谁提出目标、谁选择下一跳、谁持有该跳凭据。只要其中一个发生变化,安全审查、日志解释和故障责任就可能改变,即使路径图上的 IP 顺序完全相同。
8.4 dialer-proxy:把“连接服务器”交给另一个出站
Mihomo 的 dialer-proxy 经常被理解成“再加一个节点”。更准确的机制是:一个出站需要连接自己的服务端点时,使用指定出站提供的拨号能力。业务目标仍由原出站的代理协议表达,底层到该出站服务器的连接改走另一条路径。
本节的实现判断固定在 Mihomo 提交 88dcbf7f1614a67c3b36b848ee3592dfa92ada36,提交时间 2026-09-30,读取的是 Meta 分支该提交,不把默认 main 的另一份历史当成同一版本。官方 dialer-proxy 文档 用于配置语义;下面的调用关系来自实际源码。
考虑仅展示相关出站和路由的配置片段,地址均为文档值,没有真实凭据:
yaml
proxies:
- name: entry-A
type: socks5
server: 198.51.100.10
port: 1080
- name: exit-B
type: socks5
server: 203.0.113.20
port: 1080
dialer-proxy: entry-A
rules:
- MATCH,exit-B路由选择 exit-B,不意味着操作系统首先直连 B。B 出站先要获得通向自己服务器 203.0.113.20:1080 的流;其拨号器由 entry-A 提供,于是先通过 A 请求连接这个端点。获得流后,B 出站才在其中执行自己的 SOCKS5 协商和最终目标请求。网络路径因此是本机→A→B→目标,配置中的 dialer-proxy 值是先经过的节点,而非最终出口。
| 调用位置,均在上述固定提交 | 实际职责 |
|---|---|
adapter/outbound/base.go,BasicOption.NewDialer | 未设置内部 API 拨号器时,非空 DialerProxy 构造命名代理拨号器,否则构造普通拨号器 |
component/proxydialer/byname.go,DialContext | 根据名称取得代理对象,将请求交给代理拨号器 |
component/proxydialer/proxydialer.go,DialContext | 把本次待连接地址放入新的内部目标元数据,调用指定代理的 DialContext |
adapter/outbound/socks5.go,Socks5.DialContext | 先用拨号器连接自身 ss.addr,再执行 StreamConnContext 处理本出站协议 |
同文件 StreamConnContext | 如配置需要,先处理本出站 TLS;再按业务元数据执行 SOCKS CONNECT |
这里最关键的是两份元数据:A 收到的目标是 B 的服务端点;B 的 SOCKS 请求表达的是原业务目标。不能把前者解释成“把业务改成访问 B 网站”,也不能把两层用户名密码混为同一个认证。
源码还限定了结论范围。BasicOption.NewDialer 里内部 DialerForAPI 的优先级高于配置选择,因此“只要写了 dialer-proxy 就绝对走它”并非完整调用条件。该字段定义的注释也明确区分配置所在对象:出站可用组名作为拨号对象,不表示可以把同一字段任意放到所有代理组上。
UDP 使用另外的接口路径。命名拨号器有 ListenPacket 分支,代理拨号器会调用下层代理的数据报接口;SOCKS5 的 UDP 出站还会建立控制关联及数据通道。于是 TCP 链路能工作,并不证明某个 UDP 型出站在同一组合下必然工作。本节没有对所有出站、所有模式作兼容性验收,不能用一个抽象拨号器接口代替逐路径检查。
此参数也不自动接管订阅下载、每类 DNS 查询或全部系统流量。官方文档对经特定 SOCKS 访问外网的例子也提醒,其它控制流程可能需要另行安排。这与前面“业务出站连接”和“建立业务路径所需的前置活动”分开分析的原则一致。
最后,本例内层 SOCKS5 未加密,A 可以观察流中的 B 请求。若需要把最终目标内容隐藏于 A,必须在本机到 B 之间建立适用的保护通道并验证端点,而不是仅设置 dialer-proxy。它改变建立连接的方法,不单独增加密码学安全性质。
固定源码入口:base.go、byname.go、proxydialer.go、socks5.go。
8.5 多跳 DNS 与可见性:把“不参与解析”和“看不到”分开
客户端链中,第一跳可能只收到第二跳的地址;最后一跳才收到最终域名。这个结构能够减少某一层必须处理的信息,但是否真能隐藏信息,还要看传输表示是否受保护、解析请求走哪条路,以及观察者是否控制其它环节。
设 C 经 A、B 访问 www.example.com,B 的服务名称为 proxy.example。C 首先需要得到 A 的连接地址;A 若收到域名形式的 B 目标,还需要解析 proxy.example;B 收到最终域名后,再为 www.example.com 选择地址。三次选址分别支持不同的连接,不应合并称为“DNS 在远端”。
| 名称的用途 | 一种可能的解析位置 | 查询支持的连接 |
|---|---|---|
| A 的服务名称 | C 的前置解析系统 | C—A |
| B 的服务名称 | A,若 C 把名称交给 A | A—B |
| 最终网站名称 | B,若内层请求保留名称 | B—T |
客户端也可以预先解析 B,把数值地址交给 A;这样 A 不需要为 B 发 DNS,却仍知道自己连向哪个 IP。若 C 还预先解析最终目标,那么 B 只得到 IP 请求,名称查询可见性转到了 C 使用的解析服务。解析责任转移和解析结果已经选定是两种不同输入,都会改变实际路径。
进一步假设 C—B 的内层代理协议受独立且验证正确的安全通道保护,A 仅为它提供流;网站也使用自己的端到端安全会话。在这个特定模型中,可以列出直接处理的信息:
| 主体 | 必须或可以直接处理的信息 | 不由本模型自动提供的信息 |
|---|---|---|
| C | A、B、最终目标与所用凭据 | 无需知道每个中间网络节点 |
| A | C 的连接来源、B 的网络端点、内层密文及元数据 | 不因转送而取得受保护的最终请求明文 |
| B | A 的连接来源、C 对 B 提供的身份、最终目标 | 不自动取得网站安全会话的 HTTP 明文 |
| 网站 T | B 的出口端点及自身应用会话 | 不从 TCP 对端直接得知 C 的原始地址 |
这里的“不自动取得”不是不可推断、不可关联的证明。A 与 B 若共享观测,可以尝试按时间、大小和连接特征关联;网站可通过账户、Cookie 或应用自己发送的数据识别用户;客户端或代理主机若被控制,攻击能力又不同。链路上的名称隐藏不能替代完整匿名性分析。
如果去掉 C—B 保护,内层明文 SOCKS 请求经过 A,A 就可以解析出最终域名,即使它的正常转发函数没有读取该语义。是否被程序主动解释,不决定控制该主机的人能否观察字节。同样,若只保护 C—A,A 终止外层后仍取得它转发的内层明文。
DNS 服务还可能成为路径之外的信息汇合点。C、A、B 若都使用同一解析提供者,其可见信息范围取决于来源、查询内容和服务端数据;加密查询保护对应链路,不使服务端失去名称。缓存又可能让一次业务连接没有即时查询,不能只靠“这次没看到DNS包”证明没有前置解析。
因而可靠的隐私陈述应写成条件句:某个攻击者位于哪一段、能看到哪些包、是否控制某个终点、哪些层受保护,结论才是它能直接读取什么,仍能观察什么。DNS 的路径独立性与服务端风险依据 RFC 9076 §5—6;本节表格是在明确模型上的推导,不是多跳配置自带匿名性证明。
8.6 完整双跳实例:哪些字节在哪一跳消失
设客户端 C 通过两个无认证 SOCKS5 服务访问一个 HTTP 目标。A 位于 198.51.100.10:1080,B 位于 203.0.113.20:1080,最终目标为 example.com:80。本例所有地址与字节均为构造,网站名称的示例选址不代表真实公共 DNS 结果;无认证、无加密只用于把格式完全展示,不是部署建议。
C 先建立到 A 的 TCP,按以下顺序完成外层会话:
| 方向 | 字节 | 含义 |
|---|---|---|
| C→A | 05 01 00 | 提供无认证 |
| A→C | 05 00 | 接受无认证 |
| C→A | 05 01 00 01 cb 00 71 14 04 38 | 请求连接 B 的 IPv4 与 1080 端口 |
| A→C | 05 00 00 01 c6 33 64 0a a0 28 | 成功,报告本例 A 的下游绑定端点 |
cb 00 71 14 是 B 的 203.0.113.20,04 38=1080。A 到 B 的 TCP 建立后,A 只须转送后续目标流;它不会替 C 自动生成 B 所需的方法列表。
C 随后在这条流中与 B 交互:
| 逻辑方向,均经 A | 字节 | 含义 |
|---|---|---|
| C→B | 05 01 00 | B 会话的方法列表 |
| B→C | 05 00 | B 接受无认证 |
| C→B | 05 01 00 03 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d 00 50 | 请求最终域名的 80 端口 |
| B→C | 05 00 00 01 cb 00 71 14 a4 10 | B 连接成功,报告本例绑定端点 |
B 使用自己的解析环境选择 T,再建立 B—T。C 消费 B 的完整成功响应后,才发送前文的 37 字节 HTTP 请求,并最终收到 40 字节 HTTP 响应。A 的 SOCKS 解析器不会在 B 方法列表到达时再次启动;B 的解析器不会把 HTTP 的 GET 当第三次 SOCKS 请求。
正在绘制图表…
查看图表原文
sequenceDiagram
participant C as 客户端
participant A as 第一跳 A
participant B as 第二跳 B
participant T as HTTP 目标
C->>A: A 的方法协商与连接 B 请求
A->>B: 建立 TCP 到 B
A-->>C: A 的成功响应
C->>A: B 的 SOCKS 方法消息
A->>B: 原样转送
B-->>A: B 的方法选择
A-->>C: 原样转送
C->>A: B 的 CONNECT 最终目标请求
A->>B: 原样转送
B->>T: 选址并建立目标 TCP
B-->>A: B 的成功响应
A-->>C: 原样转送
C->>A: HTTP 请求
A->>B: 同一请求字节
B->>T: 同一请求字节
T-->>B: HTTP 响应
B-->>A: 同一响应字节
A-->>C: 同一响应字节
把各段连接的应用层字节相加,可以看见控制消息究竟在哪里被消费:
| 连接区间 | 上行组成 | 上行字节 | 下行组成 | 下行字节 |
|---|---|---|---|---|
| C—A | A 方法3+A请求10+B方法3+B请求18+HTTP37 | 71 | A选择2+A响应10+B选择2+B响应10+HTTP40 | 64 |
| A—B | B方法3+B请求18+HTTP37 | 58 | B选择2+B响应10+HTTP40 | 52 |
| B—T | HTTP请求 | 37 | HTTP响应 | 40 |
外层 A 的控制开销是 3+10+2+10=25 字节,B 的控制是 3+18+2+10=33 字节,目标 HTTP 共 77 字节,所以 C—A 累计 25+33+77=135 字节。A—B 累计 110,B—T 累计77。它们是不同网络区间的应用层统计,不能把135、110和77相加后称作单次用户内容大小;若研究总网络运输成本,可以跨区间累加,但必须明确那是按链路重复运输后的另一种指标。
A 的首个成功只确认通向 B 的连接,B 可能还没接受方法,更没有连接 T。C 如果把 A 成功当作整个链成功,就会过早把 B 的控制响应当 HTTP 数据。完整链的建立时间由多个依赖阶段构成;不能因为所有控制消息都短,就把它们当作可以无条件同时发送。
本例刻意没有隐藏内层请求,因此 A 通过观察转送的字节能够读出最终域名。若在 C—B 之间插入经过验证的安全层,A—B 上的业务表示会改变,以上明文字节数就不再是实际运输量;需要重新计算安全握手与记录。拓扑保持一样,不代表字节开销、安全边界和处理路径也一样。
8.7 多跳故障与等待:一条慢路径不只增加一次 RTT
双跳的每个阶段都可能失败。C 无法连接 A,与 A 无法连接 B,是两种连接失败;B 不接受方法,与 B 解析最终目标失败,又分别属于不同控制状态。先识别最后一个真正完成的阶段,比先猜测“哪个节点不稳定”更准确。
| 已完成的最后事件 | 后续仍未证明的事情 |
|---|---|
| C—A TCP 建立 | A 接受代理方法和请求 |
| A 的方法成功 | A 能连到 B |
| A 的 CONNECT 成功 | B 接受身份与最终目标 |
| B 的方法成功 | B 可以解析并连接 T |
| B 的 CONNECT 成功 | 网站 TLS 或应用请求成功 |
| 网站 TLS 验证成功 | 网站返回所需业务内容 |
错误还要保留所属方向和控制层。若 B 的认证消息截断,C 的外层通道可能仍健康;若 A—B 被复位,B 的内层会话失去传输,通常只能报告流失败,不能凭空推断 B 认证失败。代理进程日志里“连接建立”也要看指哪一条连接,不能把 C—A 的日志当作 B—T 的证据。
建立时间可以用前节的明确模型推导。设三段路径各自 RTT 为 rCA、rAB、rBT;路径近似对称,忽略处理、排队、序列化和丢包,没有复用、TCP Fast Open、提前数据或安全握手。C 已知 A、B 的数值地址,只有 B 解析最终域名,耗时为 dDNS。每一步严格等待前一步成功:
| 阶段 | 近似等待 |
|---|---|
| 建立 C—A TCP | rCA |
| A 方法协商 | rCA |
| A 连接 B 并返回成功 | rCA+rAB |
| 经 A 与 B 协商方法 | rCA+rAB |
| B 解析目标、连接 T 并返回成功 | rCA+rAB+dDNS+rBT |
所以到完整链成功的时间约为:
这是本例的等待依赖之和,不是任意双跳代理的固定公式。它说明内层一次逻辑往返已经跨过多段路径,不能先把每段 RTT 相加,再漏掉多轮协商对这些路径的重复使用。若有 TLS、更多认证轮次、连接复用或不同控制架构,需要重新画事件链;不能只在本式后随手加一个统一常数。
链建立后发送 HTTP 请求并收到响应,又有目标业务自己的往返与处理时间。吞吐阶段的瓶颈也不同:各段有独立拥塞状态与接收缓存,链上较慢的一段限制长期可持续速率。其余段暂时更快,只会让中间缓冲积压;更多中转不会创造缺失的带宽。
超时应与整条依赖一致。如果外层通道允许很长空闲时间,而 C 在等 B 的认证只给很短总时限,客户端仍可能先取消;反过来,B 正在慢速解析时,A 的空闲策略也可能结束通道。一个连接上存在多个计时器并不矛盾,它们由不同角色施加,但需要明确取消怎样向下传播与释放资源。
重试则会重新生成部分或全部路径。控制阶段未成功与业务已提交后的重试不同;如果应用请求已经到达 T,却在返回途中断开,直接重发可能让目标执行两次。代理层能重建连接,不代表能够证明业务“尚未执行”。后文 HTTP 的方法语义会说明何时重复请求具有额外风险;此处先保留这个跨层不确定性。
因此,故障分析的输出应是一条证据链:哪一跳的什么状态已经确认,哪一个结果码来自哪个协议,哪部分只观察到超时或流关闭,哪些原因还无法区分。把不确定性留在正确位置,比把一个通用连接错误强行命名为目标封锁或认证错误更严谨。
8.8 从路径图到依赖图:避免看得见出口、建不起通道
路径图描述数据经过哪些节点,依赖图描述建立某项能力之前还需要哪些能力。两者不同,尤其在启动、解析、健康检查和路由选择时。业务路径画成 C→A→B→T 很清楚,也仍可能因为前置依赖无法满足而永远建不起来。
设 A 的地址需要 DNS 查询,查询又被配置为必须经过 A;但建立 A 的通道之前还不知道它的地址。这是启动依赖环。若缓存里恰好还有 A 的旧地址,暂时可能工作;缓存过期、网络变化或首次运行时,环才暴露。解决原理是提供不依赖尚未建立通道的可信引导来源,或调整相应流程的依赖,而不是无限重试同一操作。
拨号链也可能成环。A 的出站拨号依赖 B,B 又依赖 A,没有任何可先建立的底层路径。实现也许会检查并拒绝,也许在运行时失败;是否有检测要查具体版本。就架构而言,没有基础出站或可用既存通道的闭环,并不会因为节点名称都合法就自动成立。
正在绘制图表…
查看图表原文
flowchart TD
AddressA[A 的可用连接地址] --> TransportA[建立到 A 的传输]
TransportA --> TunnelB[通过 A 建立到 B 的流]
TunnelB --> AuthB[B 的方法与身份阶段]
AuthB --> Target[B 解析并连接目标]
Target --> App[目标应用会话]
这个简化依赖图有明确起点,每条边表示后项依赖前项。真实配置可以有多个解析来源、多个候选节点和回退路径,但仍应能够说明在没有缓存和既存连接时,哪一项首先可用。健康检查如果采用另一条路线成功,也不能证明图中的全部业务依赖都成立。
连接池和多路复用会改变依赖的发生频率。已有 C—B 通道可能免去若干底层重建,但并非所有目标都共享同一条原生 TCP;代理协议可能在已有外层里建立新的逻辑流。共享层一旦失败,多个业务同时受影响;独立连接则隔离部分故障,同时付出更多建连和状态成本。没有一种复用开关能同时无条件消除所有成本与共享风险。
权限也应沿依赖图定位。第一跳只获准连第二跳,不等于第二跳只能访问一个目标;最后一跳拥有访问范围较大的出口权限,也不意味着最初用户应该自动取得全部权限。客户端身份、每跳凭据和最终地址策略可以在不同节点收紧,应明确哪个判断对谁有效。
对于维护者,完整的架构描述不只是节点列表,而是能回答:在没有现成通道时如何启动,地址从哪里来,目标在哪里恢复,哪个请求由谁认证,成功由谁确认,哪个层失败会终止哪些业务,何时可以安全重试。前面的字节案例让这些问题不再停留在概念图,而能落到具体控制消息与剩余流。
第 8 章涉及的通用机制仍以 SOCKS5 和 HTTP CONNECT 的规范为准;Mihomo 参数只对 8.4 标明的固定调用路径作实现结论。后续 HTTP/1.1、WebSocket、HTTP/2 与 QUIC 将提供不同的承载服务,把“取得一条流”进一步拆成消息、帧、流量窗口和共享连接,代理链的层次分析也将继续适用。