代理连接器:Tunnel 与 SOCKS
主线位置:客户端主线上「建连接」那一步的一种连法。legacy
Client向连接器要一条到目标的连接;代理连接器先让内层连接器连到代理,再在这条连接上做一次握手(HTTPCONNECT或 SOCKS),握手成功后把同一条连接交回去,Client当它是直连的目标。
flowchart TB client["legacy Client:connect_to(dst)"] --> outer["SocksV5 / SocksV4 / Tunnel:call(dst)"] outer -->|"call(proxy_dst)"| inner["内层连接器:HttpConnector"] inner --> tcp["TCP 连到代理"] tcp --> hs["在这条连接上握手:CONNECT 或 SOCKS"] outer --> hs hs -->|成功| io["同一条 IO 交回 Client"] io --> h1["HTTP/1 或 HTTP/2 握手,连接进池"]
本页站在「直接用 hyper-util」的角度讲这三个连接器的协议实现。reqwest 什么时候走代理、NO_PROXY 和系统代理怎么读、认证头放在哪,见 reqwest 卷的 代理;hyper-util 里负责「这个目标走不走代理」的 client::proxy::Matcher 也在那一页讲。连接器本身的约定(Service<Uri>、Connection、Connected)见 HttpConnector。
实验平台:M2 Max、macOS 27.2、rustc 1.99.0,hyper 1.12.0、hyper-util 0.1.21、tokio 1.54.1。代理都是自己写的几十行假代理,只监听 127.0.0.1。
分层:三个包装器
三个类型都在 connect/proxy/mod.rs 里导出:
pub use self::socks::{SocksV4, SocksV5};
pub use self::tunnel::Tunnel;
connect 模块里的 pub mod proxy; 没有 feature 门,所以只要打开 client-legacy 就有。client-proxy 这个 feature 管的是另一个模块 client::proxy(Matcher,决定走不走代理),和这里的连接器无关。
三者的形状一样:持有一个内层连接器 C 和代理地址 proxy_dst,自己实现 Service<Uri>。以 Tunnel::call 为例:
fn call(&mut self, dst: Uri) -> Self::Future {
let connecting = self.inner.call(self.proxy_dst.clone());
let headers = self.headers.clone();
Tunneling {
fut: Box::pin(async move {
let conn = connecting
.await
.map_err(|e| TunnelError::ConnectFailed(e.into()))?;
tunnel(
conn,
dst.host().ok_or(TunnelError::MissingHost)?,
dst.port().map(|p| p.as_u16()).unwrap_or(443),
&headers,
)
.await
}),
// ...
}
}
要点有三个:
- 内层连接器被调用时拿到的是代理的 URI,不是目标。内层是
HttpConnector时,它会照常检查 scheme、补默认端口、做 DNS,这些规则都作用在代理地址上,下文的两个坑就从这里来。 - 返回的
Response类型就是内层的C::Response(比如TokioIo<TcpStream>),握手不包任何东西。所以连接的Connected信息也原样来自内层:is_proxied()是false,Client对这条连接发 origin-form 的请求行,这对隧道来说正好是对的。 - 目标 URI 没写端口时,三个连接器都按 443,不看 scheme。
SocksV5在 第 263 行,SocksV4在 第 132 行,Tunnel在上面第 145 行。
错误类型是 TunnelError 和 SocksError<C>。两个都是 pub enum,但所在的 tunnel、socks 模块是私有的,proxy/mod.rs 也没有重新导出,用户代码写不出类型名,只能通过 <Tunnel<C> as Service<Uri>>::Error 这样的关联类型拿到它,或者装进 Box<dyn Error> 以后看 Display / Debug。
先跑一遍
下面这段程序把三种常见情况各跑一次:SocksV5 直接包默认的 HttpConnector;关掉 enforce_http 再试;代理 URI 不写端口;Tunnel 遇到回 403 的代理。SOCKS5 假代理只实现无认证的 CONNECT,打印它收到的目标,然后回一个固定的 HTTP 响应。
// Cargo.toml: hyper-util = { version = "=0.1.21", features = ["full"] }, hyper 1 full, tokio =1.54.1 full
fn chain(e: &(dyn StdError + 'static)) -> String {
let mut out = format!("{e}");
let mut cur = e.source();
while let Some(s) = cur {
out.push_str(&format!(" <- {s}"));
cur = s.source();
}
out
}
#[tokio::main]
async fn main() {
let sp = socks5_server().await; // logs "CONNECT host=.. port=..", then answers one request
let proxy: http::Uri = format!("socks5://127.0.0.1:{sp}").parse().unwrap();
println!("A: SocksV5 over default HttpConnector (enforce_http=true)");
let c = Client::builder(TokioExecutor::new()).build(SocksV5::new(proxy.clone(), HttpConnector::new()));
match c.request(req("http://example.test/")).await {
Ok(r) => println!(" ok {}", r.status()),
Err(e) => println!(" err: {e} | chain: {} | debug: {e:?}", chain(&e)),
}
println!("B: enforce_http(false), target http://example.test/ (no port)");
let mut hc = HttpConnector::new();
hc.enforce_http(false);
let c = Client::builder(TokioExecutor::new()).build(SocksV5::new(proxy.clone(), hc.clone()));
// ... same match
println!("B2: proxy uri without port socks5://127.0.0.1");
let c = Client::builder(TokioExecutor::new()).build(SocksV5::new("socks5://127.0.0.1".parse().unwrap(), hc.clone()));
// ... same match, with {:?}
println!("C: Tunnel, proxy answers 403");
let pp = connect_proxy("403 Forbidden").await; // logs what it got, answers with the status
let t = Tunnel::new(format!("http://127.0.0.1:{pp}").parse().unwrap(), HttpConnector::new());
let c = Client::builder(TokioExecutor::new()).build(t);
// ... same match
}
输出(跑了 3 次,除端口号外相同):
A: SocksV5 over default HttpConnector (enforce_http=true)
err: client error (Connect) | chain: client error (Connect) <- SOCKS error: failed to create underlying connection | debug: hyper_util::client::legacy::Error(Connect, Inner(ConnectError("invalid URL, scheme is not http")))
B: enforce_http(false), target http://example.test/ (no port)
socks: greeting [5, 1, 0]
socks: CONNECT host=example.test port=443
ok 200 OK
B2: proxy uri without port socks5://127.0.0.1
err: client error (Connect) <- SOCKS error: failed to create underlying connection | debug hyper_util::client::legacy::Error(Connect, Inner(ConnectError("tcp connect error", 127.0.0.1:80, Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })))
C: Tunnel, proxy answers 403
proxy got: "CONNECT example.test:443 HTTP/1.1\r\nHost: example.test:443\r\n\r\n"
err: client error (Connect) <- tunnel error: unsuccessful | debug hyper_util::client::legacy::Error(Connect, TunnelUnsuccessful)
逐条看:
- A:默认的
HttpConnector开着enforce_http,只接受http://的 URI,而它拿到的是代理的socks5://...,在连接之前就拒绝了。外面只看得到SOCKS error: failed to create underlying connection,原因只在Debug里。这个坑单独成页:SOCKS error: failed to create underlying connection。 - B:目标是
http://example.test/,没写端口,SOCKS 请求里的端口是 443,不是 80。假代理不管端口,所以请求成功了;真代理会去连目标的 443,HTTP 明文打到 HTTPS 端口上。 - B2:代理 URI 没写端口,内层
HttpConnector按http的默认端口去连127.0.0.1:80,不是 SOCKS 惯用的 1080。 - C:
Tunnel对http://目标也发CONNECT example.test:443。403 变成了tunnel error: unsuccessful,状态码没有留下,见坑页 tunnel error: unsuccessful。
reqwest 用户碰不到 B、B2 和「http 目标连 443」:reqwest 的 socks::connect 自己按 scheme 补好目标端口(https 443,其他 80)再拼出 dst_uri,代理 URL 在 into_proxy 里就补了 1080;它只对 https 目标走 Tunnel(connect_via_proxy),缺省的 443 对 https 正好是对的。直接用 hyper-util 的连接器时,这几件事要自己做:目标 URI 写全端口,代理 URI 写全端口,内层连接器关掉 enforce_http。
Tunnel:手拼 CONNECT,httparse 读回应
tunnel 是一个普通的 async 函数。请求是手拼的:
let mut buf = format!(
"\
CONNECT {host}:{port} HTTP/1.1\r\n\
Host: {host}:{port}\r\n\
"
)
.into_bytes();
// ... Proxy-Authorization 或 with_headers 给的额外头,再加一个空行
认证头和额外头来自 with_auth / with_headers,with_auth 会把值标成 sensitive。写完以后读回应,用的是一块 8192 字节的栈缓冲,每读一次就交给 httparse 解析一次:
let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
let mut res = httparse::Response::new(&mut headers);
match res.parse(&buf[..pos]) {
Ok(httparse::Status::Complete(_)) => match res.code {
Some(200) => return Ok(conn),
Some(407) => return Err(TunnelError::ProxyAuthRequired),
_ => return Err(TunnelError::TunnelUnsuccessful),
},
Ok(httparse::Status::Partial) => {
if pos == buf.len() {
return Err(TunnelError::ProxyHeadersTooLong);
}
}
Err(_) => return Err(TunnelError::TunnelUnsuccessful),
}
用 httparse 是 0.1.21 的 #315。之前的实现用 starts_with(b"HTTP/1.1 200") 判断成功,HTTP/1.1 2000 OK 也算 200;又要求整块缓冲以 \r\n\r\n 结尾,代理在 200 后面紧跟了目标的数据时就一直等到 EOF。现在这两点都修了,但还剩下几件事:
- 只认 200 和 407,其他状态码(403、502……)和解析失败一律是
TunnelUnsuccessful,状态码不进错误。 - 握手没有超时。代理接受了 TCP 却一直不回,
tunnel就一直等;要靠外面的connect_timeout或整个请求的超时。 Status::Complete(n)里的n(头部结束的位置)被忽略了,return Ok(conn)只交回连接,缓冲里头部之后的字节被丢掉。
最后一点上游的测试 established_with_early_data 只断言「不报错」,没看数据去了哪里。实测一下:假代理在同一次写里回 200 和一行 SMTP 式的问候(目标先说话的协议都是这样),300ms 后再写一行 LATER:
tokio::spawn(async move {
let (mut s, _) = l.accept().await.unwrap();
let mut b = [0u8; 1024];
let n = s.read(&mut b).await.unwrap();
println!("proxy got: {:?}", String::from_utf8_lossy(&b[..n]));
// 200 plus a server-first banner in one write
s.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n220 smtp.example.test ESMTP ready\r\n").await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
s.write_all(b"LATER\r\n").await.unwrap();
// ...
});
let mut t = Tunnel::new(format!("http://127.0.0.1:{port}").parse().unwrap(), HttpConnector::new());
let conn = t.call("http://smtp.example.test:25".parse().unwrap()).await.unwrap();
let mut io = TokioIo::new(conn);
let start = std::time::Instant::now();
let mut buf = [0u8; 256];
let n = io.read(&mut buf).await.unwrap();
println!("first read after {}ms: {:?}", start.elapsed().as_millis(), String::from_utf8_lossy(&buf[..n]));
proxy got: "CONNECT smtp.example.test:25 HTTP/1.1\r\nHost: smtp.example.test:25\r\n\r\n"
first read after 302ms: "LATER\r\n"
问候行没了,第一次读等了 300ms 才拿到后来的 LATER。对 HTTP 来说影响不大:客户端先说话,正常的代理不会在请求之前塞数据进来。但如果把 Tunnel 当作通用的 TCP 隧道(SMTP、SSH 这类目标先发问候的协议),问候会丢,客户端会一直等。
SOCKS5:六个状态
SocksConfig::execute 是一个显式的状态机,State 有六个值:
enum State {
SendingNegReq,
ReadingNegRes,
SendingAuthReq,
ReadingAuthRes,
SendingProxyReq,
ReadingProxyRes,
}
stateDiagram-v2 [*] --> SendingNegReq SendingNegReq --> ReadingNegRes: 普通 ReadingNegRes --> SendingAuthReq: 普通,方法 0x02 ReadingNegRes --> SendingProxyReq: 普通,方法 0x00 SendingAuthReq --> ReadingAuthRes: 普通 ReadingAuthRes --> SendingProxyReq: 普通 SendingProxyReq --> ReadingProxyRes: 普通 SendingNegReq --> SendingAuthReq: optimistic,有密码 SendingNegReq --> SendingProxyReq: optimistic,无密码 SendingAuthReq --> SendingProxyReq: optimistic SendingProxyReq --> ReadingNegRes: optimistic ReadingNegRes --> ReadingAuthRes: optimistic,方法 0x02 ReadingNegRes --> ReadingProxyRes: optimistic,方法 0x00 ReadingAuthRes --> ReadingProxyRes: optimistic ReadingProxyRes --> [*]: Success
普通路径一问一答:问候(只报一种方法:有密码报 0x02,没有报 0x00)、读选中的方法、发用户名密码、读结果、发 CONNECT、读结果。send_optimistically(true) 时三条请求连着写完,再按顺序读三条回应,省掉两个往返。
读回应的是 read_message。它先试着从已有的缓冲里解析,不够(ParsingError::Incomplete)才去读 socket,解析成功后只 advance 掉用过的字节,剩下的留给下一条消息:
loop {
let mut view = &buf[..];
match M::try_from(&mut view) {
Err(ParsingError::Incomplete) => {
let n = crate::rt::read(&mut conn, &mut tmp).await?;
// ... n == 0 时报错
buf.extend_from_slice(&tmp[..n]);
}
Err(err) => return Err(err.into()),
Ok(res) => {
let consumed = buf.len() - view.len();
buf.advance(consumed);
return Ok(res);
}
}
}
「先解析再读」是 0.1.21 的 #310:之前每次都先读,optimistic 模式下三条回应如果一次就全到了,第二条消息会去等一个永远不来的读。和 Tunnel 一样,最后一条 ProxyRes 之后缓冲里如果还有字节,它们随 recv_buf 一起被丢掉。
optimistic 对不支持它的代理
文档说很多服务端不支持 optimistic,但没说不支持时会怎样。不支持的方式取决于代理怎么读:按协议字段长度 read_exact 的代理,多来的字节留在 TCP 缓冲里,下一次读照样能拿到;一次 read 当作一条消息、多出来的直接扔掉的代理,就会丢掉后两条请求。两种假代理各跑一遍,都带用户名密码:
== ReadExact server, user/pass, send_optimistically(false)
ok
== ReadExact server, user/pass, send_optimistically(true)
ok
== ReadOnce server, user/pass, send_optimistically(false)
server read greeting: 3 bytes [5, 1, 2]
server read auth: 14 bytes [1, 5, 97, 108, 105, 99, 101, 6, 115, 51, 99, 114]
server read request: 19 bytes [5, 1, 0, 3, 12, 101, 120, 97, 109, 112, 108, 101]
ok
== ReadOnce server, user/pass, send_optimistically(true)
server read greeting: 36 bytes [5, 1, 2, 1, 5, 97, 108, 105, 99, 101, 6, 115]
timeout after 2s
第四组:三条请求共 36 字节,一次读进了代理的「问候」里;代理回了方法选择,然后去等认证请求,客户端在等认证结果,双方互等。客户端这边握手没有超时,timeout after 2s 是实验外面套的 tokio::time::timeout。不加超时就一直挂着。真实的代理软件是哪一种,本页没有实测。
用户名、密码、主机名合计不能超过 249 字节
execute 开头分了两块缓冲:
let mut recv_buf = BytesMut::with_capacity(513); // Max length of valid recievable message is 513 from Auth Request
let mut send_buf = BytesMut::with_capacity(262); // Max length of valid sendable message is 262 from Auth Response
注释的意思是「单条消息最长 262 字节」。但三条请求写进的是同一个 send_buf,每次从 send_buf.len() 往后追加(let start = send_buf.len();),从来不清空;而每条消息的 write_to_buf 都检查剩余容量,不够就报 SerializeError::WouldOverflow,不扩容。AuthenticationReq::write_to_buf 和 ProxyReq::write_to_buf:
if buf.capacity() - buf.len() < 3 + self.0.len() + self.1.len() {
return Err(SerializeError::WouldOverflow);
}
// ...
if buf.capacity() - buf.len() < 3 + addr_len {
return Err(SerializeError::WouldOverflow);
}
三条消息的长度分别是 3、3 + 用户名 + 密码、3 + 1 + 1 + 主机名 + 2,加起来不能超过 262,即用户名 + 密码 + 主机名 ≤ 249 字节;不带认证时主机名 ≤ 252。主机名是 IP 时 addr_len 固定(IPv4 7、IPv6 19),基本碰不到。实测(read_exact 的假代理,主机名 73 字节):
== user 5 bytes, pass 32 bytes, host 73 bytes
ok
== user 88 bytes, pass 88 bytes, host 73 bytes
ok
== user 88 bytes, pass 89 bytes, host 73 bytes
server greeting methods=[2]
server auth user_len=88 pass_len=89
err: SOCKS error: failed serialize request | debug: Serialize(WouldOverflow)
== no auth, host 252 bytes
ok
== no auth, host 253 bytes
err: SOCKS error: failed serialize request | debug: Serialize(WouldOverflow)
88 + 88 + 73 = 249 成功,多一个字节就失败。注意失败发生在认证之后:代理已经收到并接受了用户名密码,客户端到发 CONNECT 时才发现装不下,直接断开。0.1.20 的这段代码相同。
这个上限 reqwest 用户也会碰到:reqwest 0.13.5 把代理 URL 里的用户名密码原样交给 SocksV5::with_auth,socks5h:// 时主机名也原样交给代理。用一个 socks5h:// 代理、100 字节用户名、150 字节密码请求 http://example.test/:
user 20 bytes, pass 40 bytes, GET http://example.test/
socks: auth user_len=20 pass_len=40
ok 200 OK
user 100 bytes, pass 150 bytes, GET http://example.test/
socks: auth user_len=100 pass_len=150
socks: client hung up after auth
err: error sending request for url (http://example.test/) <- client error (Connect) <- error connecting to socks proxy <- SOCKS error: failed serialize request
有的代理服务把会话参数编码进用户名(地区、会话 ID 等),再加上长令牌当密码,接近这个长度并不罕见。错误文字只有 failed serialize request,看不出和长度有关。
local_dns 与主机名
local_dns(true) 时,域名在本地解析成 IP 再交给代理:
if self.local_dns {
let socket = (host, port)
.to_socket_addrs()?
.next()
.ok_or(SocksError::DnsFailure)?;
两点:只取第一个地址,不像 HttpConnector 那样逐个尝试(localhost 在这台机器上第一个结果是 ::1);用的是 std 的 ToSocketAddrs,也就是在当前线程上同步调 getaddrinfo,不像 GaiResolver 那样放进 spawn_blocking(见 HttpConnector)。在 current_thread 运行时上同时跑一个不停 yield_now 的任务,记下它两次被 poll 之间的最长间隔,连做 50 次握手(目标 localhost),3 次运行:
local_dns(false): 50 handshakes 19ms, longest gap between ticker polls 173us
local_dns(true): 50 handshakes 27ms, longest gap between ticker polls 3448us
local_dns(false): 50 handshakes 12ms, longest gap between ticker polls 76us
local_dns(true): 50 handshakes 21ms, longest gap between ticker polls 1404us
local_dns(false): 50 handshakes 13ms, longest gap between ticker polls 93us
local_dns(true): 50 handshakes 21ms, longest gap between ticker polls 1596us
解析 localhost 只要一两毫秒,运行时线程就被占了这么久。解析一个要走网络 DNS 的慢域名时,卡住的时间就是 DNS 的耗时;这个场景本页没有构造(实验不联网),结论来自源码。reqwest 的 socks5:// 用自己的解析器在外面解析好再传 IP,不走这里。
SocksV4 的 local_dns 同样同步解析,并且只挑第一个 IPv4 地址;不开 local_dns 时发的是 SOCKS4a(IP 写成 0.0.0.255,域名跟在后面)。目标是 IPv6 字面量时直接拒绝:
== SocksV4, IPv6 target
err: SOCKS error: IPV6 is not supported | debug: V4(IpV6)
错误:SocksError 没有 source
SocksError 的 Display 是「SOCKS error: + 一句话」,而 impl Error 是空的:
impl<C: std::fmt::Debug + std::fmt::Display> std::error::Error for SocksError<C> {}
没有 source()。Inner(C) 里装着内层连接器的错误、Io(io::Error) 里装着 IO 错误,沿 source() 走链的日志代码(包括 reqwest 的 Display 链)都走不到它们,只能靠 {:?}。变体 MissingPort 从来没有被构造:缺端口时用 443 顶上了。TunnelError 的 source 则实现了,ConnectFailed 和 Io 都能沿链看到原因。
| 错误 | Display | 什么时候 |
|---|---|---|
SocksError::Inner | failed to create underlying connection | 内层连接器连代理失败 |
SocksError::Io | io error during SOCKS handshake | 握手读写失败、代理提前关闭 |
SocksError::Serialize | failed serialize request | 上面的 249 字节上限 |
SocksError::Parsing | failed parsing server response | 代理的回应不合协议 |
SocksV5Error::Auth | credentials not accepted 等三种 | 方法不支持、方法不一致、密码错 |
SocksV5Error::Command | connection refused、host unreachable 等 | 代理回了非 0 的 REP |
TunnelError::ProxyAuthRequired | tunnel error: proxy authorization required | 代理回 407 |
TunnelError::TunnelUnsuccessful | tunnel error: unsuccessful | 其他状态码或解析失败 |
TunnelError::ProxyHeadersTooLong | tunnel error: proxy response headers too long | 回应头超过 8192 字节 |
小结
Tunnel、SocksV4、SocksV5是包着内层连接器的Service<Uri>,属于client-legacy,不需要client-proxy。内层连接器拿到的是代理的 URI:默认HttpConnector要关掉enforce_http,代理 URI 要写端口(否则按 80 连)。- 目标 URI 没写端口时一律按 443,
http://目标也一样。直接使用时把目标端口写全。 Tunnel只认 200 / 407,其他状态码都是unsuccessful;没有握手超时;200 之后同一批到达的字节被丢掉,目标先说话的协议会丢问候。- SOCKS5 的三条请求共用一个 262 字节的发送缓冲:用户名 + 密码 + 主机名超过 249 字节就报
failed serialize request,reqwest 用户同样会碰到。 send_optimistically遇到「一次读一条消息」的代理会互相等死,握手没有超时。local_dns在运行时线程上同步解析,只用第一个地址。SocksError没有source(),打日志用{:?}。