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:
2026-03-10 23:38:19 +00:00
parent d0146b47e3
commit 4ce008dc11
5 changed files with 246 additions and 4 deletions

View File

@@ -3,6 +3,10 @@ name = "sunbeam-proxy"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lib]
name = "sunbeam_proxy"
path = "src/lib.rs"
[dependencies] [dependencies]
# Pingora with rustls backend (pure Rust TLS, no BoringSSL C build) # Pingora with rustls backend (pure Rust TLS, no BoringSSL C build)
pingora = { version = "0.7", features = ["rustls"] } pingora = { version = "0.7", features = ["rustls"] }

6
src/lib.rs Normal file
View 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;

View File

@@ -1,17 +1,16 @@
mod acme;
mod cert; mod cert;
mod config;
mod proxy;
mod telemetry; mod telemetry;
mod watcher; mod watcher;
use sunbeam_proxy::{acme, config};
use sunbeam_proxy::proxy::SunbeamProxy;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use anyhow::Result; use anyhow::Result;
use kube::Client; use kube::Client;
use pingora::server::{configuration::Opt, Server}; use pingora::server::{configuration::Opt, Server};
use pingora_proxy::http_proxy_service; use pingora_proxy::http_proxy_service;
use proxy::SunbeamProxy;
use std::sync::RwLock; use std::sync::RwLock;
fn main() -> Result<()> { fn main() -> Result<()> {

View File

@@ -21,6 +21,8 @@ pub struct RequestCtx {
pub acme_backend: Option<String>, pub acme_backend: Option<String>,
/// Path prefix to strip before forwarding to the upstream (e.g. "/kratos"). /// Path prefix to strip before forwarding to the upstream (e.g. "/kratos").
pub strip_prefix: Option<String>, pub strip_prefix: Option<String>,
/// Original downstream scheme ("http" or "https"), captured in request_filter.
pub downstream_scheme: &'static str,
} }
impl SunbeamProxy { impl SunbeamProxy {
@@ -63,6 +65,7 @@ impl ProxyHttp for SunbeamProxy {
route: None, route: None,
start_time: Instant::now(), start_time: Instant::now(),
acme_backend: None, acme_backend: None,
downstream_scheme: "https",
strip_prefix: None, strip_prefix: None,
} }
} }
@@ -76,6 +79,8 @@ impl ProxyHttp for SunbeamProxy {
where where
Self::CTX: Send + Sync, Self::CTX: Send + Sync,
{ {
ctx.downstream_scheme = if is_plain_http(session) { "http" } else { "https" };
if is_plain_http(session) { if is_plain_http(session) {
let path = session.req_header().uri.path().to_string(); let path = session.req_header().uri.path().to_string();
@@ -210,6 +215,21 @@ impl ProxyHttp for SunbeamProxy {
where where
Self::CTX: Send + Sync, 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) { if ctx.route.as_ref().map(|r| r.websocket).unwrap_or(false) {
for name in &[CONNECTION, UPGRADE] { for name in &[CONNECTION, UPGRADE] {
if let Some(val) = session.req_header().headers.get(name.clone()) { 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
View 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:?}",
);
}