mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Skip Private Relay for the proxy engine's plain-HTTP upstream on macOS (#818)
This commit is contained in:
1 parent
603a4ce364
commit
c09ee5cc53
8 files changed
+1377
-35
No files matched your search
Generated
+4
@@ -11878,13 +11878,16 @@ name = "yaak-proxy"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"block2 0.6.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"dispatch2",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"libc",
|
||||||
"log 0.4.34",
|
"log 0.4.34",
|
||||||
"pem",
|
"pem",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -11896,6 +11899,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ tokio-rustls = "0.26"
|
|||||||
pem = "3"
|
pem = "3"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
block2 = "0.6"
|
||||||
|
dispatch2 = "0.3"
|
||||||
|
libc = "0.2"
|
||||||
|
tower-service = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hyper-util = { version = "0.1", features = ["server-auto"] }
|
hyper-util = { version = "0.1", features = ["server-auto"] }
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ mod connection;
|
|||||||
mod decode;
|
mod decode;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
mod messages;
|
mod messages;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod nw;
|
||||||
mod request;
|
mod request;
|
||||||
mod sse;
|
mod sse;
|
||||||
mod tunnel;
|
mod tunnel;
|
||||||
@@ -21,6 +23,7 @@ use std::time::SystemTime;
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
use hyper_util::client::legacy::Client;
|
use hyper_util::client::legacy::Client;
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
use hyper_util::client::legacy::connect::HttpConnector;
|
use hyper_util::client::legacy::connect::HttpConnector;
|
||||||
use hyper_util::rt::TokioExecutor;
|
use hyper_util::rt::TokioExecutor;
|
||||||
use rustls::ClientConfig;
|
use rustls::ClientConfig;
|
||||||
@@ -242,7 +245,7 @@ pub(crate) struct Engine {
|
|||||||
events: std_mpsc::Sender<ProxyEvent>,
|
events: std_mpsc::Sender<ProxyEvent>,
|
||||||
next_id: AtomicU64,
|
next_id: AtomicU64,
|
||||||
next_connection: AtomicU64,
|
next_connection: AtomicU64,
|
||||||
http_client: Client<HttpConnector, BoxBody>,
|
http_client: HttpClient,
|
||||||
upstream_tls: Arc<ClientConfig>,
|
upstream_tls: Arc<ClientConfig>,
|
||||||
upstream_tls_h1: Arc<ClientConfig>,
|
upstream_tls_h1: Arc<ClientConfig>,
|
||||||
passthrough: Mutex<HashSet<String>>,
|
passthrough: Mutex<HashSet<String>>,
|
||||||
@@ -250,8 +253,8 @@ pub(crate) struct Engine {
|
|||||||
/// Offer HTTP/2 to clients in intercepted TLS.
|
/// Offer HTTP/2 to clients in intercepted TLS.
|
||||||
http2: AtomicBool,
|
http2: AtomicBool,
|
||||||
websocket_capture: bool,
|
websocket_capture: bool,
|
||||||
/// `Via` value marking requests whose URL the engine rebuilt from `Host`, unique to this run,
|
/// `Via` value marking the plain-HTTP requests the engine forwards, unique to this run, so one
|
||||||
/// so one that comes back to the engine is refused instead of forwarded again.
|
/// that comes back to the engine is refused instead of forwarded again.
|
||||||
via: HeaderValue,
|
via: HeaderValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +308,21 @@ fn upstream_tls_config(
|
|||||||
Ok(Arc::new(config))
|
Ok(Arc::new(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends plain HTTP upstream. On macOS it connects through Network.framework, so Private Relay
|
||||||
|
/// can't route the engine's own port-80 connections back into the engine (see [`nw`]).
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
type HttpClient = Client<nw::Connector, BoxBody>;
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
type HttpClient = Client<HttpConnector, BoxBody>;
|
||||||
|
|
||||||
|
fn http_client() -> HttpClient {
|
||||||
|
let builder = Client::builder(TokioExecutor::new());
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return builder.build(nw::Connector);
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
builder.build_http()
|
||||||
|
}
|
||||||
|
|
||||||
fn via_marker() -> HeaderValue {
|
fn via_marker() -> HeaderValue {
|
||||||
let run = RandomState::new().build_hasher().finish();
|
let run = RandomState::new().build_hasher().finish();
|
||||||
HeaderValue::try_from(format!("1.1 yaak-proxy-{run:016x}")).unwrap()
|
HeaderValue::try_from(format!("1.1 yaak-proxy-{run:016x}")).unwrap()
|
||||||
@@ -327,7 +345,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
|||||||
events: event_tx,
|
events: event_tx,
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
next_connection: AtomicU64::new(1),
|
next_connection: AtomicU64::new(1),
|
||||||
http_client: Client::builder(TokioExecutor::new()).build_http(),
|
http_client: http_client(),
|
||||||
upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?,
|
upstream_tls: upstream_tls_config(&roots, &[b"h2", b"http/1.1"])?,
|
||||||
upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?,
|
upstream_tls_h1: upstream_tls_config(&roots, &[b"http/1.1"])?,
|
||||||
passthrough: Mutex::new(HashSet::new()),
|
passthrough: Mutex::new(HashSet::new()),
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,7 @@ use hyper::http::request::Parts;
|
|||||||
use hyper::http::uri::{Authority, PathAndQuery, Scheme};
|
use hyper::http::uri::{Authority, PathAndQuery, Scheme};
|
||||||
use hyper::upgrade::OnUpgrade;
|
use hyper::upgrade::OnUpgrade;
|
||||||
use hyper::{Method, Request, Response, StatusCode, Uri};
|
use hyper::{Method, Request, Response, StatusCode, Uri};
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
use hyper_util::client::legacy::connect::HttpInfo;
|
use hyper_util::client::legacy::connect::HttpInfo;
|
||||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
@@ -296,35 +297,60 @@ pub(crate) async fn handle_request(
|
|||||||
return Ok(handle_connect(req, client, engine));
|
return Ok(handle_connect(req, client, engine));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A request the engine sent that was routed back to it, as iCloud Private Relay does with
|
||||||
|
// port 80 while the system proxy is on: forwarding it again would loop forever. It is the
|
||||||
|
// engine's own copy of a flow already recorded, which gets this answer, so it records none.
|
||||||
|
if carries_via(&req, &engine) {
|
||||||
|
debug!("Refused {} {}: it looped back to Yaak Proxy", req.method(), req.uri());
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::LOOP_DETECTED)
|
||||||
|
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(text_body("request looped back to Yaak Proxy"))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
// Some clients send a bare path (`POST /api`) as they would to the server itself.
|
// Some clients send a bare path (`POST /api`) as they would to the server itself.
|
||||||
let from_host = req.uri().authority().is_none();
|
let from_host = req.uri().authority().is_none();
|
||||||
let rejected = if from_host { url_from_host(&mut req, &engine).err() } else { None };
|
let rejected = if from_host { url_from_host(&mut req).err() } else { None };
|
||||||
let flow = Flow::begin(engine.clone(), client, &req, req.uri().to_string());
|
let flow = Flow::begin(engine.clone(), client, &req, req.uri().to_string());
|
||||||
if let Some((status, error)) = rejected {
|
if let Some((status, error)) = rejected {
|
||||||
return Ok(flow.reject(status, error));
|
return Ok(flow.reject(status, error));
|
||||||
}
|
}
|
||||||
if from_host {
|
req.headers_mut().append(VIA, engine.via.clone());
|
||||||
req.headers_mut().append(VIA, engine.via.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let outgoing = Request::from_parts(parts, flow.capture_request(body));
|
let outgoing = Request::from_parts(parts, flow.capture_request(body));
|
||||||
Ok(match engine.http_client.request(outgoing).await {
|
Ok(match engine.http_client.request(outgoing).await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let remote_addr = resp.extensions().get::<HttpInfo>().map(|i| i.remote_addr());
|
let remote_addr = plain_remote_addr(&resp);
|
||||||
flow.respond(resp, remote_addr)
|
flow.respond(resp, remote_addr)
|
||||||
}
|
}
|
||||||
Err(e) => flow.fail(error_chain(&e)),
|
Err(e) => flow.fail(error_chain(&e)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The address `Engine::http_client` connected to for `resp`.
|
||||||
|
fn plain_remote_addr(resp: &Response<Incoming>) -> Option<SocketAddr> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return resp.extensions().get::<crate::nw::RemoteAddr>().map(|a| a.0);
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
resp.extensions().get::<HttpInfo>().map(|i| i.remote_addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `req` carries this engine's `Via` mark, which it adds to every plain-HTTP request it
|
||||||
|
/// forwards.
|
||||||
|
fn carries_via(req: &Request<Incoming>, engine: &Engine) -> bool {
|
||||||
|
let via = engine.via.to_str().unwrap_or_default();
|
||||||
|
req.headers()
|
||||||
|
.get_all(VIA)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.any(|v| v.split(',').any(|hop| hop.trim() == via))
|
||||||
|
}
|
||||||
|
|
||||||
/// Gives a bare-path request the `http://` URL its `Host` names. Fails when there is no usable
|
/// Gives a bare-path request the `http://` URL its `Host` names. Fails when there is no usable
|
||||||
/// `Host`, or when the request carries this engine's `Via` mark: then it is one the engine sent
|
/// `Host`.
|
||||||
/// that was routed back to it, and forwarding it again would loop forever.
|
fn url_from_host(req: &mut Request<Incoming>) -> Result<(), (StatusCode, &'static str)> {
|
||||||
fn url_from_host(
|
|
||||||
req: &mut Request<Incoming>,
|
|
||||||
engine: &Engine,
|
|
||||||
) -> Result<(), (StatusCode, &'static str)> {
|
|
||||||
let Some(host) = req.headers().get(HOST) else {
|
let Some(host) = req.headers().get(HOST) else {
|
||||||
return Err((StatusCode::BAD_REQUEST, "no host in request"));
|
return Err((StatusCode::BAD_REQUEST, "no host in request"));
|
||||||
};
|
};
|
||||||
@@ -339,17 +365,6 @@ fn url_from_host(
|
|||||||
}
|
}
|
||||||
*req.uri_mut() =
|
*req.uri_mut() =
|
||||||
Uri::from_parts(parts).map_err(|_| (StatusCode::BAD_REQUEST, "invalid host in request"))?;
|
Uri::from_parts(parts).map_err(|_| (StatusCode::BAD_REQUEST, "invalid host in request"))?;
|
||||||
|
|
||||||
let via = engine.via.to_str().unwrap_or_default();
|
|
||||||
let looped = req
|
|
||||||
.headers()
|
|
||||||
.get_all(VIA)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.to_str().ok())
|
|
||||||
.any(|v| v.split(',').any(|hop| hop.trim() == via));
|
|
||||||
if looped {
|
|
||||||
return Err((StatusCode::LOOP_DETECTED, "request looped back to Yaak Proxy"));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,43 @@ use hyper_util::rt::TokioIo;
|
|||||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, copy_bidirectional};
|
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, copy_bidirectional};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
/// Copies bytes both ways between `client` and `target`, after sending `prefix` (bytes already
|
/// Copies bytes both ways between `client` and `target` (`host:port`), after sending `prefix`
|
||||||
/// read from the client) upstream.
|
/// (bytes already read from the client) upstream.
|
||||||
pub(crate) async fn tunnel<S>(mut client: S, target: &str, prefix: &[u8]) -> io::Result<()>
|
pub(crate) async fn tunnel<S>(client: S, target: &str, prefix: &[u8]) -> io::Result<()>
|
||||||
where
|
where
|
||||||
S: AsyncRead + AsyncWrite + Unpin,
|
S: AsyncRead + AsyncWrite + Unpin,
|
||||||
{
|
{
|
||||||
let mut upstream = TcpStream::connect(target).await?;
|
// Private Relay sends public port-80 connections into the system proxy, which may be this
|
||||||
|
// engine.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some((host, "80")) = target.rsplit_once(':') {
|
||||||
|
return tunnel_skipping_proxy(client, host, 80, prefix).await;
|
||||||
|
}
|
||||||
|
relay(client, TcpStream::connect(target).await?, prefix).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`tunnel`] through Network.framework, which can skip the system proxy. When it can't, it falls
|
||||||
|
/// back to the system proxy with a `CONNECT` that arrives here, and tunneling that out the same
|
||||||
|
/// way would repeat it.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
async fn tunnel_skipping_proxy<S>(client: S, host: &str, port: u16, prefix: &[u8]) -> io::Result<()>
|
||||||
|
where
|
||||||
|
S: AsyncRead + AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||||
|
if crate::nw::is_own_fallback(host, port).await {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"not tunneling Yaak Proxy's own connection to {host}:{port} back out"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
relay(client, crate::nw::connect(host, port).await?, prefix).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn relay<S, U>(mut client: S, mut upstream: U, prefix: &[u8]) -> io::Result<()>
|
||||||
|
where
|
||||||
|
S: AsyncRead + AsyncWrite + Unpin,
|
||||||
|
U: AsyncRead + AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
upstream.write_all(prefix).await?;
|
upstream.write_all(prefix).await?;
|
||||||
copy_bidirectional(&mut client, &mut upstream).await?;
|
copy_bidirectional(&mut client, &mut upstream).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -83,3 +113,50 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for Rewind<S> {
|
|||||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "macos"))]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, duplex};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A port-80 tunnel on macOS goes through Network.framework: the prefix goes first, bytes
|
||||||
|
/// cross both ways, and each side's close reaches the other.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn tunnel_skipping_proxy_relays_both_ways_and_closes() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
|
let (mut read, mut write) = socket.split();
|
||||||
|
tokio::io::copy(&mut read, &mut write).await.unwrap();
|
||||||
|
write.shutdown().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let (client, proxy_side) = duplex(1024);
|
||||||
|
let tunnel = tokio::spawn(async move {
|
||||||
|
tunnel_skipping_proxy(proxy_side, "localhost", port, b"first,").await
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: Vec<u8> = (0..300_000).map(|i| (i % 251) as u8).collect();
|
||||||
|
let (mut read, mut write) = tokio::io::split(client);
|
||||||
|
let (echoed, ()) = tokio::join!(
|
||||||
|
async {
|
||||||
|
let mut echoed = Vec::new();
|
||||||
|
read.read_to_end(&mut echoed).await.unwrap();
|
||||||
|
echoed
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
write.write_all(&payload).await.unwrap();
|
||||||
|
write.shutdown().await.unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(&echoed[..6], b"first,");
|
||||||
|
assert!(echoed[6..] == payload[..], "echoed bytes differ from the payload");
|
||||||
|
let ended = tokio::time::timeout(Duration::from_secs(10), tunnel).await;
|
||||||
|
ended.expect("the tunnel stayed open").unwrap().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! The engine's own upstream falling back to the system proxy, which is the engine. Its own test
|
||||||
|
//! binary, since the proxy is set for the whole process.
|
||||||
|
#![cfg(target_os = "macos")]
|
||||||
|
|
||||||
|
use std::ffi::{CString, c_void};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rustls::pki_types::ServerName;
|
||||||
|
use rustls::{ClientConfig, RootCertStore};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio_rustls::TlsConnector;
|
||||||
|
use yaak_proxy::{CaStore, ProxyConfig, start_proxy};
|
||||||
|
|
||||||
|
type NwObject = *mut c_void;
|
||||||
|
|
||||||
|
#[link(name = "Network", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
static _nw_privacy_context_default_context: NwObject;
|
||||||
|
fn nw_endpoint_create_host(host: *const i8, port: *const i8) -> NwObject;
|
||||||
|
fn nw_proxy_config_create_http_connect(endpoint: NwObject, tls: NwObject) -> NwObject;
|
||||||
|
fn nw_proxy_config_set_failover_allowed(proxy_config: NwObject, allowed: bool);
|
||||||
|
fn nw_privacy_context_add_proxy(privacy_context: NwObject, proxy_config: NwObject);
|
||||||
|
fn nw_release(obj: NwObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points every Network.framework connection in the process at an HTTP CONNECT proxy on
|
||||||
|
/// `127.0.0.1:port`, as a system proxy would.
|
||||||
|
fn set_process_proxy(port: u16) {
|
||||||
|
let host = CString::new("127.0.0.1").unwrap();
|
||||||
|
let port = CString::new(port.to_string()).unwrap();
|
||||||
|
// SAFETY: each object created here is released once, after the context retains it.
|
||||||
|
unsafe {
|
||||||
|
let endpoint = nw_endpoint_create_host(host.as_ptr(), port.as_ptr());
|
||||||
|
let config = nw_proxy_config_create_http_connect(endpoint, std::ptr::null_mut());
|
||||||
|
nw_proxy_config_set_failover_allowed(config, true);
|
||||||
|
nw_privacy_context_add_proxy(_nw_privacy_context_default_context, config);
|
||||||
|
nw_release(config);
|
||||||
|
nw_release(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_head(stream: &mut (impl AsyncReadExt + Unpin)) -> String {
|
||||||
|
let mut head = Vec::new();
|
||||||
|
while !head.ends_with(b"\r\n\r\n") {
|
||||||
|
head.push(stream.read_u8().await.unwrap());
|
||||||
|
}
|
||||||
|
String::from_utf8(head).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A host the engine passes through is tunneled as soon as its `CONNECT` arrives. When that
|
||||||
|
/// `CONNECT` is the engine's own upstream falling back to the proxy, tunneling it out again would
|
||||||
|
/// fall back again, without end. One `CONNECT`, then the client gets a 502.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn own_fallback_through_a_passthrough_host_stops() {
|
||||||
|
const TARGET: &str = "yaak-proxy-own-fallback.invalid";
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ca = Arc::new(CaStore::load_or_create(dir.path()).unwrap());
|
||||||
|
let proxy = start_proxy(ProxyConfig::new(0, ca)).unwrap();
|
||||||
|
let engine_port = proxy.port;
|
||||||
|
|
||||||
|
// Counts the CONNECTs on their way from Network.framework to the engine.
|
||||||
|
let gateway = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let gateway_port = gateway.local_addr().unwrap().port();
|
||||||
|
let connects = Arc::new(AtomicUsize::new(0));
|
||||||
|
let seen = connects.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (mut inbound, _) = gateway.accept().await.unwrap();
|
||||||
|
let seen = seen.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let head = read_head(&mut inbound).await;
|
||||||
|
if head.starts_with("CONNECT ") {
|
||||||
|
seen.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let mut engine = TcpStream::connect(("127.0.0.1", engine_port)).await.unwrap();
|
||||||
|
engine.write_all(head.as_bytes()).await.unwrap();
|
||||||
|
let _ = copy_bidirectional(&mut inbound, &mut engine).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_process_proxy(gateway_port);
|
||||||
|
|
||||||
|
// A client that rejects the engine's certificate gets the target passed through.
|
||||||
|
let mut tcp = TcpStream::connect(("127.0.0.1", engine_port)).await.unwrap();
|
||||||
|
let connect = format!("CONNECT {TARGET}:80 HTTP/1.1\r\nHost: {TARGET}:80\r\n\r\n");
|
||||||
|
tcp.write_all(connect.as_bytes()).await.unwrap();
|
||||||
|
assert!(read_head(&mut tcp).await.starts_with("HTTP/1.1 200"));
|
||||||
|
let distrusting =
|
||||||
|
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.unwrap()
|
||||||
|
.with_root_certificates(RootCertStore::empty())
|
||||||
|
.with_no_client_auth();
|
||||||
|
let name = ServerName::try_from(TARGET).unwrap();
|
||||||
|
assert!(TlsConnector::from(Arc::new(distrusting)).connect(name, tcp).await.is_err());
|
||||||
|
let addr = format!("{TARGET}:80");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||||
|
while proxy.handshake_state(&addr) != (0, true) {
|
||||||
|
assert!(tokio::time::Instant::now() < deadline, "{addr} was never passed through");
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect(("127.0.0.1", engine_port)).await.unwrap();
|
||||||
|
let request = format!("GET http://{TARGET}/ HTTP/1.1\r\nHost: {TARGET}\r\n\r\n");
|
||||||
|
client.write_all(request.as_bytes()).await.unwrap();
|
||||||
|
let head = tokio::time::timeout(Duration::from_secs(10), read_head(&mut client))
|
||||||
|
.await
|
||||||
|
.expect("no response");
|
||||||
|
assert!(head.starts_with("HTTP/1.1 502"), "{head}");
|
||||||
|
|
||||||
|
// Give a runaway chain time to show.
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
assert_eq!(connects.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
@@ -1449,9 +1449,10 @@ async fn plain_http_bare_path_without_host_is_a_bad_request() {
|
|||||||
assert!(!events.iter().any(|e| matches!(e, ProxyEvent::ResponseStart { .. })));
|
assert!(!events.iter().any(|e| matches!(e, ProxyEvent::ResponseStart { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A request whose connection upstream is routed back into the proxy, as happened to Telegram's
|
/// A request whose connection upstream is routed back into the proxy, as Private Relay does with
|
||||||
/// while the system proxy was on, arrives again with a bare path. Sending that on would loop
|
/// port 80 while the system proxy is on, arrives again with the engine's `Via` mark. Sending
|
||||||
/// forever.
|
/// that on would loop forever. It is refused without a flow of its own, so the loop shows as the
|
||||||
|
/// client's request answered with a 508.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn plain_http_request_looped_back_to_the_proxy_stops() {
|
async fn plain_http_request_looped_back_to_the_proxy_stops() {
|
||||||
let h = Harness::start(1024);
|
let h = Harness::start(1024);
|
||||||
@@ -1462,8 +1463,33 @@ async fn plain_http_request_looped_back_to_the_proxy_stops() {
|
|||||||
assert_eq!(resp.text().await.unwrap(), "request looped back to Yaak Proxy");
|
assert_eq!(resp.text().await.unwrap(), "request looped back to Yaak Proxy");
|
||||||
|
|
||||||
let events = h.flow_events();
|
let events = h.flow_events();
|
||||||
let error = find!(events, ProxyEvent::Error { error, .. } => error.clone());
|
let starts: Vec<_> = events
|
||||||
assert_eq!(error, "request looped back to Yaak Proxy");
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
ProxyEvent::RequestStart { url, .. } => Some(url.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(starts, [url]);
|
||||||
|
assert_eq!(find!(events, ProxyEvent::ResponseStart { status, .. } => *status), 508);
|
||||||
|
assert!(!events.iter().any(|e| matches!(e, ProxyEvent::Error { .. })));
|
||||||
|
let (body, _) = body(&events, false);
|
||||||
|
assert_eq!(body, b"request looped back to Yaak Proxy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same loop from a client that sends a bare path, as Telegram did.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn bare_path_request_looped_back_to_the_proxy_stops() {
|
||||||
|
let h = Harness::start(1024);
|
||||||
|
let host = format!("127.0.0.1:{}", h.proxy.port);
|
||||||
|
|
||||||
|
let resp = h.send_as_written(telegram_request(Some(&host), "/api", &[0xefu8; 44])).await;
|
||||||
|
assert_eq!(resp.status(), 508);
|
||||||
|
|
||||||
|
let events = h.flow_events();
|
||||||
|
let flows: HashSet<u64> = events.iter().map(flow_id).collect();
|
||||||
|
assert_eq!(flows.len(), 1, "{events:?}");
|
||||||
|
assert_eq!(find!(events, ProxyEvent::ResponseStart { status, .. } => *status), 508);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- HTTP/2 clients --
|
// -- HTTP/2 clients --
|
||||||
|
|||||||
Reference in new issue
Block a user