跳转到内容

SOCKS

源码文件:28 个 · 核对版本 Etemenanki 596916d
  • Etemenanki/protocols/src/socks/mod.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/handshake.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/socks/codec.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/src/socks/config.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/sniff/collector.rs
  • Etemenanki/concepts/src/relay.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/codec.rs
  • Etemenanki/app/tests/unit/inbound.rs
  • Etemenanki/app/tests/integration/e2e_udp_route.rs
  • Etemenanki/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 负责。

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。

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]) -> Bytes
pub 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 是会复制数据的版本。

protocols/src/socks/protocol.rs
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) -> bool

ExpectedSender 是一个 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 字节(含端口)。

字段 长度 含义
VN 1 0x04。
CD 1 命令。只有 0x01(CMD_TCP_CONNECT)会被批准。
DSTPORT 2 目标端口。
DSTIP 4 目标 IPv4 地址。首个八位组为 0 时,该请求是 SOCKS4a,用户 ID 之后跟着一个域名。
USERID 可变 直到 NUL 为止的字节。服务端读出后丢弃。
DOMAIN 可变 仅 SOCKS4a:直到 NUL 为止的字节,作为目标域名。

read_until_null 读取以 NUL 结尾的字段,并按有损 UTF-8 解码。它最多保留 512 字节:若第 513 个字节不是 NUL,则报错 buffer overrun。

字段 长度 含义
VER 1 0x05。
NMETHODS 1 其后方法字节的个数。
METHODS NMETHODS 客户端提供的方法。

服务端不做协商。配置的 SocksAuth 固定了唯一的方法:None 对应 0x00,Password 对应 0x02。如果客户端的列表中包含该方法,服务端就选择它;否则服务端应答 0xFF 并关闭连接。

字段 长度 含义
VER 1 子协商版本,0x01。
ULEN 1 用户名长度。
UNAME ULEN 用户名。
PLEN 1 密码长度。
PASSWD PLEN 密码。

read_username_password 在查找账户之前,先按有损 UTF-8 解码两个字符串。在客户端,encode_userpass 按单字节长度字段的要求,把用户名和密码各截断到 255 字节。

字段 长度 含义
VER 1 0x05。
CMD 1 命令,见下表。
RSV 1 0x00。
ATYP 1 地址类型。
DST.ADDR 可变 CONNECT 的目标地址。对 UDP ASSOCIATE,RFC 1928 规定它是客户端数据报的来源地址;本服务端把它当作提示(见 UDP ASSOCIATE)。
DST.PORT 2 端口,两种含义同上。
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 拒绝。
字段 长度 含义
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 路径如下:

  1. 如果 auth 为 Password,应答 91 并失败(SOCKS4 not allowed when auth is required)。SOCKS4 没有密码,所以受密码保护的入站会拒绝所有 SOCKS4 客户端。
  2. 读取 DSTPORT、DSTIP 和 USERID。DSTIP 的首个八位组为 0 时,再读取 SOCKS4a 域名。
  3. 如果 CD 不是 CONNECT,应答 91 并失败(unsupported SOCKS4 command …)。这项检查之前会读完整个请求。

每个拒绝应答都在错误返回之前写出。已批准的请求此时还不作答:何时作答由驱动决定。

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)时,驱动会:

  1. 先写出成功应答;
  2. 在 collect_prefix 中收集客户端的首批字节:Collector 持续读取,直到某个嗅探器识别出 TLS 或 HTTP(Verdict::Found)、填满 SNIFF_LIMIT(4 KiB)、超过 SNIFF_TIMEOUT(300 ms)或读取失败;
  3. 把结果放入 Flow::sniffed 并发起连接;
  4. 用 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 秒之间结束,具体取决于空闲从窗口中的哪个位置开始。

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 按以下步骤执行:

  1. 选择 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 拒绝这种配置,因此这条路径只是兜底。
  2. 决定接收谁的数据报。 ExpectedSender::new(source, declared, bind_ip) 应用关联接收谁的数据报中的规则。出错时,驱动写出 write_refusal(…, 0x02)(STATUS_NOT_ALLOWED),并以该错误的消息返回 PermissionDenied,此时尚未绑定任何 hub socket。
  3. 绑定 hub。 驱动在 bind_ip 上以端口 0 绑定一个新的 UdpSocket,因此每个关联都有自己的临时端口。绑定失败时,错误会在写出任何应答之前返回。成功应答中给出 bind_ip 和该端口。
  4. 中继。 同一个 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 也随之被丢弃。

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 是由客户端运行时驱动的客户端 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::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
情形 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 socks
cargo test -p etemenanki-protocols --test pipeline socks
cargo 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 只检查收到了拒绝应答)。修改其中任何一项都需要新增测试。