全部文章

一个端口承载五种代理协议:MonoProxy 的共享中继架构

MonoProxy 如何在同一监听端口识别 HTTP、CONNECT 与 SOCKS,并处理响应顺序、背压、半关闭和安全边界。

代理网络工程iOSSOCKSHTTP
一个端口承载五种代理协议:MonoProxy 的共享中继架构

代理端口最先看到的是字节,不是协议。

这句话听上去没什么特别,却是“一个端口支持多种代理协议”最难处理的地方。新的 TCP 连接不会自带标签,告诉服务端自己是 SOCKS5 还是 HTTPS CONNECT。第一次读取可能只有一个字节,也可能已经包含完整握手,甚至握手后面还粘着应用数据。为了识别协议而多读了一点,后面的处理器就拿不到那些字节;判断得太早,分包到达的请求又会被误判。

MonoProxy 要在 iPhone 上解决这个问题,同时让监听器足够小、状态足够可控。最后形成的结构是:HTTP、HTTPS CONNECT、SOCKS4、SOCKS4a 和 SOCKS5 共用一个局域网端点,各自完成握手,然后进入同一套中继核心。

端口可以共享,状态不能混在一起

监听器通过 Apple Network.framework 接收 TCP 连接,再把连接交给串行的服务器队列。第一次读取只做很克制的判断:

Flow diagram Preparing diagram
View diagram source
flowchart LR
    A[新的 TCP 连接] --> B{首字节}
    B -->|0x05| C[SOCKS5 协商]
    B -->|0x04| D[SOCKS4 或 SOCKS4a 请求]
    B -->|其他内容| E[累积 HTTP 请求头]
    E --> F{请求方法}
    F -->|CONNECT| G[不解析内容的 TCP 隧道]
    F -->|HTTP 或 WebSocket| H[清理请求头后转发]
    C --> I[共享双向中继]
    D --> I
    G --> I
    H --> I

SOCKS 的版本字节没有歧义,因此不必先解析完整请求。其他数据进入 HTTP 路径,持续累积到出现完整的请求头分隔符。请求头缓冲上限是 64 KiB,初始请求还有 30 秒超时。这样做不是为了漂亮的数字,而是避免空闲或恶意客户端用“一次发一个字节”的方式长期占住内存。

更关键的是,协议识别只负责选择握手解析器,不会丢掉第一次读取的数据。SOCKS 解析器拿到原始字节后,只消费自己需要的字段,再把剩余数据继续向后传递。于是下面几种真实网络时序都能被正确处理:

  • SOCKS5 方法列表被拆成几次 TCP 读取;
  • 完整的 SOCKS4a 请求与第一段业务数据粘在一起到达;
  • CONNECT 请求头和 TLS ClientHello 的开头出现在同一次读取里。

TCP 是字节流,不是消息队列。把每次 receive 回调当作一个完整协议包,往往能做出看似正常的演示,却经不起真实网络时序。

各自握手,最后汇入同一个中继

每个协议处理器只做该协议独有的部分:

  • 普通 HTTP 在转发前移除代理专用头和逐跳头;
  • CONNECT 解析目标地址,连接目标端,再返回 200 Connection Established
  • SOCKS4/4a 解析 IPv4 地址,或解析用户 ID 后面的域名;
  • SOCKS5 先协商认证方式,可选校验用户名密码,再解析 IPv4、IPv6 或域名目标。

这些工作结束后,所有协议都会注册同一种“客户端连接 ↔ 目标连接”双向映射。

Sequence diagram Preparing diagram
View diagram source
sequenceDiagram
    participant C as 客户端
    participant P as MonoProxy
    participant T as 目标服务

    C->>P: SOCKS5 版本与认证方法
    P-->>C: 选择认证方法
    opt 已启用认证
        C->>P: 用户名和密码
        P-->>C: 认证结果
    end
    C->>P: CONNECT 目标地址
    P->>T: 建立 TCP 连接
    T-->>P: 连接就绪
    P-->>C: SOCKS 成功响应
    Note over P: 成功响应送达后才注册中继
    par 上行
        C->>P: 业务数据
        P->>T: 发送完成后继续读取
    and 下行
        T->>P: 响应数据
        P->>C: 发送完成后继续读取
    end

响应顺序不只是协议礼节。SOCKS 客户端必须先看到 SOCKS 成功响应,才能看到目标服务返回的任何字节。因此 MonoProxy 会等待成功响应的发送完成回调,再启动两个方向的中继,并刷新与握手一起到达的业务数据。

CONNECT 也有相似的竞态。有些客户端在 CONNECT 请求头后立刻发送 TLS ClientHello。MonoProxy 会先把客户端标记为隧道,把目标连接就绪前到达的字节放进有上限的暂存区,向客户端发送 HTTP 成功响应,最后才刷新暂存的 TLS 数据。顺序处理不严谨时,一次恰好“粘包”的 ClientHello 就可能被误当成第二个 HTTP 请求,或者在客户端收到隧道确认前被提前转发。

背压首先是正确性问题

大流量会暴露另一类问题。发送方很快、接收方较慢时,如果读取循环只顾着不断收数据,内存里会堆起越来越多的 Data。在手机上,这不只是效率差,而是可能直接结束进程。

