Rust in Everything
基于 hyper-util 0.1.21

tunnel error: unsuccessful

主线位置:客户端主线上建连接的一步。Tunnel 连上 HTTP 代理、发出 CONNECT host:port,读到的回应不是 200 也不是 407 时报这句话。reqwest 经 HTTP 代理访问 HTTPS 目标时走的也是这里。

flowchart TB
  send["写出 CONNECT host:port"] --> read["读进 8192 字节缓冲,httparse 解析"]
  read --> st{"解析结果"}
  st -->|"Partial 且缓冲满"| tl["ProxyHeadersTooLong"]
  st -->|Partial| read
  st -->|"Err:不是 HTTP 回应"| un["TunnelUnsuccessful"]
  st -->|"Complete 200"| ok["返回连接,头部后的字节丢弃"]
  st -->|"Complete 407"| auth["ProxyAuthRequired"]
  st -->|"Complete 其他状态码"| un
  un --> disp["Display:tunnel error: unsuccessful,没有 source"]

复现

假代理读掉 CONNECT 请求后回一段固定的内容。legacy Client 用 Tunnel 包默认的 HttpConnector,请求 https://example.test/。四种回应:

  let cases: [(&str, &'static [u8]); 4] = [
    ("403", b"HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n"),
    ("502", b"HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\n\r\n"),
    ("407", b"HTTP/1.1 407 Proxy Authentication Required\r\ncontent-length: 0\r\n\r\n"),
    ("garbage", b"SSH-2.0-OpenSSH_9.9\r\n\r\n"),
  ];
  for (label, answer) in cases {
    let port = proxy(answer).await;
    let c = Client::builder(TokioExecutor::new())
      .build::<_, Empty<Bytes>>(Tunnel::new(format!("http://127.0.0.1:{port}").parse().unwrap(), HttpConnector::new()));
    let e = c.get("https://example.test/".parse().unwrap()).await.unwrap_err();
    println!("{label:>8}  Tunnel: {}", chain(&e)); // Display, then each source()
    // ...
  }
     403  Tunnel: client error (Connect) <- tunnel error: unsuccessful
     502  Tunnel: client error (Connect) <- tunnel error: unsuccessful
     407  Tunnel: client error (Connect) <- tunnel error: proxy authorization required
 garbage  Tunnel: client error (Connect) <- tunnel error: unsuccessful

被代理的策略拒绝(403)、代理连不上目标(502)、端口其实不是 HTTP 代理(garbage),三种完全不同的问题报的是同一句话;{:?} 也只多一个 TunnelUnsuccessful。只有 407 有自己的变体。

在哪里产生

tunnel 读回应的循环,第 217 行 起:

        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),
            },
            // ...
            Err(_) => return Err(TunnelError::TunnelUnsuccessful),
        }

TunnelUnsuccessful 是一个不带数据的变体,res.code 和 httparse 的错误都没有存下来。Display 写 tunnel error: unsuccessful,source 对它返回 None。代理在回应 body 里写的说明(很多代理会写「blocked by policy」之类)也没有读。

这段解析是 0.1.21 的 #315 换成 httparse 的。之前是前缀匹配,HTTP/1.1 2000 OK 也会被当作 200;现在格式不对的回应走 Err(_) 分支,同样归入 unsuccessful。

修法

407:有自己的变体 proxy authorization required,用 Tunnel::with_auth 给 Proxy-Authorization。reqwest 从代理 URL 的 user:pass@ 生成它,见 reqwest 卷的 代理。

其他状态码:Tunnel 没有办法拿到。排查时最直接的是看代理自己的日志,或者用 curl -v -x http://代理 https://目标 看 CONNECT 的回应。代码里要区分,可以不用 Tunnel,改用 hyper 自己的 HTTP/1 客户端发 CONNECT,再用 upgrade 拿到隧道:

// The CONNECT goes through hyper's HTTP/1 client; the status code is ours to see.
async fn connect_via_hyper(proxy_port: u16, authority: &str) -> Result<hyper::upgrade::Upgraded, String> {
  let tcp = TcpStream::connect(("127.0.0.1", proxy_port)).await.map_err(|e| e.to_string())?;
  let (mut tx, conn) = http1::handshake::<_, Empty<Bytes>>(TokioIo::new(tcp)).await.map_err(|e| e.to_string())?;
  tokio::spawn(conn.with_upgrades());
  let req = Request::connect(authority).header("host", authority).body(Empty::new()).unwrap();
  let res = tx.send_request(req).await.map_err(|e| e.to_string())?;
  if res.status() != http::StatusCode::OK {
    return Err(format!("proxy answered {}", res.status()));
  }
  hyper::upgrade::on(res).await.map_err(|e| e.to_string())
}

同样四种回应,外加一个在 200 后面紧跟目标问候的代理:

     403  hyper CONNECT: proxy answered 403 Forbidden
     502  hyper CONNECT: proxy answered 502 Bad Gateway
     407  hyper CONNECT: proxy answered 407 Proxy Authentication Required
 garbage  hyper CONNECT: invalid HTTP version parsed
   early  hyper CONNECT first read: "220 smtp.example.test ESMTP ready\r\n"

状态码都在;不是 HTTP 的回应报的是 hyper 的解析错误(和 invalid HTTP version parsed 同一句话)。最后一行是 Tunnel 做不到的:hyper 的 upgrade 把头部之后已经读进缓冲的字节放在 Upgraded 前面(HTTP 升级),第一次读就拿到了问候;Tunnel 在同样的情况下把它丢了,第一次读要等到目标再发数据,见 代理连接器。

Upgraded 实现了 hyper 的 Read + Write,但没有实现 Connection。要把它接回 legacy Client,得写一个连接器(Service<Uri>),返回一个包着 Upgraded 并实现 Connection 的类型;TLS 握手在它上面再做。这比 Tunnel 多几十行代码,换来的是状态码和早到的字节。

小结

  • tunnel error: unsuccessful 表示代理对 CONNECT 回了 200、407 以外的状态码,或者回的根本不是 HTTP。
  • 状态码不进错误,source() 为空,{:?} 也只有 TunnelUnsuccessful。
  • 407 有单独的 proxy authorization required,用 with_auth 修。
  • 要在代码里拿到状态码,用 hyper 的 HTTP/1 客户端发 CONNECT 再 upgrade;这样早到的字节也不会丢。

💬 关联讨论

发起讨论
加载中...