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"
|
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
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 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<()> {
|
||||||
|
|||||||
76
src/proxy.rs
76
src/proxy.rs
@@ -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
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