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 有自己的变体。
在哪里产生
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;这样早到的字节也不会丢。