diff --git a/Cargo.toml b/Cargo.toml index ae74368..ff6292b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..190da03 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 2aa5146..263f943 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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<()> { diff --git a/src/proxy.rs b/src/proxy.rs index dcfbfa4..e5f7223 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -21,6 +21,8 @@ pub struct RequestCtx { pub acme_backend: Option, /// Path prefix to strip before forwarding to the upstream (e.g. "/kratos"). pub strip_prefix: Option, + /// 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"); + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..ebe38f6 --- /dev/null +++ b/tests/e2e.rs @@ -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>) { + 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:?}", + ); +}