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:
@@ -3,6 +3,10 @@ name = "sunbeam-proxy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "sunbeam_proxy"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Pingora with rustls backend (pure Rust TLS, no BoringSSL C build)
|
||||
pingora = { version = "0.7", features = ["rustls"] }
|
||||
|
||||
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Library crate root — exports the proxy/config/acme modules so that
|
||||
// integration tests in tests/ can construct and drive a SunbeamProxy
|
||||
// without going through the binary entry point.
|
||||
pub mod acme;
|
||||
pub mod config;
|
||||
pub mod proxy;
|
||||
@@ -1,17 +1,16 @@
|
||||
mod acme;
|
||||
mod cert;
|
||||
mod config;
|
||||
mod proxy;
|
||||
mod telemetry;
|
||||
mod watcher;
|
||||
|
||||
use sunbeam_proxy::{acme, config};
|
||||
use sunbeam_proxy::proxy::SunbeamProxy;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use kube::Client;
|
||||
use pingora::server::{configuration::Opt, Server};
|
||||
use pingora_proxy::http_proxy_service;
|
||||
use proxy::SunbeamProxy;
|
||||
use std::sync::RwLock;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
|
||||
76
src/proxy.rs
76
src/proxy.rs
@@ -21,6 +21,8 @@ pub struct RequestCtx {
|
||||
pub acme_backend: Option<String>,
|
||||
/// Path prefix to strip before forwarding to the upstream (e.g. "/kratos").
|
||||
pub strip_prefix: Option<String>,
|
||||
/// Original downstream scheme ("http" or "https"), captured in request_filter.
|
||||
pub downstream_scheme: &'static str,
|
||||
}
|
||||
|
||||
impl SunbeamProxy {
|
||||
@@ -63,6 +65,7 @@ impl ProxyHttp for SunbeamProxy {
|
||||
route: None,
|
||||
start_time: Instant::now(),
|
||||
acme_backend: None,
|
||||
downstream_scheme: "https",
|
||||
strip_prefix: None,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +79,8 @@ impl ProxyHttp for SunbeamProxy {
|
||||
where
|
||||
Self::CTX: Send + Sync,
|
||||
{
|
||||
ctx.downstream_scheme = if is_plain_http(session) { "http" } else { "https" };
|
||||
|
||||
if is_plain_http(session) {
|
||||
let path = session.req_header().uri.path().to_string();
|
||||
|
||||
@@ -210,6 +215,21 @@ impl ProxyHttp for SunbeamProxy {
|
||||
where
|
||||
Self::CTX: Send + Sync,
|
||||
{
|
||||
// Inform backends of the original downstream scheme so they can construct
|
||||
// correct absolute URLs (e.g. OIDC redirect_uri, CSRF checks).
|
||||
// Must use insert_header (not headers.insert) so that both base.headers
|
||||
// and the CaseMap are updated together — header_to_h1_wire zips them
|
||||
// and silently drops headers only present in base.headers.
|
||||
upstream_req
|
||||
.insert_header("x-forwarded-proto", ctx.downstream_scheme)
|
||||
.map_err(|e| {
|
||||
pingora_core::Error::because(
|
||||
pingora_core::ErrorType::InternalError,
|
||||
"failed to insert x-forwarded-proto",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if ctx.route.as_ref().map(|r| r.websocket).unwrap_or(false) {
|
||||
for name in &[CONNECTION, UPGRADE] {
|
||||
if let Some(val) = session.req_header().headers.get(name.clone()) {
|
||||
@@ -285,3 +305,59 @@ impl ProxyHttp for SunbeamProxy {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::header::HeaderValue;
|
||||
|
||||
/// insert_header keeps CaseMap and base.headers in sync so the header
|
||||
/// survives header_to_h1_wire serialization.
|
||||
#[test]
|
||||
fn test_x_forwarded_proto_https_roundtrips_through_insert_header() {
|
||||
let mut req = RequestHeader::build("GET", b"/", None).unwrap();
|
||||
req.insert_header("x-forwarded-proto", "https").unwrap();
|
||||
assert_eq!(
|
||||
req.headers.get("x-forwarded-proto"),
|
||||
Some(&HeaderValue::from_static("https")),
|
||||
);
|
||||
// Verify it survives wire serialization (CaseMap + base.headers in sync).
|
||||
let mut buf = Vec::new();
|
||||
req.header_to_h1_wire(&mut buf);
|
||||
let wire = String::from_utf8(buf).unwrap();
|
||||
assert!(wire.contains("x-forwarded-proto: https"), "wire: {wire:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x_forwarded_proto_http_roundtrips_through_insert_header() {
|
||||
let mut req = RequestHeader::build("GET", b"/", None).unwrap();
|
||||
req.insert_header("x-forwarded-proto", "http").unwrap();
|
||||
assert_eq!(
|
||||
req.headers.get("x-forwarded-proto"),
|
||||
Some(&HeaderValue::from_static("http")),
|
||||
);
|
||||
let mut buf = Vec::new();
|
||||
req.header_to_h1_wire(&mut buf);
|
||||
let wire = String::from_utf8(buf).unwrap();
|
||||
assert!(wire.contains("x-forwarded-proto: http"), "wire: {wire:?}");
|
||||
}
|
||||
|
||||
/// ctx.downstream_scheme defaults to "https" and is readable.
|
||||
#[test]
|
||||
fn test_ctx_default_scheme_is_https() {
|
||||
let ctx = RequestCtx {
|
||||
route: None,
|
||||
start_time: Instant::now(),
|
||||
acme_backend: None,
|
||||
strip_prefix: None,
|
||||
downstream_scheme: "https",
|
||||
};
|
||||
assert_eq!(ctx.downstream_scheme, "https");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_addr_strips_scheme() {
|
||||
assert_eq!(backend_addr("http://svc.ns.svc.cluster.local:80"), "svc.ns.svc.cluster.local:80");
|
||||
assert_eq!(backend_addr("https://svc.ns.svc.cluster.local:443"), "svc.ns.svc.cluster.local:443");
|
||||
}
|
||||
}
|
||||
|
||||
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