SOCKS
SOCKS 是本地应用连接代理时常用的协议。浏览器、curl、git、包管理器以及大多数桌面客户端都能直接指向 SOCKS 代理,无需额外软件。etemenanki-app 在两个方向上都实现了它:
- 作为入站(
[[inbound]]下的protocol = "socks"),它在同一个端口上服务 SOCKS4、SOCKS4a 和 SOCKS5 客户端,可选用户名/密码认证和 SOCKS5 UDP; - 作为出站(
[[outbound]]下的protocol = "socks"),它是一个 SOCKS5 客户端,把 TCP 和 UDP 流转发给上游 SOCKS5 服务器。
入站适合作为工作站上或可信网络内部的入口,出站则用于经由已有的 SOCKS5 代理做链式转发。SOCKS 不做任何加密,不适合暴露在互联网上;见安全。
| 入站 | 出站 | |
|---|---|---|
| 版本 | SOCKS4、SOCKS4a、SOCKS5 共用一个端口 | 仅 SOCKS5 |
| 认证 | 无,或 SOCKS5 用户名/密码(RFC 1929) | 无,或 SOCKS5 用户名/密码 |
| TCP | CONNECT |
CONNECT |
| UDP | UDP ASSOCIATE,默认开启 |
UDP ASSOCIATE |
BIND |
拒绝 | 不使用 |
传输层([.stream]) |
仅明文 TCP | TCP、TLS、WebSocket 或 gRPC |
| Unix socket 监听器 | 支持 | 不适用 |
可运行的示例
Section titled “可运行的示例”下面的配置在所有 IPv4 地址上(listen = "0.0.0.0")接受经过密码认证的 SOCKS5 客户端,包括 UDP,并通过 freedom 出站把所有流量直接发出。
# A password-protected SOCKS5 inbound on every IPv4 address, with UDP ASSOCIATE,# relaying everything directly to the internet.## SOCKS carries no encryption: credentials and traffic cross the network in# the clear. Expose this only on a network you trust or behind a tunnel.
[log]level = "info"
[[inbound]]tag = "socks-in"protocol = "socks"listen = "0.0.0.0"port = 1080
[inbound.settings]auth = "password"accounts = [ { user = "alice", pass = "replace-with-a-long-random-password" }, { user = "bob", pass = "replace-with-another-long-random-password" },]# UDP ASSOCIATE is on by default. Each association opens a relay socket on a# UDP port the OS picks, on the address the client connected to; set udp_bind# to pin that address instead.udp = true
[[outbound]]tag = "direct"protocol = "freedom"
[route]default = "direct"启动前先检查配置:
etemenanki-app --test -c socks-auth.toml--test 会输出 Configuration OK. 或遇到的第一个错误。然后启动代理,在客户端上试用。代理 URL 的写法决定了由谁解析目标域名:
# socks5h:// 把主机名发给代理,由代理解析。curl -x 'socks5h://alice:replace-with-a-long-random-password@192.0.2.10:1080' https://example.com/# socks5:// 在本地解析并发送 IP 地址。开启嗅探时,# 代理会立即应答,并从最初的字节中读取 TLS 服务器名。curl -x 'socks5://alice:replace-with-a-long-random-password@192.0.2.10:1080' https://example.com/# 只能用于 auth = "none" 的入站:SOCKS4 没有密码认证方式。curl -x 'socks4a://127.0.0.1:1080' https://example.com/如果用户名或密码包含 URL 中的特殊字符,例如 @ 或 /,请用 --proxy-user 'alice:…' 传递凭据,不要写在 URL 里。curl 只能测试 TCP;要测试 UDP,请使用实现了 UDP ASSOCIATE 的客户端。
入站使用通用的 [[inbound]] 键(tag、listen、port、sniffing),见入站。它自己的键写在 [inbound.settings] 中:
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
auth | string (enum) | 否 | "none" | 客户端的认证方式。"none" 不做认证,接受任何客户端,允许 SOCKS4、SOCKS4a 和 SOCKS5。"password" 要求 SOCKS5 用户名/密码认证(RFC 1929),并拒绝 SOCKS4。取值区分大小写,必须完全匹配;其他任何值(包括 Xray 的 "noauth")都会报 unknown socks auth。 |
accounts | array of tables | 视情况 | [] | auth = "password" 时接受的账号,每项写作 { user = "…", pass = "…" },每一项都必须同时写这两个键。使用 auth = "password" 时必须填写,才能让客户端通过:空列表能通过解析,但所有客户端都会被拒绝。同一个 user 出现两次时以最后一项为准。auth = "none" 时忽略此项。 |
udp | bool | 否 | true | 是否接受 SOCKS5 的 UDP ASSOCIATE 命令。为 false 时,服务端对 UDP ASSOCIATE 回复 0x07(command not supported)并关闭连接。 |
udp_bind | string | 视情况 | — | 每个 UDP 关联绑定中继 socket 的 IP 地址(IPv4 或 IPv6,不能写主机名),同时也是服务端告诉客户端的中继地址。不设置时,中继使用客户端所建 TCP 连接的本地地址。中继只接收来自客户端控制连接源地址的数据报,因此 udp_bind 必须与客户端连接所用的地址族一致:IPv4 地址,或 ::ffff:192.0.2.10 这类 IPv4 映射的 IPv6 地址,只能接收 IPv4 客户端;:: 两者都能接收;其他 IPv6 地址只能接收 IPv6 客户端。若中继无法接收某个客户端的数据报,该客户端发来的 UDP ASSOCIATE 会被拒绝,回复码为 0x02(connection not allowed by ruleset)。当 listen 是 Unix socket 路径且 udp 为 true 时必须设置,因为 Unix socket 没有本地 IP。在 Unix socket 上,每个客户端的 UDP ASSOCIATE 还必须写明其数据报将使用的确切来源 IP 地址和端口,且地址族必须是 udp_bind 能接收的;地址或端口为零、或写的是域名的请求会被拒绝,回复 0x02。 |
[inbound.settings] 中的未知键会被拒绝,因此像 atuh = "password" 这样的拼写错误会让代理无法启动,而不是让它以默认的 auth = "none" 敞开运行。
入站根据第一个字节识别版本,并按如下方式应用 auth:
| 客户端使用 | auth = "none" |
auth = "password" |
|---|---|---|
SOCKS5,提供“无需认证”(0x00) |
接受 | 拒绝该方式(0xFF),除非同时提供 0x02 |
SOCKS5,提供用户名/密码(0x02) |
拒绝该方式(0xFF),除非同时提供 0x00 |
按 accounts 校验凭据 |
| SOCKS4 或 SOCKS4a | 接受 | 以应答码 91 拒绝 |
服务端只会选择一种方式,即 auth 指定的那种。同时提供两种方式的客户端可以连上任一类入站;例如 curl 在有凭据时会同时提供两种。凭据错误时返回 RFC 1929 的失败状态(0xFF),并关闭连接。
SOCKS4 带有一个用户 ID 字段;入站会读取但忽略它。当 SOCKS4 请求的目标 IP 以 0 开头时(SOCKS4a 客户端发送 0.0.0.x),入站会在用户 ID 之后读取一个主机名,并原样传递、不做解析。
| 命令 | 行为 |
|---|---|
CONNECT(0x01) |
作为 TCP 流进行路由和中继。这是 SOCKS4 上唯一接受的命令。 |
Tor RESOLVE(0xF0)和 RESOLVE_PTR(0xF1) |
仅 SOCKS5。按对所指定地址的 CONNECT 处理。 |
UDP ASSOCIATE(0x03) |
仅 SOCKS5。udp = true 时开启 UDP 中继;否则以 0x07 拒绝。 |
BIND(0x02) |
拒绝:SOCKS5 上返回 0x07,SOCKS4 上返回 91。 |
| 其他命令 | 拒绝:SOCKS5 上返回 0x07,SOCKS4 上返回 91。 |
整个握手,从第一个字节到完整的请求,必须在 10 秒内到达,否则入站会关闭连接。
何时发送应答
Section titled “何时发送应答”对于 CONNECT,入站通常先拨号连接目标,然后才应答。这样客户端从应答中就能得知目标不可达,而不是看到一个先建立、随即又被关闭的连接。
例外情况是:请求指定的是 IP 地址,并且 sniffing 处于开启状态(默认开启)。客户端在收到应答之前不会发送任何数据,因此入站会立即回复“成功”,收集客户端最初发送的字节(最多 4 KiB,最长等待 300 ms),从中读取 TLS 服务器名或 HTTP Host,并用这个域名进行路由。之后才拨号。
sequenceDiagram
participant C as 客户端
participant S as SOCKS 入站
participant O as 路由选中的出站
C->>S: 问候、认证、CONNECT
alt 目标是域名,或嗅探已关闭
S->>O: 拨号连接目标
O-->>S: 连接成功或出错
S-->>C: 应答 0x00 或拒绝码
else 目标是 IP 且嗅探已开启
S-->>C: 立即应答 0x00
C->>S: 最初的字节,最多 4 KiB 或 300 ms
S->>O: 拨号,如嗅探到域名则按该域名路由
end
C->>O: 经由入站双向中继
| 情况 | SOCKS5 应答 | SOCKS4 应答 |
|---|---|---|
| 拨号成功 | 0x00,附带客户端所连接的地址,端口为 0 |
90 |
| 拨号失败,原因为“connection refused” | 0x05(connection refused) |
91 |
| 其他任何拨号失败 | 0x04(host unreachable) |
91 |
| 提前回复“成功”且已收到客户端字节后拨号失败 | 无:直接关闭连接 | 无:直接关闭连接 |
如果你的客户端需要对每个 IP 目标都得到准确的应答,请在入站上设置 sniffing = false;此时路由只能看到 IP 地址。发送主机名的客户端,例如使用 socks5h:// 的 curl,永远不会被嗅探,总能得到准确的应答。
入站每 300 秒检查一次正在中继的连接,如果自上次检查以来两个方向都没有数据传输,就关闭该连接,因此空闲连接会持续 5 到 10 分钟。
UDP ASSOCIATE
Section titled “UDP ASSOCIATE”udp = true 时,SOCKS5 客户端可以请求 UDP 中继:
- 客户端通过 TCP 连接发送
UDP ASSOCIATE,这条 TCP 连接随即成为控制连接。请求中的DST.ADDR和DST.PORT表示客户端声称其数据报将从哪里发出。入站对它们的使用仅限于关联只接收哪个客户端中所述。 - 入站选定中继地址:设置了
udp_bind时取其值,否则取控制连接的本地地址,也就是客户端连接服务端时使用的地址。在绑定任何东西之前,入站先检查该地址上的中继能否收到这个客户端的数据报。如果中继地址收不到来自客户端所在地址族的数据报(见下表),入站应答0x02(规则集不允许连接)并关闭控制连接。否则,它在中继地址上、由操作系统选择的端口上绑定一个新的 UDP socket,并把该地址和端口回复给客户端。 - 客户端把数据报发往中继端口,每个数据报都包裹在注明其目标的 SOCKS5 UDP 头中。
- 入站解开每个数据报并单独进行路由,然后给每个回复包裹上它所来自的对端地址。
每个数据报都单独路由,因此一个 UDP 关联可以经由不同的出站到达多个对端。带有 network = "udp"、port 或 cidr 的 [[route.rule]] 逐包生效,被路由到 blackhole 的数据报会被丢弃,而该关联的其余部分照常工作。见路由。不承载 UDP 的出站会丢弃路由给它的数据报;哪些出站承载 UDP 见出站。
第 2 步的地址族检查取决于中继地址:
| 中继地址 | 接收经由以下方式连接的客户端 |
|---|---|
IPv4 地址,或 IPv4 映射的 IPv6 地址,例如 ::ffff:192.0.2.10 |
仅 IPv4 |
:: |
IPv4 和 IPv6 |
其他任何 IPv6 地址,包括 ::1 |
仅 IPv6 |
因此,udp_bind = "127.0.0.1" 会拒绝通过 IPv6 连接的客户端,udp_bind = "::1" 会拒绝 IPv4 客户端。通过 IPv4 连到双栈监听器的客户端算作 IPv4。不设置 udp_bind 时,中继地址就是客户端连接的那个地址,因此总与客户端属于同一地址族。
入站会丢弃无法使用的数据报:
- 分片的数据报(
FRAG字节不为零),因为未实现分片; - 头部格式错误的数据报;
- 负载为空的数据报。
一个 UDP 关联同时最多与 64 个不同的出站保持链路;需要第 65 个时,关闭最久未发送过数据的那条链路。
客户端关闭控制连接,或者 300 秒内两个方向都没有中继任何数据报时,UDP 关联结束。通往某个出站的链路结束后会被单独丢弃,下一个路由到该出站的数据报会重新打开它;关联本身继续运行。
关联只接收哪个客户端
Section titled “关联只接收哪个客户端”一个 UDP 关联只为一个客户端中继,即打开控制连接的那个客户端(RFC 1928 第 7 节)。通过 TCP 连接时,入站按以下方式把关联限定在该客户端;Unix socket 客户端见在 Unix socket 上监听:
- 中继只接受来自控制连接源 IP 地址的数据报。IPv4 映射的 IPv6 地址(例如
::ffff:192.0.2.20)算作 IPv4 地址192.0.2.20。 - 来自其他任何地址的数据报都会被丢弃,绝不中继。回复只发给该客户端。
- 入站接受中继的第一个数据报会把关联固定到该数据报的源端口。这个数据报必须有合法的头部、
FRAG为 0 且负载非空。此后,来自同一 IP 其他端口的数据报都会被丢弃。格式错误或负载为空的数据报不会固定端口。 - 如果请求写的是客户端自己的 IP 和非零端口,该端口从一开始就被固定,不必等第一个数据报到达。
- 请求写的是其他任何源地址时,入站会忽略它,而不是拒绝请求。客户端经常这样做:NAT 后面的客户端写自己的局域网地址;sing-box 在首个目标为私有地址时写另一地址族的回环地址;PySocks 写
0.0.0.0加一个端口。写域名同样会被忽略。关联仍然只接收控制连接的 IP:写上另一台主机的地址,并不会放行那台主机。
如果客户端的 UDP 与 TCP 连接从不同的 IP 地址发出,它的 UDP 将得不到中继:关联在 TCP 上建立成功,之后却传不了任何数据。例如,客户端的 TCP 经 IPv6、UDP 经 IPv4 到达服务器,或者它位于把 TCP 和 UDP 流量映射到不同公网地址的 NAT 之后。对于第一种情况,请让客户端直接连接服务器的 IPv4 或 IPv6 地址,而不是同时解析出两者的域名。
在 Unix socket 上监听
Section titled “在 Unix socket 上监听”listen 的值以 / 开头时,入站在 Unix socket 上监听,而不是 TCP。入站释放时会删除 socket 文件。此时不能写 port。Unix socket 没有可用于 UDP 中继的本地 IP,因此在默认 udp = true 的情况下,你必须二选一:
[[inbound]]tag = "local"protocol = "socks"listen = "/run/etemenanki/socks.sock"
[inbound.settings]udp_bind = "127.0.0.1" # 或者:udp = false两者都不设置时,--test 会报 socks over a unix socket has no local IP for UDP associate; set udp_bind or udp = false。Unix socket 上成功的 CONNECT 以绑定地址 0.0.0.0 应答。
Unix socket 客户端没有可用来限定关联的 IP 地址,因此它的 UDP ASSOCIATE 必须写明其数据报将从哪个确切的 IP 地址和非零端口发出,且该地址属于 udp_bind 能接收的地址族。写未指定地址(0.0.0.0 或 ::)、端口 0 或域名的请求会以 0x02 被拒绝,连接随之关闭。关联建立后,中继只接受恰好来自该地址和端口的数据报。
SOCKS 出站需要用 server 和 port 指定上游 SOCKS5 服务器。通用的 [[outbound]] 键(server、port、stream、address_family)见出站。凭据写在 [outbound.settings] 中:
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
user | string | 否 | — | SOCKS5 用户名/密码认证(RFC 1929)的用户名。设置后,客户端只提供密码认证这一种方式;不设置时只提供“无需认证”。超过 255 字节时截断为 255 字节。 |
pass | string | 否 | "" | 与 user 配套的密码。没有 user 时忽略此项,客户端不做认证。超过 255 字节时截断为 255 字节。 |
[[outbound]]tag = "upstream"protocol = "socks"server = "proxy.example.com"port = 1080
[outbound.settings]user = "alice"pass = "replace-with-a-long-random-password"[[outbound]]tag = "upstream-tls"protocol = "socks"server = "proxy.example.com"port = 443
[outbound.stream]network = "tls"
[outbound.stream.tls]server_name = "proxy.example.com"出站支持所有传输层:TCP、TLS、WebSocket 和 gRPC,可带或不带 TLS。对端必须在其 SOCKS 服务器之前终结该传输层;etemenanki-app 自己的 SOCKS 入站只接受明文 TCP。
客户端如何连接
Section titled “客户端如何连接”对每个 TCP 流,出站通过配置的传输层建立到 server:port 的连接,然后:
- 只提供一种认证方式:设置了
user时提供用户名/密码,否则提供“无需认证”; - 如有凭据,发送凭据;
- 发送带有该流目标地址的
CONNECT。域名按域名发送,由上游服务器解析; - 服务器应答
0x00后开始中继数据流。
如果服务器选择了未提供的方式,该流以 auth method not supported 失败。凭据被拒绝时以 server rejects account 失败;请求收到非零应答时以 server rejects request: N 失败,其中 N 是应答码。
SOCKS 出站有上游地址,因此可以作为负载均衡器的成员。
经由 SOCKS 出站的 UDP
Section titled “经由 SOCKS 出站的 UDP”路由到 SOCKS 出站的 UDP 流会通过配置的传输层,单独建立一条到服务器的控制连接,并发送 UDP ASSOCIATE。之后出站:
- 绑定一个本地 UDP socket,其地址族与服务器报告的中继地址相同;
- 把每个数据报包裹在 SOCKS5 UDP 头中,发往该中继地址;
- 只接受来自中继地址的回复,并丢弃无法解析的数据报。在这项检查中,IPv4 地址与其 IPv4 映射的 IPv6 形式视为同一地址;
- 控制连接关闭时结束该 UDP 关联。
数据报本身以明文 UDP 直接发往中继地址,不经过承载控制连接的 TLS、WebSocket 或 gRPC 传输层。服务器必须报告一个可达的 IP 地址:以域名形式给出的中继地址会以 socks: the relay address is a domain 失败。
UDP ASSOCIATE 请求不写源地址(0.0.0.0:0)。检查源地址的上游(etemenanki-app 自己的入站就是如此)会把关联限定在控制连接的来源地址上。因此,只有当数据报与控制连接从同一个 IP 地址发出时,经由该出站的 UDP 才能工作。如果在上游之前由单独的前端终结 TLS、WebSocket 或 gRPC 传输层,上游看到的控制连接来源就是该前端,并会丢弃不是来自前端 IP 地址的数据报。
如果确实要在公网地址上监听,如上面的示例:
- 设置
auth = "password"并使用足够长的随机密码,避免端口成为开放中继; - 用防火墙把 TCP 端口和 UDP 中继端口限制为已知的客户端地址。每个中继端口本身已经只接收其客户端控制连接的地址;
- 如果没有客户端需要 UDP,设置
udp = false。
以下错误会导致 --test 和启动失败。--test 在 configuration invalid: 之后记录它们,每条消息以 inbound <tag>: 或 outbound <tag>: 开头。
| 错误 | 原因与解决 |
|---|---|
unknown socks auth "noauth" |
auth 只接受小写的 "none" 和 "password"。Xray 的 "noauth" 在这里写作 "none"。 |
invalid settings: unknown field `…` |
[inbound.settings] 或 [outbound.settings] 中有拼错的键。消息会列出可接受的键。 |
invalid settings: missing field `pass` |
accounts 中的每一项都需要同时写 user 和 pass。消息的下一行会指出 accounts。 |
invalid settings: invalid IP address syntax |
udp_bind 需要 IP 地址,不能写主机名。消息的下一行会指出 udp_bind。 |
socks over a unix socket has no local IP for UDP associate; set udp_bind or udp = false |
listen 是 Unix socket,而 udp 仍开启。二者设置其一。 |
protocol socks does not support stream network "ws" 或 protocol socks does not support stream security "tls" |
入站的 [inbound.stream] 块中 network 不是 tcp,或 security 不是 none。删除该块。 |
missing server / missing port |
SOCKS 出站缺少 server 或 port。如果使用 TLS、WebSocket 或 gRPC stream 且没有 server,可能先报出传输层错误,例如 tls stream needs tls.server_name or server。 |
以下错误在运行时出现。入站在连接结束时以 debug 级别记录它们,格式为 socks connection from Some(<client IP>) ended: <message>,通过 Unix socket 连接时 Some(…) 处为 None。出站则把它们作为该流的错误报告。
| 消息 | 含义 |
|---|---|
no matching auth method |
客户端没有提供 auth 要求的方式;例如没有凭据的客户端连接 auth = "password" 的入站。 |
invalid username or password |
凭据与 accounts 中的任何一项都不匹配。 |
SOCKS4 not allowed when auth is required |
SOCKS4 或 SOCKS4a 客户端连接了 auth = "password" 的入站。请把客户端改为 SOCKS5。 |
UDP not enabled |
在 udp = false 时,客户端请求了 UDP ASSOCIATE。 |
socks: UDP associate over a unix socket must name its source address and port |
Unix socket 客户端的 UDP ASSOCIATE 没有写明确切的 IP 地址和非零端口,收到了 0x02。请配置客户端写明其 UDP 发出的地址和端口。见在 Unix socket 上监听。 |
socks: UDP associate from an address family the relay is not bound in |
客户端连接所用的地址族是 udp_bind 无法接收的,收到了 0x02;通过 Unix socket 时,则是请求所写的源地址属于这样的地址族。不设置 udp_bind 时,TCP 客户端不会遇到此错误。请把 udp_bind 设为客户端所用地址族的地址,或设为 :: 同时接收两者。见 UDP ASSOCIATE 中的表格。 |
TCP bind is not supported |
客户端发送了 BIND。 |
client did not complete its request in time |
客户端没有在 10 秒内完成握手。 |
auth method not supported |
出站:上游服务器选择了未提供的认证方式。检查它是否需要凭据。 |
server rejects account |
出站:上游拒绝了 user 和 pass。 |
server rejects request: 5 |
出站:上游拒绝了请求。数字是 SOCKS5 应答码;5 表示目标拒绝了连接。 |
server rejects request: 2 |
出站,UDP 流:上游拒绝了该关联,例如 etemenanki-app 入站的 udp_bind 与出站连接所用的地址族不一致。 |