use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
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::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use plecto_control::{Control, Host, Manifest, MemoryStore, ResolvedArtifact};
use plecto_host::test_support::{TestSigner, bound_sbom, filter_hello_component};
use plecto_server::serve;
async fn echo(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
let path = req
.uri()
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_default();
let traceparent = req
.headers()
.get("traceparent")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let xff = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let xfp = req
.headers()
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let xrealip = req
.headers()
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let cfip = req
.headers()
.get("cf-connecting-ip")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
Ok(Response::builder()
.status(200)
.header("x-upstream-path", path)
.header("x-upstream-traceparent", traceparent)
.header("x-upstream-xff", xff)
.header("x-upstream-xfproto", xfp)
.header("x-upstream-xrealip", xrealip)
.header("x-upstream-cfip", cfip)
.header("x-from", "upstream")
.header("x-plecto-respedit", "1")
.body(Full::new(Bytes::from_static(b"upstream-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 (stream, _) = listener.accept().await.unwrap();
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(control: Arc<Control>) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(control, listener).await;
});
addr
}
fn control_for(upstream_addr: SocketAddr) -> Arc<Control> {
let component = filter_hello_component();
let signer = TestSigner::new().unwrap();
let component_signature = signer.sign(&component).unwrap();
let sbom = bound_sbom(&component);
let sbom_signature = signer.sign(&sbom).unwrap();
let mut store = MemoryStore::new();
let digest = store.insert(
"fh",
ResolvedArtifact {
component,
component_signature,
sbom,
sbom_signature,
},
);
let toml = format!(
r#"
[[filter]]
id = "fh"
source = "fh"
digest = "{digest}"
isolation = "trusted"
[[upstream]]
name = "echo"
addresses = ["{upstream_addr}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
[[route]]
filters = ["fh"]
upstream = "echo"
strip_prefix = "/api"
[route.match]
path_prefix = "/api"
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
Arc::new(Control::load(host, &manifest, Box::new(store)).unwrap())
}
fn client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
async fn get(
client: &Client<HttpConnector, Empty<Bytes>>,
proxy: SocketAddr,
path: &str,
headers: &[(&str, &str)],
) -> (StatusCode, hyper::HeaderMap, String) {
let mut builder = Request::builder()
.method("GET")
.uri(format!("http://{proxy}{path}"));
for (n, v) in headers {
builder = builder.header(*n, *v);
}
let resp = client
.request(builder.body(Empty::<Bytes>::new()).unwrap())
.await
.expect("proxy request");
let (parts, body) = resp.into_parts();
let bytes = body.collect().await.unwrap().to_bytes();
(
parts.status,
parts.headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
async fn wait_ready(client: &Client<HttpConnector, Empty<Bytes>>, proxy: SocketAddr) {
for _ in 0..100 {
let (status, _, _) = get(client, proxy, "/api/__ready", &[]).await;
if status != StatusCode::SERVICE_UNAVAILABLE {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("upstream never became healthy within the readiness window");
}
#[tokio::test]
async fn routes_runs_chain_strips_prefix_and_forwards() {
let upstream = spawn_upstream().await;
let proxy = spawn_proxy(control_for(upstream)).await;
let client = client();
wait_ready(&client, proxy).await;
let (status, headers, body) = get(&client, proxy, "/api/hello", &[]).await;
assert_eq!(status, StatusCode::OK, "an unblocked request forwards 200");
assert_eq!(body, "upstream-ok", "the upstream body streams through");
assert_eq!(
headers.get("x-from").and_then(|v| v.to_str().ok()),
Some("upstream"),
"the response came from the upstream (the chain continued)"
);
assert_eq!(
headers.get("x-upstream-path").and_then(|v| v.to_str().ok()),
Some("/hello"),
"host-native strip_prefix removed /api before forwarding"
);
assert!(
headers.contains_key("x-plecto-respadded"),
"the response-side chain ran and applied the filter's response edit"
);
}
#[tokio::test]
async fn short_circuit_never_reaches_upstream() {
let upstream = spawn_upstream().await;
let proxy = spawn_proxy(control_for(upstream)).await;
let client = client();
let (status, headers, body) =
get(&client, proxy, "/api/hello", &[("x-plecto-block", "1")]).await;
assert_eq!(status, StatusCode::FORBIDDEN, "a blocked request gets 403");
assert_eq!(
body, "blocked by filter-hello",
"the filter synthesised the body"
);
assert!(
!headers.contains_key("x-from"),
"a short-circuit must not reach the upstream"
);
}
#[tokio::test]
async fn unmatched_route_is_404() {
let upstream = spawn_upstream().await;
let proxy = spawn_proxy(control_for(upstream)).await;
let client = client();
let (status, headers, _body) = get(&client, proxy, "/nope", &[]).await;
assert_eq!(status, StatusCode::NOT_FOUND, "no matching route → 404");
assert_eq!(
headers.get("x-plecto-fault").and_then(|v| v.to_str().ok()),
Some("no-route"),
);
}
#[tokio::test]
async fn continues_inbound_trace_context() {
let upstream = spawn_upstream().await;
let proxy = spawn_proxy(control_for(upstream)).await;
let client = client();
wait_ready(&client, proxy).await;
let inbound = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
let (status, headers, _body) =
get(&client, proxy, "/api/hello", &[("traceparent", inbound)]).await;
assert_eq!(status, StatusCode::OK);
let forwarded = headers
.get("x-upstream-traceparent")
.and_then(|v| v.to_str().ok())
.expect("the upstream received a traceparent");
let trace_id = forwarded.split('-').nth(1).unwrap_or("");
assert_eq!(
trace_id, "4bf92f3577b34da6a3ce929d0e0e4736",
"the inbound trace-id is preserved (the caller's trace continues, not a new root)"
);
}
#[tokio::test]
async fn overwrites_spoofed_forwarded_headers_with_the_real_peer() {
let upstream = spawn_upstream().await;
let proxy = spawn_proxy(control_for(upstream)).await;
let client = client();
wait_ready(&client, proxy).await;
let (status, headers, _body) = get(
&client,
proxy,
"/api/hello",
&[
("x-forwarded-for", "9.9.9.9"),
("x-real-ip", "9.9.9.9"),
("cf-connecting-ip", "8.8.8.8"),
],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
headers.get("x-upstream-xff").and_then(|v| v.to_str().ok()),
Some("127.0.0.1"),
"the spoofed X-Forwarded-For is replaced by the real loopback peer"
);
assert_eq!(
headers
.get("x-upstream-xrealip")
.and_then(|v| v.to_str().ok()),
Some("127.0.0.1"),
"the spoofed X-Real-IP is replaced by the real loopback peer"
);
assert_eq!(
headers.get("x-upstream-cfip").and_then(|v| v.to_str().ok()),
Some(""),
"a spoofed CF-Connecting-IP is stripped and not re-issued"
);
assert_eq!(
headers
.get("x-upstream-xfproto")
.and_then(|v| v.to_str().ok()),
Some("http"),
"X-Forwarded-Proto reflects the plaintext connection"
);
}