SOCKS
源码文件:28 个 · 核对版本 Etemenanki 596916d
Etemenanki/protocols/src/socks/mod.rsEtemenanki/protocols/src/socks/protocol.rsEtemenanki/protocols/src/socks/handshake.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/socks/codec.rsEtemenanki/protocols/src/socks/udp_link.rsEtemenanki/protocols/src/socks/config.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/sniff/collector.rsEtemenanki/concepts/src/relay.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/core.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/config.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/unit/socks/protocol.rsEtemenanki/protocols/tests/unit/socks/server.rsEtemenanki/protocols/tests/unit/socks/codec.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/integration/e2e_udp_route.rsEtemenanki/app/tests/integration/e2e_unix.rs
etemenanki-protocols 中的 socks 模块实现了 SOCKS 的两端。服务端是 SocksInbound:一个驱动在同一条连接上同时支持 SOCKS4、SOCKS4a 和 SOCKS5,并中继 CONNECT 或 UDP ASSOCIATE。客户端包括一个 SOCKS5 CONNECT codec SocksConnect,以及一个用于 UDP ASSOCIATE 的数据报 link SocksUdpLink。
修改握手、应答规则、关联中继或客户端之前,请先读完本页。用户编写的配置见 SOCKS 用户指南页。
| 组成部分 | 文件 → 符号 | 负责 | 交给其他部分 |
|---|---|---|---|
| 线上格式原语 | protocols/src/socks/protocol.rs |
常量、RFC 1929 读取器、UDP 中继头 codec、客户端的切片构造器与解析器。 | 地址编码,由 protocols/src/helpers/address.rs 中的 AddressCodec::SOCKS 负责。 |
| 服务端握手 | protocols/src/socks/handshake.rs → handshake、handshake_with_udp_source |
版本识别、方法选择、认证、请求,以及所有拒绝应答。 | 对已批准请求的应答,因为它取决于连接结果。 |
| 入站驱动 | protocols/src/socks/server.rs → SocksInbound |
握手期限、嗅探、连接、成功或失败应答、CONNECT 中继,以及由 ExpectedSender 限定为单个客户端的 UDP ASSOCIATE 中继。 |
路由和拨号,由 Connector 负责。 |
客户端 CONNECT |
protocols/src/socks/codec.rs → SocksConnect |
以 sans-I/O codec 形式完成 SOCKS5 握手,之后原样传递明文。 | 拨号和 I/O,由客户端运行时负责。 |
客户端 UDP ASSOCIATE |
protocols/src/socks/udp_link.rs → SocksUdpLink |
在控制流上完成关联握手,之后在本地 UDP socket 上封装报文。 | 绑定 socket,由调用方的 bind 闭包负责。 |
| 设置 | protocols/src/socks/config.rs |
SocksAuth 和 SocksServerConfig。 |
解析 TOML,由 etemenanki-app 负责。 |
为什么 SOCKS 使用专用驱动
Section titled “为什么 SOCKS 使用专用驱动”crate 中其他所有流协议都是 ProxyCoreDecode 协议核心(core):一个 sans-I/O 状态机,由服务端运行时在单条传输 link 上驱动(见服务端协议核心和服务端运行时)。SOCKS 不符合这种形态。UDP ASSOCIATE 不在发起它的那条连接上传输数据。数据报到达的是第二个 socket,即服务端为该关联绑定的 UDP “hub”,而 TCP 控制连接只负责让关联保持存活。协议核心只能看到一个传输层,没有地方容纳这第二个 socket。
因此,SocksInbound 是一个普通的 async 函数,在连接的整个生命周期内持有控制流。在 etemenanki-app 中,app/src/serve.rs → serve_connection 为它单独设了一个分支:StreamProtocol::Socks 直接调用 SocksInbound::serve,其他所有流协议都连同各自的协议核心交给 drive。app 在整个 serve 调用期间持有该连接的会话许可(permit),所以一个关联在结束之前都算作一条活动连接。
由于驱动自己读取流,SOCKS 入站只能运行在普通 TCP 或 Unix socket 上。app/src/inbound/mod.rs 调用 reject_stream(cfg, "socks"),app/src/transport.rs → reject_stream_settings 会拒绝 tcp(或空值)以外的任何 stream network,以及 none(或空值)以外的任何 security,例如报错 inbound <tag>: protocol socks does not support stream network "ws"。
pub enum SocksAuth<T> { None(Arc<T>), Password(HashMap<CompactString, (CompactString, Arc<T>)>),}
pub struct SocksServerConfig<T> { pub auth: SocksAuth<T>, pub udp_enabled: bool, pub udp_bind: Option<IpAddr>,}T 是随流传给 connector 的每用户载荷。SocksAuth::None 持有所有匿名连接共用的那一份载荷。SocksAuth::Password 把用户名映射到对应的密码和载荷。当 T: Default 时,SocksServerConfig::default() 为匿名认证、启用 UDP、不设 udp_bind。
udp_bind 必须属于客户端连接时所用的地址族,因为中继只接收来自客户端控制连接来源地址的数据报(见 UDP ASSOCIATE)。Unix socket 上没有这样的地址,因此此时必须设置 udp_bind,并且客户端的 UDP ASSOCIATE 请求必须给出其数据报确切的来源地址和端口。
app 从 [inbound.settings] 构建这些值(app/src/config.rs → SocksInboundSettings,拒绝未知字段):auth = "none"(默认)或 "password"(其他值会报错 inbound <tag>: unknown socks auth "<value>")、accounts、udp(默认 true)和 udp_bind。app 中的载荷类型 T 为 ()。
pub struct SocksInbound<T> { /* auth, udp_enabled, udp_bind, sniff */ }
impl<T> SocksInbound<T> { pub fn new(config: SocksServerConfig<T>, sniff: bool) -> Self}
impl<T: Send + Sync + 'static> SocksInbound<T> { pub async fn serve<S, C>( &self, mut stream: S, local_ip: Option<IpAddr>, source: Option<IpAddr>, mut connector: C, ) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin, C: Connector<Flow<T>>, C::Datagram: DatagramLink<Addr = Destination>,}| 参数 | 含义 |
|---|---|
stream |
已接受的控制连接。 |
local_ip |
服务端在该连接上的本端地址。Unix socket 上为 None。它是 CONNECT 成功应答中的绑定地址,也是 UDP hub 的后备绑定地址。 |
source |
客户端地址。Unix socket 上为 None。它作为 Flow::source 写入每个 Flow,也是该连接上的 UDP ASSOCIATE 唯一接收的 IP。 |
connector |
为每个流拨号。CONNECT 必须得到 Outbound::Stream,关联必须得到 Outbound::Datagram。 |
connector 相关 trait 来自 concepts/src/link.rs(见 Link 与类型):
pub trait Connector<Target> { type Stream: AsyncRead + AsyncWrite + Unpin; type Datagram: DatagramLink; type Future: Future<Output = io::Result<Outbound<Self::Stream, Self::Datagram>>>;
fn connect(&mut self, target: Target) -> Self::Future;}
pub trait DatagramLink: Unpin { type Addr;
fn poll_send_to( &mut self, cx: &mut Context<'_>, buf: &[u8], to: &Self::Addr, ) -> Poll<io::Result<usize>>;
fn poll_recv_from( &mut self, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll<io::Result<Self::Addr>>;}target 是 protocols/src/flow.rs → Flow<T>,与 crate 中每个服务端协议核心交给 connector 的类型相同:
pub struct Flow<T> { pub destination: Destination, pub user: NetworkUser<T>, pub sniffed: Option<SniffedBehavior>, pub source: Option<IpAddr>,}
impl<T> Flow<T> { pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self}pub enum Version { V4, V5,}
pub enum Request { Connect(Destination), UdpAssociate,}
pub struct Handshake<T> { pub version: Version, pub user: NetworkUser<T>, pub request: Request,}
pub async fn handshake<S, T>( stream: &mut S, auth: &SocksAuth<T>, udp_enabled: bool,) -> io::Result<Handshake<T>>where S: AsyncRead + AsyncWrite + Unpin,
pub(crate) async fn handshake_with_udp_source<S, T>( stream: &mut S, auth: &SocksAuth<T>, udp_enabled: bool,) -> io::Result<(Handshake<T>, Option<Destination>)>where S: AsyncRead + AsyncWrite + Unpin,handshake_with_udp_source 依次完成版本识别、认证和请求,SocksInbound::serve 调用的是它。public 的 handshake 是一个包装,丢弃第二个返回值。Request::UdpAssociate 仍然不携带任何内容。UDP ASSOCIATE 请求中的 DST.ADDR 和 DST.PORT,即客户端声明的数据报来源,作为第二个返回值 Some(source) 返回。对其他任何请求以及 SOCKS4,它为 None。在多大程度上相信它由入站决定(见 UDP ASSOCIATE)。
通过认证的用户会成为带 UserAuthorization::UsernamePassword 的 NetworkUser。用户名就是客户端发送的那个;匿名客户端或 SOCKS4 客户端的用户名为空。密码字段始终为空:密码从不随流传递。
应答写入函数是 public 的,以便驱动在连接完成后再作答:
pub async fn write_socks5_response<S: AsyncWrite + Unpin>( stream: &mut S, code: u8, remote: &Remote, port: u16,) -> io::Result<()>
pub async fn write_socks4_response<S: AsyncWrite + Unpin>( stream: &mut S, code: u8,) -> io::Result<()>
pub async fn write_refusal<S: AsyncWrite + Unpin>( stream: &mut S, version: Version, code: u8,) -> io::Result<()>
pub async fn write_granted<S: AsyncWrite + Unpin>( stream: &mut S, version: Version, bound: Option<IpAddr>,) -> io::Result<()>对 SOCKS5,write_refusal 发送 code 和绑定地址 0.0.0.0:0;对 SOCKS4,它始终发送 91。对 SOCKS5,write_granted 发送 0x00、bound(或 0.0.0.0)和端口 0;对 SOCKS4,它发送 90。
UDP 中继头
Section titled “UDP 中继头”pub fn decode_udp_packet(packet: &[u8]) -> io::Result<(Destination, Bytes)>pub fn parse_udp_packet(packet: &[u8]) -> io::Result<(Destination, usize)>pub fn encode_udp_packet(remote: &Remote, port: u16, data: &[u8]) -> Bytespub fn encode_udp_packet_into(remote: &Remote, port: u16, data: &[u8], out: &mut BytesMut)两端的中继都使用不复制数据的那一对函数 parse_udp_packet 和 encode_udp_packet_into,并复用缓冲区。decode_udp_packet 和 encode_udp_packet 是会复制数据的版本。
关联的客户端
Section titled “关联的客户端”pub(crate) fn endpoint(addr: SocketAddr) -> (IpAddr, u16)关联的两端都用 endpoint 比较中继数据报的发送方:取规范形式的 IP(to_canonical,即 IPv4-mapped IPv6 地址会变成对应的 IPv4 地址)和端口。它忽略 IPv6 的 flow info 和 scope ID。双栈 socket 会把 IPv4 对端报告为 IPv4-mapped 地址,endpoint 让这两种形式相等。
// protocols/src/socks/server.rs (private)const STATUS_NOT_ALLOWED: u8 = 0x02;
struct ExpectedSender { ip: IpAddr, port: Option<u16>, client: Option<SocketAddr>,}
impl ExpectedSender { fn new( peer: Option<IpAddr>, declared: Option<&Destination>, hub: IpAddr, ) -> Result<Self, &'static str> fn admits(&self, from: SocketAddr) -> bool fn pin(&mut self, from: SocketAddr) fn client(&self) -> Option<SocketAddr>}
fn hears(hub: IpAddr, ip: IpAddr) -> boolExpectedSender 是一个 UDP ASSOCIATE 所接收的唯一客户端(RFC 1928 §7)。ip 是每个数据报都必须来自的规范地址。请求在给出 ip 的同时给出了端口时,设置 port。client 是第一个为其转发了数据报的发送方,保存为 hub 所见的形式,回复就发往这里。new 根据控制连接的对端(Unix socket 上为 None)、请求声明的来源和 hub 的地址构建它。它的错误说明为什么不存在中继能够限定的客户端。hears 判断绑定在 hub 上的 hub 能否收到来自 ip 的数据报。STATUS_NOT_ALLOWED 是 SOCKS5 应答“connection not allowed by ruleset”,在 new 失败时发送。
pub struct SocksConnect { /* dest, auth, stage */ }
impl SocksConnect { pub fn new(dest: &Destination, auth: Option<(&str, &str)>) -> Self}
impl ProxyCoreEncodeHandshake for SocksConnect { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = 528; // start, reply, finish}
impl ProxyCoreEncode for SocksConnect { /* seal, open */ }pub struct SocksUdpLink<S> { /* control, socket, relay, scratch, recv, sink, control_closed */ }
impl<S> SocksUdpLink<S>where S: AsyncRead + AsyncWrite + Unpin,{ pub async fn associate( mut control: S, auth: Option<(&str, &str)>, bind: impl FnOnce(&SocketAddr) -> io::Result<UdpSocket>, ) -> io::Result<Self>
pub fn relay(&self) -> SocketAddr}
impl<S> DatagramLink for SocksUdpLink<S>where S: AsyncRead + AsyncWrite + Unpin,{ type Addr = Destination; // poll_send_to, poll_recv_from}所有多字节整数均为大端序。SOCKS5 地址使用 AddressCodec::SOCKS(即 protocol.rs 中的常量 ADDR),端口放在地址之后:
ATYP |
地址 | 长度 |
|---|---|---|
0x01 |
IPv4 | 4 字节 |
0x03 |
域名:一个长度字节,后跟名称 | 1 + 1 至 255 字节 |
0x04 |
IPv6 | 16 字节 |
其他任何 ATYP 都会报错 unknown address type: …。域名必须非空、是合法 UTF-8,并且是合法的域名。以数字或 [ 开头且能解析为 IP 字面量的名称会被当作 IP 地址。编码后的地址最长为 AddressCodec::MAX_LEN,即 259 字节(含端口)。
SOCKS4 与 SOCKS4a
Section titled “SOCKS4 与 SOCKS4a”| 字段 | 长度 | 含义 |
|---|---|---|
VN |
1 | 0x04。 |
CD |
1 | 命令。只有 0x01(CMD_TCP_CONNECT)会被批准。 |
DSTPORT |
2 | 目标端口。 |
DSTIP |
4 | 目标 IPv4 地址。首个八位组为 0 时,该请求是 SOCKS4a,用户 ID 之后跟着一个域名。 |
USERID |
可变 | 直到 NUL 为止的字节。服务端读出后丢弃。 |
DOMAIN |
可变 | 仅 SOCKS4a:直到 NUL 为止的字节,作为目标域名。 |
| 字段 | 长度 | 含义 |
|---|---|---|
VN |
1 | 0x00。 |
CD |
1 | 90(SOCKS4_REQUEST_GRANTED)或 91(SOCKS4_REQUEST_REJECTED)。 |
DSTPORT |
2 | 始终为 0。 |
DSTIP |
4 | 始终为 0.0.0.0。 |
read_until_null 读取以 NUL 结尾的字段,并按有损 UTF-8 解码。它最多保留 512 字节:若第 513 个字节不是 NUL,则报错 buffer overrun。
SOCKS5 问候与方法选择
Section titled “SOCKS5 问候与方法选择”| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 0x05。 |
NMETHODS |
1 | 其后方法字节的个数。 |
METHODS |
NMETHODS |
客户端提供的方法。 |
| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 0x05。 |
METHOD |
1 | 0x00(AUTH_NOT_REQUIRED)、0x02(AUTH_PASSWORD)或 0xFF(AUTH_NO_MATCHING_METHOD)。 |
服务端不做协商。配置的 SocksAuth 固定了唯一的方法:None 对应 0x00,Password 对应 0x02。如果客户端的列表中包含该方法,服务端就选择它;否则服务端应答 0xFF 并关闭连接。
RFC 1929 用户名/密码
Section titled “RFC 1929 用户名/密码”| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 子协商版本,0x01。 |
ULEN |
1 | 用户名长度。 |
UNAME |
ULEN |
用户名。 |
PLEN |
1 | 密码长度。 |
PASSWD |
PLEN |
密码。 |
| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 0x01。 |
STATUS |
1 | 0x00 表示接受,0xFF 表示拒绝。客户端把任何非零值都视为拒绝。 |
read_username_password 在查找账户之前,先按有损 UTF-8 解码两个字符串。在客户端,encode_userpass 按单字节长度字段的要求,把用户名和密码各截断到 255 字节。
SOCKS5 请求与应答
Section titled “SOCKS5 请求与应答”| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 0x05。 |
CMD |
1 | 命令,见下表。 |
RSV |
1 | 0x00。 |
ATYP |
1 | 地址类型。 |
DST.ADDR |
可变 | CONNECT 的目标地址。对 UDP ASSOCIATE,RFC 1928 规定它是客户端数据报的来源地址;本服务端把它当作提示(见 UDP ASSOCIATE)。 |
DST.PORT |
2 | 端口,两种含义同上。 |
| 字段 | 长度 | 含义 |
|---|---|---|
VER |
1 | 0x05。 |
REP |
1 | 应答码,见失败应答。 |
RSV |
1 | 0x00。 |
ATYP |
1 | 绑定地址的地址类型。 |
BND.ADDR |
可变 | CONNECT:服务端在该连接上的本端 IP,或 0.0.0.0。UDP ASSOCIATE:hub 的 IP。 |
BND.PORT |
2 | CONNECT:0。UDP ASSOCIATE:hub 的端口。 |
CMD |
常量 | 服务端处理 |
|---|---|---|
0x01 |
CMD_TCP_CONNECT |
Request::Connect。 |
0x02 |
CMD_TCP_BIND |
以 0x07 拒绝。 |
0x03 |
CMD_UDP_ASSOCIATE |
Request::UdpAssociate;UDP 被禁用时以 0x07 拒绝。 |
0xF0 |
CMD_TOR_RESOLVE |
按到指定目标的 Request::Connect 处理。 |
0xF1 |
CMD_TOR_RESOLVE_PTR |
按到指定目标的 Request::Connect 处理。 |
| 其他 | 以 0x07 拒绝。 |
UDP 中继报文
Section titled “UDP 中继报文”| 字段 | 长度 | 含义 |
|---|---|---|
RSV |
2 | 0x0000。 |
FRAG |
1 | 分片编号。非 0 的值一律拒绝(discarding fragmented payload)。 |
ATYP |
1 | 地址类型。 |
DST.ADDR |
可变 | 客户端到 hub:载荷的去向。hub 到客户端:回复的来源。 |
DST.PORT |
2 | 端口,两种含义同上。 |
DATA |
剩余部分 | 载荷。 |
parse_udp_packet 会拒绝短于 5 字节的报文(insufficient length of packet),以及地址超出报文末尾的报文。
handshake_with_udp_source(因而 handshake 也一样)先读取两个字节,并根据第一个字节分支:
flowchart TB head["读取 2 字节"] head -->|"0x05, NMETHODS"| methods["读取 METHODS"] head -->|"0x04, CD"| v4["handshake4"] head -->|"其他"| badver["error: unknown SOCKS version"] methods -->|"提供了配置的方法"| sel["应答 0x05, method"] methods -->|"未提供"| nomatch["应答 0x05 0xFF,报错"] sel -->|"Password"| creds["RFC 1929 轮次"] sel -->|"None"| req["读取 VER CMD RSV"] creds -->|"匹配"| req creds -->|"不匹配"| authfail["应答 0x01 0xFF,报错"] req -->|"CONNECT 或 Tor resolve"| dst["读取 DST,Request::Connect"] req -->|"UDP ASSOCIATE,UDP 已启用"| udp["读取 DST,作为声明的来源返回"] req -->|"BIND、未知命令或 UDP 已禁用"| refuse["应答 0x07,报错"]
handshake4 中的 SOCKS4 路径如下:
- 如果
auth为Password,应答91并失败(SOCKS4 not allowed when auth is required)。SOCKS4 没有密码,所以受密码保护的入站会拒绝所有 SOCKS4 客户端。 - 读取
DSTPORT、DSTIP和USERID。DSTIP的首个八位组为0时,再读取 SOCKS4a 域名。 - 如果
CD不是CONNECT,应答91并失败(unsupported SOCKS4 command …)。这项检查之前会读完整个请求。
每个拒绝应答都在错误返回之前写出。已批准的请求此时还不作答:何时作答由驱动决定。
CONNECT
Section titled “CONNECT”sequenceDiagram
participant C as 客户端
participant S as SocksInbound::serve
participant K as Connector
participant U as 上游
C->>S: 问候、认证、CONNECT 请求
alt 嗅探开启且目标是 IP
S->>C: 成功应答
C->>S: 首批字节,最多 4 KiB 或 300 ms
S->>K: connect(Flow with sniffed)
K->>U: 拨号
S->>U: 收集到的前缀
else 其他情况
S->>K: connect(Flow)
K->>U: 拨号
S->>C: 成功应答,出错时为拒绝应答
end
loop 直到两个方向都结束或空闲保护触发
C->>U: 经 BidirectionalConnection 传输字节
U->>C: 经 BidirectionalConnection 传输字节
end
SocksInbound::connect 在连接完成后才作答,因此客户端能知道目标是否可达。失败应答来自 refusal_code:io::ErrorKind::ConnectionRefused 对应 0x05(STATUS_CONNECTION_REFUSED),其他所有错误对应 0x04(STATUS_HOST_UNREACHABLE)。SOCKS4 始终得到 91。
嗅探是个例外,原因在于 SOCKS 客户端在收到应答之前不会发送任何载荷。当入站开启嗅探(sniff 为 true)且 worth_sniffing(&flow.destination) 成立(即目标是裸 IP)时,驱动会:
- 先写出成功应答;
- 在
collect_prefix中收集客户端的首批字节:Collector持续读取,直到某个嗅探器识别出 TLS 或 HTTP(Verdict::Found)、填满SNIFF_LIMIT(4 KiB)、超过SNIFF_TIMEOUT(300 ms)或读取失败; - 把结果放入
Flow::sniffed并发起连接; - 用
write_all把收集到的前缀写给上游,然后开始中继。
如果收集到前缀之后连接失败,客户端已经拿到了成功应答。此时驱动直接返回错误而不发送拒绝应答,客户端看到的是连接被关闭。目标为域名时从不嗅探,因为它已经可以按该名称路由。
如果 connector 对 CONNECT 返回 Outbound::Datagram,驱动会报错 socks: a CONNECT was answered with a datagram link。
async fn relay_with_idle_guard<A, B>(a: A, b: B) -> io::Result<Relayed>where A: AsyncRead + AsyncWrite + Unpin, B: AsyncRead + AsyncWrite + Unpin,relay_with_idle_guard 包装了 concepts/src/relay.rs → BidirectionalConnection:
pub struct BidirectionalConnection<A, B, const BUF_SIZE: usize = 8192> { /* a, b, a_to_b, b_to_a */ }
impl<A, B, const BUF_SIZE: usize> BidirectionalConnection<A, B, BUF_SIZE> { pub fn new(a: A, b: B) -> Self pub fn relayed(&self) -> Relayed}
pub struct Relayed { pub a_to_b: u64, pub b_to_a: u64,}驱动用 RELAY_BUF(16 KiB)实例化它,每个方向一个装箱的 16 KiB 缓冲区。BidirectionalConnection 是一个手写的 future:没有按方向划分的 task,没有 channel,构造之后也不再分配内存。某一侧的读端到达流末尾时,该方向会排空缓冲区,并对另一侧的写端调用 poll_shutdown,从而传递半关闭。两个方向都完成后,future 才结束。读错误、写错误或零长度写入(WriteZero)会立即结束它。
空闲保护用 select! 在中继和一个 RELAY_IDLE_TIMEOUT(300 s)的 sleep 之间选择。每次计时到期,它都把 relayed() 与上一次采样比较。如果两个计数器都没有变化,就返回 TimedOut,错误为 socks: relay idle。每个窗口只采样一次,所以空闲连接会在最后一个字节之后 300 到 600 秒之间结束,具体取决于空闲从窗口中的哪个位置开始。
UDP ASSOCIATE
Section titled “UDP ASSOCIATE”sequenceDiagram
participant C as 客户端控制连接
participant D as 客户端 UDP
participant S as SocksInbound::associate
participant H as Hub socket
participant L as Connector link
C->>S: UDP ASSOCIATE 请求,DST 保留为声明的来源
break ExpectedSender::new 失败
S->>C: 应答 0x02,然后关闭
end
S->>H: 在 bind_ip 上绑定,端口 0
S->>C: 应答 0x00,BND 为 bind_ip 和 hub 端口
D->>H: RSV FRAG ATYP DST DATA
H->>S: recv_from
S->>S: ExpectedSender::admits,解析头部
S->>S: ExpectedSender::pin
opt 尚无 link
S->>L: connect(该目标的 Flow)
end
S->>L: poll_send_to(payload, dest)
L->>S: poll_recv_from 返回载荷和来源
S->>H: 标明来源的头部,后跟载荷
H->>D: send_to 已固定的客户端
Note over C,S: 控制流关闭或出错、link 失败或空闲 300 s 时结束
SocksInbound::associate 按以下步骤执行:
- 选择 hub 地址。
bind_ip取udp_bind,否则取local_ip。Unix socket 上没有local_ip,所以在没有udp_bind时,驱动应答0x07并报错UDP associate over a unix socket needs udp_bind。app 在构建时就会以inbound <tag>: socks over a unix socket has no local IP for UDP associate; set udp_bind or udp = false拒绝这种配置,因此这条路径只是兜底。 - 决定接收谁的数据报。
ExpectedSender::new(source, declared, bind_ip)应用关联接收谁的数据报中的规则。出错时,驱动写出write_refusal(…, 0x02)(STATUS_NOT_ALLOWED),并以该错误的消息返回PermissionDenied,此时尚未绑定任何 hub socket。 - 绑定 hub。 驱动在
bind_ip上以端口 0 绑定一个新的UdpSocket,因此每个关联都有自己的临时端口。绑定失败时,错误会在写出任何应答之前返回。成功应答中给出bind_ip和该端口。 - 中继。 同一个 future 中的一个
tokio::select!循环处理四个分支:
| 分支 | 动作 |
|---|---|
hub.recv_from 读入 up 缓冲区 |
!sender.admits(from)、parse_udp_packet 失败或载荷为空时,丢弃该数据报。否则调用 sender.pin(from),在首次使用时创建 link,用 poll_send_to 发送载荷,并重置空闲计时器。发送出错时以 debug 级别记录日志并丢弃该报文。recv_from 出错时,关联以该错误结束。 |
recv_reply 读入 down 缓冲区(仅在 link 已存在时) |
用 encode_udp_packet_into 封装载荷并标明其来源地址,然后 send_to(sender.client())。发送错误被忽略。重置空闲计时器。link 出错时以 debug 级别记录日志(socks: association link ended: …),关联结束且不返回错误。 |
stream.read 读入 256 字节的 sink |
客户端在控制流上发送的字节被丢弃。流结束或出错时关联结束。 |
idle |
在 RELAY_IDLE_TIMEOUT(300 s)内两个方向都没有转发任何数据报时,关联结束。 |
只有值得转发的数据报,即能够解析且带有载荷的数据报,才会固定客户端,因此与客户端同一 IP 上的邻居无法通过先发送垃圾数据来抢占该关联。
link 只创建一次,目标取第一个被转发的数据报的目标:以 UDP 目标构造 Flow::new(dest, user, source)。之后的数据报经由同一个 link 发送,并在 poll_send_to 中带上各自的 dest,因此 link 必须按报文路由。etemenanki-app 正是这样做的:app/src/connector.rs → AppConnector 从不为 UDP 流拨号,而是返回一个逐包路由的 FanOutLink(见连接服务)。如果 connector 为第一个流返回错误,关联以该错误结束。如果它返回 Outbound::Stream,关联报错 socks: an association was answered with a stream。
循环中没有任何队列。挂起的 connect、poll_send_to 或 send_to 会让整个 select 暂停,因此在发送完成之前不会读取 hub。内核的 socket 缓冲区是唯一的缓冲,多出的数据报由内核丢弃。
因控制流关闭、link 失败或空闲计时器触发而结束的关联返回 Ok(())。丢弃该 future 时,hub socket 和 link 也随之被丢弃。
关联接收谁的数据报
Section titled “关联接收谁的数据报”ExpectedSender::new 在绑定 hub 之前确定 IP,并在可能时确定端口。对端和请求给出的 IP 都在 to_canonical 之后比较,所以 ::ffff:127.0.0.1 指的就是 127.0.0.1。只有当 declared 是一个非未指定地址的 IP 时,才算给出了地址。域名、0.0.0.0、::ffff:0.0.0.0 或 :: 都不算给出任何地址。
| 控制连接 | 请求给出 | 接收的 IP | 端口 |
|---|---|---|---|
| TCP 对端 P | P,端口 N ≠ 0 | P | 从一开始就是 N |
| TCP 对端 P | P,端口 0 | P | 由第一个被转发的数据报固定 |
| TCP 对端 P | 其他任何内容:另一个 IP、未指定地址或域名 | P;给出的来源被搁置 | 由第一个被转发的数据报固定 |
| Unix socket(无对端) | 非未指定地址的 IP A,端口 N ≠ 0 | A | 从一开始就是 N |
| Unix socket(无对端) | 其他任何内容 | 拒绝:socks: UDP associate over a unix socket must name its source address and port |
请求给出的来源与对端不同时,它会被搁置而不是被拒绝,因为数据报并不来自那里:NAT 后面的客户端会给出其局域网地址,sing-box 在首个目标为私有地址时会给出一个环回地址,PySocks 只给出端口。给出另一个地址永远不会让那个地址进来。
随后 new 调用 hears(hub, ip)。IPv4 或 IPv4-mapped 的 hub 只接收 IPv4,:: 接收两种地址族,其他任何 IPv6 hub 只接收 IPv6。如果 hub 无法接收来自 ip 的数据报,请求会以 socks: UDP associate from an address family the relay is not bound in 被拒绝,而不是建立一个永远收不到数据报的关联。
当 from 的规范 IP 等于 ip、端口等于请求给出的端口(如果有),并且在客户端已固定后 endpoint(from) == endpoint(client) 时,admits(from) 成立。pin 使用 get_or_insert,所以第一次固定在关联的整个生命周期内有效。回复发往已固定的地址,保持 hub 所见的形式,在双栈 hub 上可能是 IPv4-mapped 地址。
客户端:SocksConnect
Section titled “客户端:SocksConnect”SocksConnect 是由客户端运行时驱动的客户端 codec(见客户端运行时)。在 etemenanki-app 中,app/src/outbound/mod.rs → SocksOutbound 为每个流构建一个 SocksConnect,放在使用 SOCKS_BUF(16 KiB)的 ProxyClient 中,运行在出站的传输层之上。因此 SOCKS 出站可以运行在 TLS、WebSocket 或 gRPC 之上,而入站不行。
stateDiagram-v2 [*] --> Method: start 暂存 05 01 method Method --> UserPass: method 0x02,暂存凭据 Method --> Request: method 0x00,暂存 CONNECT UserPass --> Request: status 0x00,暂存 CONNECT Request --> Done: REP 0x00 Done --> [*]
| 阶段 | 解析内容 | 失败时的错误 |
|---|---|---|
Method |
parse_method_reply:2 字节,VER 必须为 0x05 |
unexpected server version(InvalidData);所选方法不是客户端提供的方法时为 auth method not supported(PermissionDenied) |
UserPass |
parse_userpass_reply:2 字节 |
server rejects account(PermissionDenied) |
Request |
parse_reply:VER REP RSV 加一个地址 |
server rejects request: N(ConnectionRefused),其中 N 为应答码 |
Done |
无 | socks: handshake already done |
客户端只提供一种方法:有凭据时为 0x02,否则为 0x00(encode_method_request)。每个解析器在整条消息到齐之前都返回 Reply::NeedMore。每个 Reply::Step 都报告它消费的字节数,因此最终应答之后的字节就是来自目标的首批数据。握手完成后,seal 把明文复制到暂存区,open 把所有线上字节作为一帧交还。STAGING_RESERVE 为 528 字节,足以容纳最大的凭据消息(513 字节)或带 259 字节地址的请求。如果暂存区放不下一条消息,codec 报错 socks: staging room below the declared reserve。
客户端:SocksUdpLink
Section titled “客户端:SocksUdpLink”SocksUdpLink::associate 用异步辅助函数 round 直接在控制流上执行相同的几轮交互;round 每次读取 512 字节,直到解析器返回结果。如果流先结束,则报错 socks: server closed during the handshake。请求中声明的地址为 0.0.0.0:0(encode_request(CMD_UDP_ASSOCIATE, None)),这正是 RFC 1928 规定客户端在不知道自身来源时的做法:本地 socket 要等知道中继地址后才绑定,而且在 NAT 后面,本地地址本来就是错的。像 SocksInbound 这样检查来源的服务端,会把关联限定在控制连接的地址和第一个数据报的端口上。非零应答码会报错 server rejects request: N(ConnectionRefused)。应答中的绑定地址必须是 IP;若为域名则报错 socks: the relay address is a domain。bind 闭包会收到中继地址,以便据此选择地址族。SocksOutbound::connect_datagram 绑定与中继地址同族的未指定地址。
| 方法 | 行为 |
|---|---|
poll_send_to |
调用 poll_control,在复用的 scratch 缓冲区中给载荷加上中继头,并发送到中继地址。 |
poll_recv_from |
调用 poll_control,接收到装箱的 64 KiB recv 缓冲区(RECV_BUF),除非 endpoint(from) == endpoint(self.relay),否则丢弃数据报,也丢弃无法解析的数据报,再把载荷复制到 buf。大于 buf 的载荷会被截断。返回头部中的来源地址。 |
poll_control |
把控制流排空到 256 字节的 sink 中。读错误原样返回一次。此后以及流结束之后,每次调用都返回 BrokenPipe,错误为 socks: the control connection closed。 |
由于比较经过 endpoint,双栈客户端 socket 即使把 IPv4 中继的回复报告为来自其 IPv4-mapped 地址,也仍能接收该中继的数据报。
控制流保存在 link 内部,所以丢弃 link 就会关闭控制流,从而结束服务端上的关联。
| 不变量 | 机制 | 由谁保证 |
|---|---|---|
已批准的 CONNECT 只在连接完成后才作答,嗅探路径除外。 |
prefix 为空时,SocksInbound::connect 在 connector.connect 之后写出 write_granted。 |
protocols/tests/pipeline/socks.rs 中的 new_server_refuses_an_unreachable_target_after_trying |
| 每个拒绝应答都在错误返回之前写到线上。 | handshake5、handshake4 和 associate 中的每个拒绝分支都会在 return Err 之前 await write_refusal、write_socks4_response 或写出方法/状态字节。 |
associate 中的 0x02 拒绝:udp_association_over_a_unix_socket_needs_its_exact_source、udp_association_refuses_a_relay_that_cannot_hear_the_client |
| 整个握手只有一个期限。 | serve 用 tokio::time::timeout(HANDSHAKE_TIMEOUT, …) 包住 handshake_with_udp_source。 |
|
| 一个关联只为一个客户端中继:控制连接的 IP(Unix socket 上则是请求给出的确切来源),固定到一个端口,回复也只发给它。 | ExpectedSender:admits 检查每个发送方,第一个被转发数据报的 pin 一直有效。 |
protocols/tests/pipeline/socks.rs 中的 udp_association_ignores_another_ip、udp_association_ignores_another_port_once_pinned、udp_association_holds_to_the_port_the_request_names、udp_association_is_not_widened_by_the_request;protocols/tests/unit/socks/server.rs 中的单元测试 |
| 中继永远无法接收的关联会在绑定 hub 之前被拒绝。 | ExpectedSender::new 和 hears,以 0x02 应答。 |
protocols/tests/unit/socks/server.rs 中的 a_relay_that_cannot_hear_the_client_is_refused;protocols/tests/pipeline/socks.rs 中的 udp_association_refuses_a_relay_that_cannot_hear_the_client |
| 客户端 link 只接受来自服务端所告知中继地址的回复。 | SocksUdpLink::poll_recv_from 把 endpoint(from) 与 endpoint(self.relay) 比较。 |
protocols/tests/pipeline/socks.rs 中的 udp_link_ignores_datagrams_not_from_the_relay、udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay |
| 关联的存活时间不超过其控制流。 | 服务端的 stream.read 分支;客户端的 poll_control。 |
|
| 分片的中继报文永远不会被转发。 | parse_udp_packet 拒绝 FRAG ≠ 0,两端中继都会丢弃它拒绝的报文。 |
|
| 不存在按连接的 task、channel 或队列。 | serve 是单个 future:CONNECT 用 BidirectionalConnection,关联用一个 select! 循环。 |
concepts/src/relay.rs 中的 bidirectional_relays_both_ways_and_half_closes |
失败路径与取消
Section titled “失败路径与取消”| 情形 | SOCKS5 应答 | SOCKS4 应答 | 返回的错误 |
|---|---|---|---|
第一个字节既不是 0x04 也不是 0x05 |
无 | 无 | InvalidData:unknown SOCKS version: N |
握手未在 HANDSHAKE_TIMEOUT(10 s)内完成 |
无 | 无 | TimedOut:client did not complete its request in time |
| 客户端未提供配置的方法 | method 0xFF |
PermissionDenied:no matching auth method |
|
| 未知用户或密码错误 | status 0xFF |
PermissionDenied:invalid username or password |
|
| 在密码认证入站上使用 SOCKS4 | 91 |
PermissionDenied:SOCKS4 not allowed when auth is required |
|
CONNECT 以外的 SOCKS4 命令 |
91 |
Unsupported:unsupported SOCKS4 command N |
|
BIND |
0x07 |
Unsupported:TCP bind is not supported |
|
| 未知命令 | 0x07 |
InvalidData:unknown command Some(N) |
|
UDP 被禁用时的 UDP ASSOCIATE |
0x07 |
Unsupported:UDP not enabled |
|
Unix socket 上未设 udp_bind 的 UDP ASSOCIATE |
0x07 |
Unsupported:UDP associate over a unix socket needs udp_bind |
|
Unix socket 上没有确切来源的 UDP ASSOCIATE(无地址、未指定地址、域名或端口 0) |
0x02 |
PermissionDenied:socks: UDP associate over a unix socket must name its source address and port |
|
来自 hub 不接收的地址族的 UDP ASSOCIATE |
0x02 |
PermissionDenied:socks: UDP associate from an address family the relay is not bound in |
|
| 无法绑定 hub socket | 无 | 绑定错误 | |
| 地址格式错误或被截断,或者流在握手中途结束 | 无 | 无 | 读取错误 |
连接被拒绝(ConnectionRefused) |
0x05 |
91 |
connector 的错误 |
| 其他任何连接错误 | 0x04 |
91 |
connector 的错误 |
CONNECT 得到的是数据报 link |
无 | 无 | Unsupported:socks: a CONNECT was answered with a datagram link |
| 关联得到的是流 | 无 | Unsupported:socks: an association was answered with a stream |
|
CONNECT 中继空闲 |
无,已批准 | 无,已批准 | TimedOut:socks: relay idle |
serve 把所有错误返回给调用方。app 以 debug 级别记录为 socks connection from Some(<ip>) ended: <error>(Unix socket 上为 None);SOCKS 模块本身不记录握手失败。在关联内部,逐包的问题(被丢弃的数据报、失败的发送)以 debug 级别记录或直接忽略,不会结束关联。
serve 不 spawn 任何 task。它拥有的一切都在它自己的 future 中:控制流、上游流、hub socket、数据报 link 和各个缓冲区。丢弃该 future(例如某个 generation(一代实例)关闭时)会把它们一起关闭,不会留下需要清理的 task。
| 常量 | 值 | 位置 | 控制内容 |
|---|---|---|---|
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
整个服务端握手,从第一个字节到请求解析完成。 |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
CONNECT 空闲保护窗口;关联空闲计时器。 |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
collect_prefix 等待首批字节的时长。 |
SNIFF_LIMIT |
4 KiB | protocols/src/sniff/mod.rs |
collect_prefix 最多收集的字节数。 |
RELAY_BUF |
16 KiB | protocols/src/socks/server.rs |
CONNECT 中继每个方向的缓冲区。 |
HUB_BUF |
64 KiB | protocols/src/socks/server.rs |
关联的两个缓冲区(up、down)各自的大小。 |
RECV_BUF |
64 KiB | protocols/src/socks/udp_link.rs |
客户端 link 的接收缓冲区。 |
STAGING_RESERVE |
528 字节 | protocols/src/socks/codec.rs |
一次 SocksConnect 调用可能需要的暂存空间。 |
AddressCodec::MAX_LEN |
259 字节 | protocols/src/helpers/address.rs |
含端口的 SOCKS 地址编码的最大长度。 |
read_until_null 上限 |
512 字节 | protocols/src/socks/protocol.rs |
SOCKS4 USERID 和 SOCKS4a 域名。 |
SOCKS_BUF |
16 KiB | app/src/outbound/mod.rs |
app 中 SOCKS 出站的客户端运行时缓冲区。 |
可复用的关联回复缓冲区和客户端的 scratch 缓冲区初始为 2048 字节,按需增长。两个控制流 sink 都是 256 字节。
在 Etemenanki workspace 中运行本模块的单元测试和 pipeline 测试:
cargo test -p etemenanki-protocols --lib sockscargo test -p etemenanki-protocols --test pipeline sockscargo test -p etemenanki-app --test integration e2e_udp_route| 文件 | 测试 | 覆盖内容 |
|---|---|---|
protocols/tests/unit/socks/protocol.rs |
udp_encoding_roundtrip、read_username_password_ok、read_username_password_err、read_until_null_ok、read_until_null_err |
线上格式原语,移植自 Xray-core 的 SOCKS 测试。 |
client_handshake_messages_round_trip_through_slices |
所有客户端构造器和解析器,包括输入过短时的 NeedMore 和全零的 UDP ASSOCIATE 请求。 |
|
endpoint_sees_through_ipv4_mapping_and_ignores_flow_info |
endpoint 把 IPv4-mapped 地址与对应的 IPv4 地址视为相等,忽略 IPv6 flow info,同时仍能区分端口、IP 和地址族。 |
|
protocols/tests/unit/socks/server.rs(从 server.rs 挂载) |
only_the_control_peer_is_heard_and_its_first_datagram_pins_the_port |
另一个 IP 从不被接纳;在固定之前对端的任何端口都被接纳;第一次固定一直有效。 |
an_ipv4_mapped_address_is_the_ipv4_one |
mapped 形式的对端或发送方与其 IPv4 形式匹配,已固定的客户端保持 hub 所见的形式。 | |
a_request_naming_the_peer_pins_its_port_up_front |
给出对端及端口时只接纳该端口;给出对端但端口为 0 时不固定任何端口。 |
|
a_request_naming_any_other_source_is_set_aside |
IPv6 环回地址、局域网地址、带端口的未指定地址、另一台主机和域名都会被搁置,只有对端被接纳。 | |
over_a_unix_socket_the_request_must_name_the_exact_source |
没有对端时,凡是不满足“已指定的 IP 加非零端口”的请求都被拒绝,而确切的来源只接纳它自己。 | |
a_relay_that_cannot_hear_the_client_is_refused |
针对 IPv4、IPv6、::、0.0.0.0 和 IPv4-mapped hub 的 hears,分别在 TCP 和 Unix socket 上。 |
|
protocols/tests/unit/socks/codec.rs |
anonymous_connect_takes_two_rounds |
无凭据的 SocksConnect:暂存的字节、NeedMore、消费的字节数、应答之后的数据。 |
credentials_add_a_round_and_a_refusal_is_an_error |
凭据轮次、被拒绝的请求、被拒绝的账户,以及 0xFF 方法应答。 |
|
protocols/tests/pipeline/socks.rs |
new_server_vs_new_client_tcp |
带凭据的 SocksInbound 对 SocksConnect,回显 100 000 字节并干净地半关闭。嗅探开启且目标是 IP,因此会走嗅探路径的提前应答,并收集到前缀。 |
new_server_refuses_an_unreachable_target_after_trying |
嗅探关闭时,关闭的端口产生一个拒绝应答,客户端看到的是 server rejects request。 |
|
new_server_vs_new_client_udp |
SocksInbound 对 SocksUdpLink,包括一个 1400 字节的载荷。 |
|
udp_association_ignores_another_ip |
来自 127.0.0.2 的数据报,无论在客户端首个数据报之前还是之后发送,都不会被转发或得到回复。 |
|
udp_association_ignores_another_port_once_pinned |
第一个数据报之后,客户端 IP 上的另一个 socket 不会被转发。 | |
udp_association_holds_to_the_port_the_request_names |
给出客户端地址和端口的请求会把同一 IP 上先发送的邻居挡在外面。 | |
udp_association_sets_aside_a_source_it_cannot_hold_to |
IPv4 客户端给出 [::1]:0 的请求被批准,并为该客户端中继。 |
|
udp_association_is_not_widened_by_the_request |
给出另一台主机的请求不会让那台主机进来。 | |
udp_association_over_a_unix_socket_needs_its_exact_source |
在 Unix socket 上,0.0.0.0:0 以 0x02 被拒绝且连接关闭;确切的来源被批准,邻居的数据报不被接收。 |
|
udp_association_refuses_a_relay_that_cannot_hear_the_client |
IPv6 的 udp_bind 配上 IPv4 客户端时,以 0x02 拒绝且连接关闭。 |
|
udp_link_ignores_datagrams_not_from_the_relay |
对一个手写的服务端,来自另一个 socket 的格式正确的数据报不会被当作回复。 | |
udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay |
绑定在 :: 上的 link 能接收回复以 IPv4-mapped 形式到达的 IPv4 中继。 |
|
concepts/src/relay.rs |
bidirectional_relays_both_ways_and_half_closes |
缓冲区小于载荷时的 BidirectionalConnection。 |
app/tests/unit/inbound.rs |
socks_udp_over_a_unix_socket_needs_udp_bind |
构建时拒绝 Unix socket 上未设 udp_bind 的 UDP。 |
app/tests/integration/e2e_udp_route.rs |
one_association_routes_each_peer_separately、replies_from_several_peers_merge_back_correctly |
同一个关联由 app 的 FanOutLink 逐包路由,回复归属到正确的对端。 |
app/tests/integration/e2e_unix.rs |
socks_over_a_unix_socket_relays_and_cleans_up |
通过真实二进制在 Unix socket 上执行 CONNECT。 |
udp_association_ignores_another_ip、udp_association_is_not_widened_by_the_request 和 udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay 标注了 #[cfg(target_os = "linux")],因为它们依赖 127.0.0.2 是环回地址或依赖双栈 socket。udp_association_over_a_unix_socket_needs_its_exact_source 标注了 #[cfg(unix)],e2e_unix.rs 只在 Unix 上编译(#![cfg(unix)])。许多其他 app 集成测试以 SOCKS 入站作为入口,因此也间接覆盖了它。
目前没有测试覆盖以下内容:握手期限、中继空闲保护、握手写出的拒绝应答、嗅探路径什么也没收到的情形,以及连接错误映射到的应答码(new_server_refuses_an_unreachable_target_after_trying 只检查收到了拒绝应答)。修改其中任何一项都需要新增测试。