use std::convert::Infallible;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::TlsConnector;
use tokio_rustls::rustls::pki_types::ServerName;
use tokio_rustls::rustls::{ClientConfig, RootCertStore, crypto::aws_lc_rs};
use plecto_control::{Control, Host, Manifest, MemoryStore};
use plecto_host::test_support::TestSigner;
use plecto_server::serve;
const SIGNATURE: &[u8; 12] = b"\r\n\r\n\0\r\nQUIT\n";
async fn echo(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
let reflect = |name: &str| {
req.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
Ok(Response::builder()
.status(200)
.header("x-upstream-xff", reflect("x-forwarded-for"))
.header("x-upstream-xrealip", reflect("x-real-ip"))
.body(Full::new(Bytes::from_static(b"ok")))
.unwrap())
}
async fn spawn_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 Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), service_fn(echo))
.await;
});
}
});
addr
}
async fn spawn_proxy(trusted: &str, extra: &str) -> SocketAddr {
let upstream = spawn_upstream().await;
let toml = format!(
r#"[listen.proxy_protocol]
trusted = ["{trusted}"]
[[upstream]]
name = "app"
addresses = ["{upstream}"]
[upstream.health]
path = "/healthz"
[[route]]
upstream = "app"
[route.match]
path_prefix = "/api"
{extra}
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let signer = TestSigner::new().unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
let control = Control::load(host, &manifest, Box::new(MemoryStore::new())).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(Arc::new(control), listener).await;
});
proxy
}
fn v2_proxy_ipv4(src: SocketAddrV4, dst: SocketAddrV4, tlv: &[u8]) -> Vec<u8> {
let mut h = Vec::new();
h.extend_from_slice(SIGNATURE);
h.push(0x21); h.push(0x11); h.extend_from_slice(&u16::try_from(12 + tlv.len()).unwrap().to_be_bytes());
h.extend_from_slice(&src.ip().octets());
h.extend_from_slice(&dst.ip().octets());
h.extend_from_slice(&src.port().to_be_bytes());
h.extend_from_slice(&dst.port().to_be_bytes());
h.extend_from_slice(tlv);
h
}
fn v2_local() -> Vec<u8> {
let mut h = Vec::new();
h.extend_from_slice(SIGNATURE);
h.push(0x20); h.push(0x00); h.extend_from_slice(&0u16.to_be_bytes());
h
}
fn sample_header() -> Vec<u8> {
v2_proxy_ipv4(
SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 9), 51234),
SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443),
&[],
)
}
async fn request_after(
proxy: SocketAddr,
preamble: &[u8],
) -> Result<Response<Incoming>, Box<dyn std::error::Error>> {
let fut = async {
let mut stream = TcpStream::connect(proxy).await?;
if !preamble.is_empty() {
stream.write_all(preamble).await?;
}
let (mut sender, conn) =
hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
tokio::spawn(async move {
let _ = conn.await;
});
let req = Request::builder()
.uri("/api/x")
.header("host", "localhost")
.body(Empty::<Bytes>::new())?;
Ok(sender.send_request(req).await?)
};
tokio::time::timeout(Duration::from_secs(10), fut)
.await
.map_err(|_| "timed out")?
}
async fn request_after_ready(
proxy: SocketAddr,
preamble: &[u8],
) -> Result<Response<Incoming>, Box<dyn std::error::Error>> {
for _ in 0..100 {
match request_after(proxy, preamble).await {
Ok(resp) if resp.status() == StatusCode::SERVICE_UNAVAILABLE => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
other => return other,
}
}
request_after(proxy, preamble).await
}
fn header_str<'a>(resp: &'a Response<Incoming>, name: &str) -> &'a str {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
}
#[tokio::test]
async fn trusted_proxy_v2_restores_the_client_ip() {
let proxy = spawn_proxy("127.0.0.0/8", "").await;
let resp = request_after_ready(proxy, &sample_header())
.await
.expect("request must succeed");
assert_eq!(resp.status(), 200);
assert_eq!(
header_str(&resp, "x-upstream-xff"),
"203.0.113.9",
"X-Forwarded-For must carry the PROXY-restored client, not the LB peer"
);
assert_eq!(header_str(&resp, "x-upstream-xrealip"), "203.0.113.9");
}
#[tokio::test]
async fn trusted_local_command_keeps_the_real_peer() {
let proxy = spawn_proxy("127.0.0.0/8", "").await;
let resp = request_after_ready(proxy, &v2_local())
.await
.expect("request must succeed");
assert_eq!(resp.status(), 200);
assert_eq!(
header_str(&resp, "x-upstream-xff"),
"127.0.0.1",
"LOCAL must fall back to the connection's real endpoints (LB health-check shape)"
);
}
#[tokio::test]
async fn trusted_peer_without_a_header_is_cut() {
let proxy = spawn_proxy("127.0.0.0/8", "").await;
let result = request_after(proxy, b"").await;
assert!(
result.is_err(),
"a trusted peer that skips the mandatory PROXY header must be cut, got: {result:?}"
);
}
#[tokio::test]
async fn trusted_peer_with_garbage_header_is_cut() {
let proxy = spawn_proxy("127.0.0.0/8", "").await;
let mut bad = sample_header();
bad[12] = 0x2F;
let result = request_after(proxy, &bad).await;
assert!(
result.is_err(),
"a malformed header must cut, not pass through, got: {result:?}"
);
}
#[tokio::test]
async fn untrusted_peer_sending_proxy_v2_is_cut() {
let proxy = spawn_proxy("203.0.113.0/24", "").await;
let result = request_after(proxy, &sample_header()).await;
assert!(
result.is_err(),
"a PROXY header from outside the trusted CIDRs must be cut, got: {result:?}"
);
}
#[tokio::test]
async fn untrusted_peer_plain_request_passes_through() {
let proxy = spawn_proxy("203.0.113.0/24", "").await;
let resp = request_after_ready(proxy, b"")
.await
.expect("mixed mode: direct clients still work");
assert_eq!(resp.status(), 200);
assert_eq!(
header_str(&resp, "x-upstream-xff"),
"127.0.0.1",
"an untrusted peer keeps its real address"
);
}
#[tokio::test]
async fn proxy_v2_header_precedes_the_tls_handshake() {
let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, generated.cert.pem()).unwrap();
std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
let tls_section = format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n",
cert_path.to_str().unwrap(),
key_path.to_str().unwrap()
);
let proxy = spawn_proxy("127.0.0.0/8", &tls_section).await;
let mut roots = RootCertStore::empty();
roots.add(generated.cert.der().clone()).unwrap();
let config = ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let xff = tokio::time::timeout(Duration::from_secs(10), async {
let mut tcp = TcpStream::connect(proxy).await.unwrap();
tcp.write_all(&sample_header()).await.unwrap();
let tls = connector
.connect(ServerName::try_from("localhost").unwrap(), tcp)
.await
.expect("TLS handshake must succeed after the PROXY header is consumed");
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.unwrap();
tokio::spawn(async move {
let _ = conn.await;
});
let req = Request::builder()
.uri("/api/x")
.header("host", "localhost")
.body(Empty::<Bytes>::new())
.unwrap();
let resp = sender.send_request(req).await.unwrap();
let xff = header_str(&resp, "x-upstream-xff").to_string();
let _ = resp.into_body().collect().await;
xff
})
.await
.expect("the proxy never answered over TLS");
assert_eq!(
xff, "203.0.113.9",
"the restored client must survive TLS termination"
);
}