use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use plecto_control::{Control, Host, Manifest, MemoryStore};
use plecto_host::test_support::TestSigner;
use plecto_server::serve;
fn loaded_control(toml: &str) -> Result<Control, plecto_control::ControlError> {
let manifest = Manifest::from_toml(toml)?;
let signer = TestSigner::new().unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
Control::load(host, &manifest, Box::new(MemoryStore::new()))
}
async fn read_head(stream: &mut TcpStream) -> Vec<u8> {
let mut buf = Vec::new();
let mut tmp = [0u8; 1024];
loop {
let n = stream.read(&mut tmp).await.unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
buf
}
async fn spawn_echo_upgrade_upstream() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let head = read_head(&mut stream).await;
let head = String::from_utf8_lossy(&head).to_lowercase();
if head.starts_with("get /healthz") {
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
.await;
return;
}
if !head.contains("upgrade: websocket") {
let _ = stream
.write_all(b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\n\r\n")
.await;
return;
}
stream
.write_all(
b"HTTP/1.1 101 Switching Protocols\r\n\
upgrade: websocket\r\n\
connection: upgrade\r\n\
sec-websocket-accept: e2e-accept\r\n\r\n",
)
.await
.unwrap();
let mut tmp = [0u8; 1024];
loop {
match stream.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if stream.write_all(&tmp[..n]).await.is_err() {
return;
}
}
}
}
});
}
});
addr
}
async fn spawn_proxy(toml: &str) -> SocketAddr {
let control = Arc::new(loaded_control(toml).unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(control, listener).await;
});
proxy
}
async fn free_addr() -> SocketAddr {
TcpListener::bind("127.0.0.1:0")
.await
.unwrap()
.local_addr()
.unwrap()
}
async fn scrape_metrics(admin: SocketAddr) -> String {
let mut s = TcpStream::connect(admin).await.unwrap();
s.write_all(b"GET /metrics HTTP/1.1\r\nhost: admin\r\nconnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = Vec::new();
s.read_to_end(&mut buf).await.unwrap();
String::from_utf8_lossy(&buf).into_owned()
}
#[tokio::test]
async fn upgrade_route_tunnels_bytes_bidirectionally_after_the_101() {
let upstream = spawn_echo_upgrade_upstream().await;
let toml = format!(
r#"
[[upstream]]
name = "ws"
addresses = ["127.0.0.1:{port}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
healthy_threshold = 1
[[route]]
upstream = "ws"
[route.match]
path_prefix = "/ws"
[route.upgrade]
protocols = ["websocket"]
"#,
port = upstream.port()
);
let proxy = spawn_proxy(&toml).await;
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let mut s = TcpStream::connect(proxy).await.unwrap();
s.write_all(
b"GET /ws HTTP/1.1\r\n\
host: e2e\r\n\
connection: upgrade\r\n\
upgrade: websocket\r\n\r\n",
)
.await
.unwrap();
let head = read_head(&mut s).await;
let head = String::from_utf8_lossy(&head).to_lowercase();
if !head.starts_with("http/1.1 101") {
tokio::time::sleep(Duration::from_millis(20)).await;
continue;
}
assert!(
head.contains("upgrade: websocket"),
"the 101 re-issues the Upgrade token: {head}"
);
assert!(
head.contains("sec-websocket-accept: e2e-accept"),
"end-to-end handshake headers pass through the 101: {head}"
);
s.write_all(b"ping-through-tunnel").await.unwrap();
let mut echo = [0u8; 19];
s.read_exact(&mut echo).await.unwrap();
assert_eq!(&echo, b"ping-through-tunnel");
s.write_all(b"second-frame").await.unwrap();
let mut echo = [0u8; 12];
s.read_exact(&mut echo).await.unwrap();
assert_eq!(&echo, b"second-frame");
break;
}
})
.await
.expect("the upgrade handshake never tunnelled through the proxy");
}
#[tokio::test]
async fn tunnel_occupancy_and_bytes_show_on_the_admin_metrics() {
let upstream = spawn_echo_upgrade_upstream().await;
let admin = free_addr().await;
let toml = format!(
r#"
[observability]
admin_addr = "{admin}"
[[upstream]]
name = "ws"
addresses = ["127.0.0.1:{port}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
healthy_threshold = 1
[[route]]
upstream = "ws"
[route.match]
path_prefix = "/ws"
[route.upgrade]
protocols = ["websocket"]
"#,
port = upstream.port()
);
let proxy = spawn_proxy(&toml).await;
tokio::time::timeout(Duration::from_secs(10), async {
let mut s = loop {
let mut s = TcpStream::connect(proxy).await.unwrap();
s.write_all(
b"GET /ws HTTP/1.1\r\n\
host: e2e\r\n\
connection: upgrade\r\n\
upgrade: websocket\r\n\r\n",
)
.await
.unwrap();
let head = read_head(&mut s).await;
if String::from_utf8_lossy(&head)
.to_lowercase()
.starts_with("http/1.1 101")
{
break s;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
s.write_all(b"ping-through-tunnel").await.unwrap();
let mut echo = [0u8; 19];
s.read_exact(&mut echo).await.unwrap();
assert!(
scrape_metrics(admin)
.await
.contains("plecto_tunnels_active 1"),
"a live tunnel must be visible on the gauge"
);
drop(s);
loop {
let text = scrape_metrics(admin).await;
if text.contains("plecto_tunnels_active 0") {
assert!(
text.contains("plecto_tunnel_bytes_up_total 19"),
"client→upstream bytes recorded at close: {text}"
);
assert!(
text.contains("plecto_tunnel_bytes_down_total 19"),
"upstream→client bytes recorded at close: {text}"
);
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the tunnel metrics never reached the expected values");
}
#[tokio::test]
async fn a_route_without_upgrade_keeps_stripping_the_upgrade_header() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let upstream = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(|req: Request<Incoming>| async move {
let body = if req.headers().contains_key(hyper::header::UPGRADE) {
"upgrade-leaked"
} else {
"upgrade-stripped"
};
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
}),
)
.await;
});
}
});
let toml = format!(
r#"
[[upstream]]
name = "plain"
addresses = ["127.0.0.1:{port}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
healthy_threshold = 1
[[route]]
upstream = "plain"
[route.match]
path_prefix = "/plain"
"#,
port = upstream.port()
);
let proxy = spawn_proxy(&toml).await;
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let mut s = TcpStream::connect(proxy).await.unwrap();
s.write_all(
b"GET /plain HTTP/1.1\r\n\
host: e2e\r\n\
connection: upgrade\r\n\
upgrade: websocket\r\n\r\n",
)
.await
.unwrap();
let mut resp = Vec::new();
let mut tmp = [0u8; 1024];
let outcome = loop {
match tokio::time::timeout(Duration::from_millis(500), s.read(&mut tmp)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => break None,
Ok(Ok(n)) => resp.extend_from_slice(&tmp[..n]),
}
let text = String::from_utf8_lossy(&resp).to_string();
if text.contains("upgrade-stripped") || text.contains("upgrade-leaked") {
break Some(text);
}
};
if let Some(text) = outcome
&& text.starts_with("HTTP/1.1 200")
{
assert!(
text.contains("upgrade-stripped"),
"the Upgrade header must not reach an undeclared route's upstream: {text}"
);
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the plain route never served the normal (non-upgrade) response");
}