代理端口最先看到的是字节,不是协议。
这句话听上去没什么特别,却是“一个端口支持多种代理协议”最难处理的地方。新的 TCP 连接不会自带标签,告诉服务端自己是 SOCKS5 还是 HTTPS CONNECT。第一次读取可能只有一个字节,也可能已经包含完整握手,甚至握手后面还粘着应用数据。为了识别协议而多读了一点,后面的处理器就拿不到那些字节;判断得太早,分包到达的请求又会被误判。
MonoProxy 要在 iPhone 上解决这个问题,同时让监听器足够小、状态足够可控。最后形成的结构是:HTTP、HTTPS CONNECT、SOCKS4、SOCKS4a 和 SOCKS5 共用一个局域网端点,各自完成握手,然后进入同一套中继核心。
端口可以共享,状态不能混在一起
监听器通过 Apple Network.framework 接收 TCP 连接,再把连接交给串行的服务器队列。第一次读取只做很克制的判断:
This diagram could not be rendered. Its source is still available below.
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 或域名目标。
这些工作结束后,所有协议都会注册同一种“客户端连接 ↔ 目标连接”双向映射。
This diagram could not be rendered. Its source is still available below.
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 数据块。背压链路因此很直接:
This diagram could not be rendered. Its source is still available below.
View diagram source
flowchart LR
A[读取有上限的数据块] --> B[记录流量]
B --> C[发送给对端]
C --> D{发送已处理}
D -->|是| A
D -->|错误| E[幂等清理]
流量统计也会批量发布。中继仍然记录每个字节,但界面不需要为每次网络回调刷新。否则负责“观察流量”的代码,反而会和真正的数据转发争抢资源。
连接进入协议解析之前还有容量控制:全局连接总量有上限,单个客户端也有可配置上限。拒绝事件会进入诊断指标,所以容量触顶不会只表现成一个让人摸不着头脑的网络失败。
这些机制并不把 iPhone 包装成数据中心代理,也不是吞吐量跑分。它们提供的是移动设备上更有价值的保证:内存增长有边界,不会让某一个客户端独占全部连接。
读到 EOF,不等于立刻关闭两端
隧道是全双工的。一端完成发送后,仍然可能等待另一端返回数据。如果第一次读到 EOF 就关闭两条连接,会截断完全合法的响应。某些协议还会主动使用半关闭,表示“请求体已经发完,但我还在等结果”。
MonoProxy 分别记录两个方向的完成状态:
This diagram could not be rendered. Its source is still available below.
View diagram source
stateDiagram-v2
[*] --> 中继中
中继中 --> 客户端半关闭: 客户端 EOF
中继中 --> 目标端半关闭: 目标端 EOF
客户端半关闭 --> 已关闭: 目标端 EOF
目标端半关闭 --> 已关闭: 客户端 EOF
客户端半关闭 --> 客户端半关闭: 继续排空目标响应
目标端半关闭 --> 目标端半关闭: 继续排空客户端数据
客户端半关闭 --> 已关闭: 排空超时
目标端半关闭 --> 已关闭: 排空超时
已关闭 --> [*]
某个方向完成输入后,中继只向对应输出发送 TCP FIN,反向通路继续保留。30 秒排空定时器会回收被遗忘的半关闭连接。清理逻辑必须幂等,因为连接状态回调、发送失败和定时器都可能同时发现同一条隧道已经失效。
这是用户不应该注意到的细节。大家通常只会注意到它的错误版本:下载在结尾前被截断,或者所有请求字节都已经发完,连接却一直挂着。
安全不应该从输入密码才开始
局域网代理不能假设当前网络里的每台设备都可信。MonoProxy 把控制拆成几层:
- 监听范围。 服务可以只留在回环地址;需要附近设备接入时,再绑定选定的网络接口。
- 客户端策略。 私有/本地地址限制以及 IPv4、IPv6 允许或阻止规则,会在连接进入协议处理前执行。
- 身份认证。 HTTP 和 CONNECT 使用代理认证;SOCKS5 支持用户名密码协商。凭据比较会走完整字节序列,不会在第一处不匹配时提前返回。
- 失败限速。 认证失败按客户端记录。60 秒内累计五次失败会阻止五分钟,记录表本身也有容量上限。
- 诚实面对协议边界。 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 和其他隧道流量保持不透明。
这些约束让共享端口模型仍然容易推理:监听器识别协议,握手建立目标,一套有边界的中继负责搬运字节,不假装理解字节里的内容。也正是这种分层,让五种代理形式可以共用一个端口,而不会把核心代码变成五套彼此分叉的服务器。