跳转到内容

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 监听器 支持 不适用

下面的配置在所有 IPv4 地址上(listen = "0.0.0.0")接受经过密码认证的 SOCKS5 客户端,包括 UDP,并通过 freedom 出站把所有流量直接发出。

socks-auth.toml
# 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/

如果用户名或密码包含 URL 中的特殊字符,例如 @ 或 /,请用 --proxy-user 'alice:…' 传递凭据,不要写在 URL 里。curl 只能测试 TCP;要测试 UDP,请使用实现了 UDP ASSOCIATE 的客户端。

入站使用通用的 [[inbound]] 键(tag、listen、port、sniffing),见入站。它自己的键写在 [inbound.settings] 中:

键类型必填默认值说明
authstring (enum)否"none"客户端的认证方式。"none" 不做认证,接受任何客户端,允许 SOCKS4、SOCKS4a 和 SOCKS5。"password" 要求 SOCKS5 用户名/密码认证(RFC 1929),并拒绝 SOCKS4。取值区分大小写,必须完全匹配;其他任何值(包括 Xray 的 "noauth")都会报 unknown socks auth。
accountsarray of tables视情况[]auth = "password" 时接受的账号,每项写作 { user = "…", pass = "…" },每一项都必须同时写这两个键。使用 auth = "password" 时必须填写,才能让客户端通过:空列表能通过解析,但所有客户端都会被拒绝。同一个 user 出现两次时以最后一项为准。auth = "none" 时忽略此项。
udpbool否true是否接受 SOCKS5 的 UDP ASSOCIATE 命令。为 false 时,服务端对 UDP ASSOCIATE 回复 0x07(command not supported)并关闭连接。
udp_bindstring视情况—每个 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 秒内到达,否则入站会关闭连接。

对于 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 = true 时,SOCKS5 客户端可以请求 UDP 中继:

  1. 客户端通过 TCP 连接发送 UDP ASSOCIATE,这条 TCP 连接随即成为控制连接。请求中的 DST.ADDR 和 DST.PORT 表示客户端声称其数据报将从哪里发出。入站对它们的使用仅限于关联只接收哪个客户端中所述。
  2. 入站选定中继地址:设置了 udp_bind 时取其值,否则取控制连接的本地地址,也就是客户端连接服务端时使用的地址。在绑定任何东西之前,入站先检查该地址上的中继能否收到这个客户端的数据报。如果中继地址收不到来自客户端所在地址族的数据报(见下表),入站应答 0x02(规则集不允许连接)并关闭控制连接。否则,它在中继地址上、由操作系统选择的端口上绑定一个新的 UDP socket,并把该地址和端口回复给客户端。
  3. 客户端把数据报发往中继端口,每个数据报都包裹在注明其目标的 SOCKS5 UDP 头中。
  4. 入站解开每个数据报并单独进行路由,然后给每个回复包裹上它所来自的对端地址。

每个数据报都单独路由,因此一个 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 关联结束。通往某个出站的链路结束后会被单独丢弃,下一个路由到该出站的数据报会重新打开它;关联本身继续运行。

一个 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 地址,而不是同时解析出两者的域名。

listen 的值以 / 开头时,入站在 Unix socket 上监听,而不是 TCP。入站释放时会删除 socket 文件。此时不能写 port。Unix socket 没有可用于 UDP 中继的本地 IP,因此在默认 udp = true 的情况下,你必须二选一:

Unix socket 入站
[[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] 中:

键类型必填默认值说明
userstring否—SOCKS5 用户名/密码认证(RFC 1929)的用户名。设置后,客户端只提供密码认证这一种方式;不设置时只提供“无需认证”。超过 255 字节时截断为 255 字节。
passstring否""与 user 配套的密码。没有 user 时忽略此项,客户端不做认证。超过 255 字节时截断为 255 字节。
config.toml
[[outbound]]
tag = "upstream"
protocol = "socks"
server = "proxy.example.com"
port = 1080
[outbound.settings]
user = "alice"
pass = "replace-with-a-long-random-password"

出站支持所有传输层:TCP、TLS、WebSocket 和 gRPC,可带或不带 TLS。对端必须在其 SOCKS 服务器之前终结该传输层;etemenanki-app 自己的 SOCKS 入站只接受明文 TCP。

对每个 TCP 流,出站通过配置的传输层建立到 server:port 的连接,然后:

  1. 只提供一种认证方式:设置了 user 时提供用户名/密码,否则提供“无需认证”;
  2. 如有凭据,发送凭据;
  3. 发送带有该流目标地址的 CONNECT。域名按域名发送,由上游服务器解析;
  4. 服务器应答 0x00 后开始中继数据流。

如果服务器选择了未提供的方式,该流以 auth method not supported 失败。凭据被拒绝时以 server rejects account 失败;请求收到非零应答时以 server rejects request: N 失败,其中 N 是应答码。

SOCKS 出站有上游地址,因此可以作为负载均衡器的成员。

路由到 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 与出站连接所用的地址族不一致。