MonoProxy 的中继每次读取有上限的数据块,发送给另一端,只有 Network.framework 确认发送已处理后才安排下一次读取。隧道单次读取上限为 256 KiB,普通 HTTP 响应使用 64 KiB 数据块。背压链路因此很直接:

Flow diagram Preparing diagram
View diagram source
flowchart LR
    A[读取有上限的数据块] --> B[记录流量]
    B --> C[发送给对端]
    C --> D{发送已处理}
    D -->|是| A
    D -->|错误| E[幂等清理]

流量统计也会批量发布。中继仍然记录每个字节,但界面不需要为每次网络回调刷新。否则负责“观察流量”的代码,反而会和真正的数据转发争抢资源。

连接进入协议解析之前还有容量控制:全局连接总量有上限,单个客户端也有可配置上限。拒绝事件会进入诊断指标,所以容量触顶不会只表现成一个让人摸不着头脑的网络失败。

这些机制并不把 iPhone 包装成数据中心代理,也不是吞吐量跑分。它们提供的是移动设备上更有价值的保证:内存增长有边界,不会让某一个客户端独占全部连接。

读到 EOF,不等于立刻关闭两端

隧道是全双工的。一端完成发送后,仍然可能等待另一端返回数据。如果第一次读到 EOF 就关闭两条连接,会截断完全合法的响应。某些协议还会主动使用半关闭,表示“请求体已经发完,但我还在等结果”。

MonoProxy 分别记录两个方向的完成状态:

State diagram Preparing diagram
View diagram source
stateDiagram-v2
    [*] --> 中继中
    中继中 --> 客户端半关闭: 客户端 EOF
    中继中 --> 目标端半关闭: 目标端 EOF
    客户端半关闭 --> 已关闭: 目标端 EOF
    目标端半关闭 --> 已关闭: 客户端 EOF
    客户端半关闭 --> 客户端半关闭: 继续排空目标响应
    目标端半关闭 --> 目标端半关闭: 继续排空客户端数据
    客户端半关闭 --> 已关闭: 排空超时
    目标端半关闭 --> 已关闭: 排空超时
    已关闭 --> [*]

某个方向完成输入后,中继只向对应输出发送 TCP FIN,反向通路继续保留。30 秒排空定时器会回收被遗忘的半关闭连接。清理逻辑必须幂等,因为连接状态回调、发送失败和定时器都可能同时发现同一条隧道已经失效。

这是用户不应该注意到的细节。大家通常只会注意到它的错误版本:下载在结尾前被截断,或者所有请求字节都已经发完,连接却一直挂着。

安全不应该从输入密码才开始

局域网代理不能假设当前网络里的每台设备都可信。MonoProxy 把控制拆成几层:

  1. 监听范围。 服务可以只留在回环地址;需要附近设备接入时,再绑定选定的网络接口。
  2. 客户端策略。 私有/本地地址限制以及 IPv4、IPv6 允许或阻止规则,会在连接进入协议处理前执行。
  3. 身份认证。 HTTP 和 CONNECT 使用代理认证;SOCKS5 支持用户名密码协商。凭据比较会走完整字节序列,不会在第一处不匹配时提前返回。
  4. 失败限速。 认证失败按客户端记录。60 秒内累计五次失败会阻止五分钟,记录表本身也有容量上限。
  5. 诚实面对协议边界。 SOCKS4 没有密码认证机制。启用认证后,MonoProxy 会拒绝 SOCKS4,而不是给用户一个并不存在的安全感。

Basic 代理凭据只是编码,不是加密,应当只在自己可控的网络中使用。HTTPS 内容本身仍然是端到端的不透明隧道:MonoProxy 不安装证书、不终止 TLS,也不解密加密流量。

公开的 MonoProxy 产品网站 介绍了面向用户的隐私边界。工程上的边界同样重要:校验长度、限制缓冲、让不完整状态超时,并让每一次拒绝都有可观察的记录。

测试应该盯住顺序,而不只是成功路径

集成测试专门覆盖字节流实现最容易出错的位置:分片到达的最大长度 SOCKS4 用户 ID、与握手粘在一起的业务数据、认证过程中客户端突然进入限速状态、HTTP/CONNECT/SOCKS5 混合循环、受控的连接突发、8 MiB 确定性 HTTP 负载,以及经过认证 SOCKS5 的 4 MiB 负载。

此外还有重复启停、全局容量拒绝、公开站点可达性和 HLS 分片传输场景。这些不是营销跑分。它们的意义是让定时器、连接回调和清理路径重叠发生时,中继依然必须保持响应顺序和字节完整性。

有意保留的边界

MonoProxy 当前实现 SOCKS 的 CONNECT 命令,不宣称支持 BIND 或 UDP ASSOCIATE。普通 HTTP 每个请求使用独立的目标连接,请求体需要明确长度;遇到有歧义的传输分帧时会拒绝,而不是猜测。HTTPS 和其他隧道流量保持不透明。

这些约束让共享端口模型仍然容易推理:监听器识别协议,握手建立目标,一套有边界的中继负责搬运字节,不假装理解字节里的内容。也正是这种分层,让五种代理形式可以共用一个端口,而不会把核心代码变成五套彼此分叉的服务器。

来自 MonoWare

MonoProxy 背后还有更多上下文。

打开产品网站,或继续阅读按 MonoProxy 筛选的笔记。

通讯

隐私与产品工程笔记,偶尔发送。