Rust in Everything
基于 hyper-util 0.1.21

代理连接器:Tunnel 与 SOCKS

主线位置:客户端主线上「建连接」那一步的一种连法。legacy Client 向连接器要一条到目标的连接;代理连接器先让内层连接器连到代理,再在这条连接上做一次握手(HTTP CONNECT 或 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::Innerfailed to create underlying connection内层连接器连代理失败
SocksError::Ioio error during SOCKS handshake握手读写失败、代理提前关闭
SocksError::Serializefailed serialize request上面的 249 字节上限
SocksError::Parsingfailed parsing server response代理的回应不合协议
SocksV5Error::Authcredentials not accepted 等三种方法不支持、方法不一致、密码错
SocksV5Error::Commandconnection refused、host unreachable 等代理回了非 0 的 REP
TunnelError::ProxyAuthRequiredtunnel error: proxy authorization required代理回 407
TunnelError::TunnelUnsuccessfultunnel error: unsuccessful其他状态码或解析失败
TunnelError::ProxyHeadersTooLongtunnel 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(),打日志用 {:?}。

💬 关联讨论

发起讨论
加载中...