fix(proxy): forward X-Forwarded-Proto via insert_header; add e2e test
Root cause: upstream_request_filter was inserting x-forwarded-proto with a raw headers.insert() call (via DerefMut) which only updates base.headers but NOT the CaseMap. header_to_h1_wire zips CaseMap with base.headers, so headers added without a CaseMap entry are silently dropped on the wire. Fix: use insert_header() which keeps both maps in sync. Also adds: - src/lib.rs + [lib] section: exposes SunbeamProxy/RouteConfig/AcmeRoutes to integration tests without re-declaring modules in main.rs - tests/e2e.rs: real end-to-end test — starts a SunbeamProxy over plain HTTP, routes it to a TCP echo backend, and asserts x-forwarded-proto: http is present in the upstream request headers - Updated unit tests to verify header_to_h1_wire round-trip (not just that HeaderMap::insert works in isolation) Signed-off-by: Sienna Meridian Satterwhite <sienna@sunbeam.pt>
This commit is contained in:
157
tests/e2e.rs
Normal file
157
tests/e2e.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! End-to-end tests: spin up a real SunbeamProxy over plain HTTP, route it
|
||||
//! to a tiny TCP echo-backend, and verify that the upstream receives the
|
||||
//! correct X-Forwarded-Proto header.
|
||||
//!
|
||||
//! The proxy is started once per process in a background thread (Pingora's
|
||||
//! `run_forever()` never returns, which is fine — the OS cleans everything up
|
||||
//! when the test binary exits).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pingora::server::{configuration::Opt, Server};
|
||||
use pingora_proxy::http_proxy_service;
|
||||
use sunbeam_proxy::{acme::AcmeRoutes, config::RouteConfig, proxy::SunbeamProxy};
|
||||
|
||||
/// HTTP port the test proxy listens on. Must not conflict with other services
|
||||
/// on the CI machine; kept in the ephemeral-but-not-kernel-reserved range.
|
||||
const PROXY_PORT: u16 = 18_889;
|
||||
|
||||
// ── Echo backend ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Start a one-shot HTTP echo server on a random OS-assigned port.
|
||||
///
|
||||
/// Accepts exactly one connection, records every request header (lower-cased),
|
||||
/// returns 200 OK, then exits the thread. The captured headers are delivered
|
||||
/// via the returned `Receiver`.
|
||||
fn start_echo_backend() -> (u16, std::sync::mpsc::Receiver<HashMap<String, String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind echo backend");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept");
|
||||
// Clone for the BufReader so we can write the response on the original.
|
||||
let reader_stream = stream.try_clone().expect("clone stream");
|
||||
let mut reader = BufReader::new(reader_stream);
|
||||
let mut headers = HashMap::new();
|
||||
let mut skip_first = true; // first line is the request line, not a header
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
break; // EOF before blank line
|
||||
}
|
||||
let trimmed = line.trim_end_matches(|c| c == '\r' || c == '\n');
|
||||
if skip_first {
|
||||
skip_first = false;
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
break; // end of HTTP headers
|
||||
}
|
||||
if let Some((k, v)) = trimmed.split_once(": ") {
|
||||
headers.insert(k.to_lowercase(), v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(headers);
|
||||
let _ = stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
|
||||
(port, rx)
|
||||
}
|
||||
|
||||
// ── Proxy startup ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Poll `PROXY_PORT` until it accepts a connection (proxy is ready) or 5 s elapses.
|
||||
fn wait_for_proxy() {
|
||||
for _ in 0..50 {
|
||||
if TcpStream::connect(("127.0.0.1", PROXY_PORT)).is_ok() {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
panic!("proxy did not start on port {PROXY_PORT} within 5 s");
|
||||
}
|
||||
|
||||
/// Start a `SunbeamProxy` that routes `Host: test.*` to `backend_port`.
|
||||
///
|
||||
/// Guarded by `std::sync::Once` so the background thread is started at most
|
||||
/// once per test-binary process, regardless of how many tests call this.
|
||||
fn start_proxy_once(backend_port: u16) {
|
||||
static PROXY_ONCE: std::sync::Once = std::sync::Once::new();
|
||||
PROXY_ONCE.call_once(|| {
|
||||
// rustls 0.23 requires an explicit crypto provider. Ignore the error
|
||||
// in case another test (or the host binary) already installed one.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let routes = vec![RouteConfig {
|
||||
host_prefix: "test".to_string(),
|
||||
backend: format!("http://127.0.0.1:{backend_port}"),
|
||||
websocket: false,
|
||||
// Allow plain-HTTP requests through so we can test header forwarding
|
||||
// without needing TLS certificates in the test environment.
|
||||
disable_secure_redirection: true,
|
||||
paths: vec![],
|
||||
}];
|
||||
let acme_routes: AcmeRoutes = Arc::new(RwLock::new(HashMap::new()));
|
||||
let proxy = SunbeamProxy { routes, acme_routes };
|
||||
|
||||
let opt = Opt {
|
||||
upgrade: false,
|
||||
daemon: false,
|
||||
nocapture: false,
|
||||
test: false,
|
||||
conf: None,
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut server = Server::new(Some(opt)).expect("create server");
|
||||
server.bootstrap();
|
||||
let mut svc = http_proxy_service(&server.configuration, proxy);
|
||||
// HTTP only — no TLS cert files needed.
|
||||
svc.add_tcp(&format!("127.0.0.1:{PROXY_PORT}"));
|
||||
server.add_service(svc);
|
||||
server.run_forever(); // blocks this thread forever
|
||||
});
|
||||
|
||||
wait_for_proxy();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A plain-HTTP request routed through the proxy must arrive at the backend
|
||||
/// with `x-forwarded-proto: http`.
|
||||
#[test]
|
||||
fn test_plain_http_request_carries_x_forwarded_proto() {
|
||||
let (backend_port, rx) = start_echo_backend();
|
||||
start_proxy_once(backend_port);
|
||||
|
||||
// Send a minimal HTTP/1.1 request. `Host: test.local` → prefix "test"
|
||||
// matches the route configured above.
|
||||
let mut conn =
|
||||
TcpStream::connect(("127.0.0.1", PROXY_PORT)).expect("connect to proxy");
|
||||
conn.write_all(b"GET / HTTP/1.1\r\nHost: test.local\r\nConnection: close\r\n\r\n")
|
||||
.expect("write request");
|
||||
|
||||
// Drain the proxy response so the TCP handshake can close cleanly.
|
||||
let mut _resp = Vec::new();
|
||||
let _ = conn.read_to_end(&mut _resp);
|
||||
|
||||
let headers = rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("backend did not receive a request within 5 s");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("x-forwarded-proto").map(String::as_str),
|
||||
Some("http"),
|
||||
"expected x-forwarded-proto: http in upstream headers; got: {headers:?}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user