这是 Relay Observatory 代理项目实现系列的第二篇。完整代码放在 GitHub。
上一篇实现了 Go HTTP 正向代理。SOCKS5 与 HTTP 代理不同:它不解析 HTTP 文本,而是在 TCP 连接上交换紧凑的二进制报文,因此可以代理 SSH、数据库协议和其他 TCP 应用。
SOCKS5 是一段状态机
一条 SOCKS5 连接依次经过:
TCP Accept
↓
认证方式协商
↓
用户名密码认证
↓
读取命令与目标地址
↓
CONNECT / BIND / UDP ASSOCIATE
↓
数据转发与流量统计
每一步都依赖前一步完成,不能把它当成普通 HTTP Handler。
第一步:协商认证方式
客户端首先发送:
+-----+----------+----------+
| VER | NMETHODS | METHODS |
+-----+----------+----------+
| 1 | 1 | 1~255 |
+-----+----------+----------+
例如:
05 01 02
含义是:
05:SOCKS5;01:提供一种认证方式;02:用户名密码认证。
读取固定长度协议字段时,应使用 io.ReadFull:
header := make([]byte, 2)
if _, err := io.ReadFull(client, header); err != nil {
return err
}
methods := make([]byte, int(header[1]))
if _, err := io.ReadFull(client, methods); err != nil {
return err
}
不能假设一次 Read 就会拿到完整报文。TCP 是字节流,一次写入可能被拆成多次读取,也可能与后续数据一起到达。
服务端选择 0x02:
05 02
如果客户端没有提供支持的方法,则返回:
05 FF
FF 表示“所有认证方式都不接受”。
RFC 1929 用户名密码认证
用户名密码认证不是 RFC 1928 主协议的一部分,而是 RFC 1929 扩展:
+-----+------+----------+------+----------+
| VER | ULEN | UNAME | PLEN | PASSWD |
+-----+------+----------+------+----------+
| 1 | 1 | 1~255 | 1 | 1~255 |
+-----+------+----------+------+----------+
这里的版本是 01,不是 SOCKS 版本 05。
认证成功返回:
01 00
失败返回:
01 01
生产实现不应保存明文密码。项目把用户存入 SQLite,并使用 bcrypt:
identity, ok := authenticator.Authenticate(
username,
string(passwordBytes),
)
需要注意:RFC 1929 只定义了密码格式,没有提供传输加密。客户端到 SOCKS5 服务端之间如果是不可信公网,密码仍可能被窃听,应再套 VPN 或 TLS。
第二步:解析通用请求
认证成功后,三种命令共享同一报文结构:
+-----+-----+-----+------+----------+----------+
| VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+-----+-----+-----+------+----------+----------+
| 1 | 1 | 1 | 1 | Variable | 2 |
+-----+-----+-----+------+----------+----------+
CMD:
| 值 | 命令 |
|---|---|
01 | CONNECT |
02 | BIND |
03 | UDP ASSOCIATE |
ATYP:
| 值 | 地址 | 长度 |
|---|---|---|
01 | IPv4 | 4 字节 |
03 | 域名 | 1 字节长度 + 域名 |
04 | IPv6 | 16 字节 |
端口固定为两字节大端序:
port := binary.BigEndian.Uint16(portBytes)
把地址解析抽成 endpoint 后,TCP 请求和 UDP 报文可以复用同一套编码器:
type endpoint struct {
Host string
Port uint16
}
func (e endpoint) Address() string {
return net.JoinHostPort(e.Host, strconv.Itoa(int(e.Port)))
}
net.JoinHostPort 很重要,它会正确处理 IPv6 的方括号:
2001:db8::1 + 443
=> [2001:db8::1]:443
CONNECT:代理主动连接目标
CONNECT 与 HTTP CONNECT 的隧道阶段类似:
upstream, err := dialer.DialContext(
context.Background(),
"tcp",
target.Address(),
)
连接成功后回复:
+-----+-----+-----+------+----------+----------+
| VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+-----+-----+-----+------+----------+----------+
其中 REP=00 表示成功,BND.ADDR/BND.PORT 是代理用于这条上游连接的本地地址。
之后启动两个 io.Copy:
client --------上传--------> upstream
client <-------下载--------- upstream
CONNECT 适用于绝大多数 TCP 场景,也是 curl 的 socks5h:// 主要使用的命令。
BIND:为什么必须返回两次
BIND 的连接方向相反。代理不主动连接目标,而是创建临时监听端口:
客户端 ----控制连接----> SOCKS5 代理
目标端 ----反向连接----> 代理临时端口
因此协议需要两次响应。
第一次响应
代理调用:
listener, err := net.ListenTCP(
"tcp",
&net.TCPAddr{IP: localIP, Port: 0},
)
端口 0 表示让操作系统分配空闲端口。第一次响应把这个监听地址告诉客户端:
REP=00, BND.ADDR=代理地址, BND.PORT=临时端口
等待并校验目标
客户端在请求中提供的 DST.ADDR/DST.PORT 表示预期连接者。代理不能接受第一个随便连入的连接,否则临时端口可能被第三方抢占。
校验逻辑包括:
- 请求端口非零时必须匹配来源端口;
- 请求 IP 非
0.0.0.0/::时必须匹配来源 IP; - 请求是域名时解析全部 IP 后比较;
- 不匹配的连接关闭并继续等待;
- 超过 30 秒返回超时。
第二次响应
目标通过校验后,代理再次返回成功,这次 BND.ADDR/BND.PORT 是目标的真实来源地址。
客户端只有收到第二次响应后,才能开始把 BIND 连接当成普通 TCP 隧道使用。
UDP ASSOCIATE:TCP 控制,UDP 传数据
UDP ASSOCIATE 最容易误解。它不是把 UDP 塞进 TCP,而是:
TCP:客户端 -------- 会话生命周期 -------- SOCKS5 代理
UDP:客户端 ---- SOCKS5 UDP 报文 ----> 代理 ---- 原始 UDP ----> 目标
UDP:客户端 <--- SOCKS5 UDP 报文 ----- 代理 <--- 原始 UDP ----- 目标
客户端发送 UDP ASSOCIATE 后,代理创建 UDP socket,并通过 TCP 回复它的地址。
TCP 控制连接必须保持打开。一旦 TCP 断开,代理立即关闭对应 UDP socket:
go func() {
_, _ = io.Copy(io.Discard, controlConnection)
_ = relaySocket.Close()
}()
这个 TCP 连接没有业务数据,只承担“租约”作用。
SOCKS5 UDP 报文格式
客户端不能把原始 UDP payload 直接发给代理,因为代理不知道目标地址。每个数据报都要带 Header:
+------+------+------+----------+----------+----------+
| RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA |
+------+------+------+----------+----------+----------+
| 2 | 1 | 1 | Variable | 2 | Variable |
+------+------+------+----------+----------+----------+
例如把 hello udp 发到 127.0.0.1:53:
00 00 | 00 | 01 | 7F 00 00 01 | 00 35 | hello udp
RSV FRAG ATYP IP PORT DATA
代理解析 Header,把 DATA 作为普通 UDP 发给目标。目标响应后,代理使用目标来源地址重新封装,再发回客户端。
为什么拒绝 FRAG != 0
FRAG 用于 UDP 分片。完整实现需要:
- 按客户端与目标维护分片队列;
- 识别分片序号和结束位;
- 设置重组超时;
- 限制总大小与并发队列;
- 防止内存耗尽攻击。
大多数 SOCKS5 客户端不会使用该能力。为了避免“看似支持、实际不安全”,实现中明确丢弃 FRAG != 0 的数据报。
这是协议实现中很重要的原则:不完整的复杂特性应该明确拒绝,而不是静默误处理。
UDP 中继的来源约束
公开 UDP 中继很容易变成反射攻击工具,因此至少要做三层限制:
- UDP 来源 IP 必须等于已认证 TCP 客户端 IP;
- 第一个合法 UDP 包锁定客户端真实端口;
- 目标响应必须来自客户端主动联系过的地址。
contactedTargets[targetAddress.String()] = struct{}{}
if _, allowed := contactedTargets[source.String()]; !allowed {
continue
}
请求中的客户端地址经常是 0.0.0.0:0,这是因为客户端在发送第一个 UDP 包前可能还不知道 NAT 映射端口。因此“首次合法报文锁定”比盲目信任请求字段更实用。
三种命令的流量定义
项目采用:
| 命令 | 上传 | 下载 |
|---|---|---|
| CONNECT | 客户端到目标的 TCP 字节 | 目标到客户端的 TCP 字节 |
| BIND | 客户端到反向连接者的字节 | 反向连接者到客户端的字节 |
| UDP ASSOCIATE | 去除 SOCKS Header 后的 UDP payload | 目标返回的 UDP payload |
UDP 不统计 SOCKS5 封装头,便于衡量真正的业务数据。
测试不能只测 parser
协议 parser 单元测试只能证明字节解析正确。完整测试还应创建真实 socket:
测试客户端 -> SOCKS5 服务 -> 本地 TCP/UDP Echo Server
覆盖:
- 正确和错误密码;
- IPv4、IPv6、域名编码;
- CONNECT 双向数据;
- BIND 两次响应;
- UDP ASSOCIATE 双向回显;
FRAG != 0拒绝;- 上传、下载和请求次数。
再配合 go test -race,检查会话关闭、资源跟踪和统计写入是否存在数据竞争。
小结
SOCKS5 的难点不在 io.Copy,而在状态机和边界:
握手 -> 认证 -> 通用请求 -> 命令分派 -> 生命周期 -> 统计
CONNECT 是主动拨号,BIND 是两阶段反向接入,UDP ASSOCIATE 则是 TCP 控制下的 UDP 中继。把地址编解码、命令处理和资源关闭拆开后,协议实现才会既可读又可测试。
下一篇进入数据层:用 SQLite 持久化代理用户、分协议流量与小时趋势。
