diff --git a/Cargo.lock b/Cargo.lock index f938f80..5430140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" @@ -287,6 +293,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "dunce" version = "1.0.5" @@ -315,6 +330,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -641,6 +662,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.28" @@ -682,6 +709,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -725,6 +758,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -737,6 +780,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -777,6 +826,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "regex" version = "1.12.3" @@ -820,6 +882,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -847,6 +922,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1028,6 +1112,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1057,6 +1154,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tiny-proxy" version = "0.3.0" @@ -1071,10 +1187,15 @@ dependencies = [ "hyper-rustls", "hyper-util", "num_cpus", + "rcgen", + "rustls", + "rustls-pemfile", "serde", "serde_json", + "tempfile", "thiserror", "tokio", + "tokio-rustls", "tracing", "tracing-subscriber", "uuid", @@ -1628,6 +1749,15 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "zerocopy" version = "0.8.42" diff --git a/Cargo.toml b/Cargo.toml index 44a7c1a..4c1f250 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ required-features = ["cli"] hyper = { version = "1.3", features = ["http1", "client", "server"] } hyper-util = { version = "0.1", features = ["client", "server", "http1", "tokio"] } http-body-util = "0.1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal", "process", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal", "process", "fs", "io-util"] } anyhow = "1.0" thiserror = "2.0.18" bytes = "1.0" @@ -48,6 +48,9 @@ uuid = { version = "1", features = ["v4"] } serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } hyper-rustls = { version = "0.27", optional = true } +tokio-rustls = { version = "0.26", optional = true } +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "logging", "std", "tls12"], optional = true } +rustls-pemfile = { version = "2", optional = true } tracing = "0.1" clap = { version = "4.5", features = ["derive"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"], optional = true } @@ -55,6 +58,14 @@ http-body = "1.0.1" [dev-dependencies] criterion = { version = "0.5.1", default-features = false } +rcgen = { version = "0.13" } +tempfile = "3" +hyper = "1.3" +hyper-util = { version = "0.1", features = ["client", "server", "http1", "tokio"] } +hyper-rustls = { version = "0.27" } +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "logging", "std", "tls12"] } +rustls-pemfile = { version = "2" } +http-body-util = "0.1" [package.metadata.docs.rs] all-features = true @@ -78,6 +89,6 @@ harness = false [features] default = ["cli", "tls", "api", "logging"] cli = ["dep:clap", "dep:tracing-subscriber"] -tls = ["dep:hyper-rustls"] +tls = ["dep:hyper-rustls", "dep:tokio-rustls", "dep:rustls", "dep:rustls-pemfile"] api = ["dep:serde", "dep:serde_json"] logging = [] diff --git a/README.md b/README.md index c86b950..12cf6cb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Lightweight, embeddable HTTP reverse proxy written in Rust with Caddy-like confi - **Header Manipulation**: Add, modify, or remove headers - **URI Rewriting**: Replace parts of request URIs - **HTTP/HTTPS Backend Support**: Full support for both HTTP and HTTPS backends +- **TLS Termination**: HTTPS on the frontend with SNI-based multi-domain support - **Method-based Routing**: Different behavior for different HTTP methods - **Direct Responses**: Respond with custom status codes and bodies - **Authentication Module**: Token validation and header substitution @@ -51,13 +52,21 @@ tiny-proxy = "0.3" Run as standalone server: ```bash +# Auto-detect listeners from config (recommended) +tiny-proxy --config config.caddy + +# Or specify a single listen address tiny-proxy --config config.caddy --addr 127.0.0.1:8080 ``` #### CLI Arguments - `--config, -c`: Path to configuration file (default: `./file.caddy`) -- `--addr, -a`: Address to listen on (default: `127.0.0.1:8080`) +- `--addr, -a`: Optional. Bind a **single** listener on this address (plain `start()`). + When omitted, **auto-detect mode** (`start_all()`): one listener per site address in config. + TLS sites → HTTPS with SNI; non-TLS → HTTP. In auto-detect mode only, each TLS port also + gets an HTTP→HTTPS redirect listener (`redirect_port = tls_port - 443 + 80`, e.g. 443→80, + 8443→8080). With `--addr`, only the specified listener runs — **no** automatic redirect server. ### Library Mode @@ -109,7 +118,13 @@ async fn main() -> anyhow::Result<()> { #### Hot-Reload Configuration Update configuration at runtime without restart. The proxy uses `Arc>` internally, -so any config change takes effect immediately for new connections: +so routing and directive changes take effect immediately for new connections. + +> **TLS certificates**: cert/key files and `TlsAcceptor` are loaded when a listener starts. +> Hot-reload updates site routing and directives, but **not** TLS certificates — to pick up +> new certs or keys, restart the proxy (or the TLS listener). + +Example: ```rust use tiny_proxy::{Config, Proxy}; @@ -188,6 +203,45 @@ localhost:8080 { Timeout values support duration suffixes: `30s`, `5m`, `2h`, `1d`, or plain numbers (seconds). +#### `tls` + +Enable HTTPS on the frontend with TLS termination. Specify paths to the certificate chain and private key (PEM format). + +```caddy +# Single domain with TLS +example.com:443 { + tls /etc/ssl/cert.pem /etc/ssl/key.pem + reverse_proxy backend:8080 +} + +# Multiple domains on port 443 (SNI-based routing) +example.com:443 { + tls /etc/ssl/example.com/cert.pem /etc/ssl/example.com/key.pem + reverse_proxy backend:8080 +} + +api.example.com:443 { + tls /etc/ssl/api.example.com/cert.pem /etc/ssl/api.example.com/key.pem + reverse_proxy api-backend:3000 +} +``` + +**Auto-detect mode** (no `--addr`, uses `start_all()`): +- One HTTPS listener per TLS site address, with SNI-based certificate selection +- HTTP→HTTPS redirect per TLS port: `redirect_port = tls_port - 443 + 80` (443→80, 8443→8080) +- Correct `X-Forwarded-Proto: https` sent to backends + +**Single-address mode** (`--addr`): only the given listener is started — no automatic redirect server. +Use this when you bind one port manually; use auto-detect for full multi-site + redirect setup. + +> If the redirect port is already in use, HTTPS continues to work; redirect is skipped with a warning. + +> **Host header**: on default ports (443/80), browsers omit the port (`Host: example.com`). +> On non-default TLS ports (e.g. 8443), browsers include it (`Host: example.com:8443`) — config +> keys must match. See `find_site` docs in `handler.rs` for details. + +> **Known limitation**: TLS certs are loaded at listener startup; hot-reload does not reload them (see Hot-Reload above). + #### `handle_path` Match paths with pattern (supports wildcard `*`). @@ -382,7 +436,11 @@ Enable CLI dependencies and `tiny-proxy` binary. #### `tls` (default) -Enable HTTPS backend support using `hyper-rustls` (pure Rust TLS). +Enable TLS support — both **frontend TLS termination** (HTTPS listeners) and **backend HTTPS** connections: + +- Frontend: `rustls` + `tokio-rustls` for HTTPS listeners with SNI-based routing +- Backend: `hyper-rustls` for proxying to HTTPS backends +- `rustls-pemfile` for loading PEM certificate chains and private keys #### `api` (default) @@ -524,7 +582,7 @@ cargo run --example background - ⏳ Static file serving - ⏳ Try files (SPA support) - ⏳ Buffering control -- ⏳ TLS/SSL support +- ✅ TLS/SSL termination (SNI, multi-domain, HTTP→HTTPS redirect) - ⏳ WebSocket support - ⏳ Rate limiting - ✅ Structured access log with X-Request-ID (method, path, host, status, duration, bytes_sent) diff --git a/benches/proxy_bench.rs b/benches/proxy_bench.rs index 0189690..1b2beb3 100644 --- a/benches/proxy_bench.rs +++ b/benches/proxy_bench.rs @@ -428,6 +428,7 @@ fn create_simple_config() -> Config { read_timeout: None, }, ], + tls: None, }, )]), } @@ -463,6 +464,7 @@ fn create_medium_config() -> Config { read_timeout: None, }, ], + tls: None, }, ), ( @@ -474,6 +476,7 @@ fn create_medium_config() -> Config { connect_timeout: None, read_timeout: None, }], + tls: None, }, ), ]), @@ -549,6 +552,7 @@ fn create_complex_config() -> Config { }], }, ], + tls: None, }, )]), } @@ -576,6 +580,7 @@ fn create_multi_site_config(count: usize) -> Config { read_timeout: None, }, ], + tls: None, }, ); } diff --git a/examples/tls.caddy b/examples/tls.caddy new file mode 100644 index 0000000..fc822ee --- /dev/null +++ b/examples/tls.caddy @@ -0,0 +1,27 @@ +# TLS termination example +# +# Requires certificates (run once): +# mkdir -p examples/certs +# openssl req -x509 -newkey rsa:2048 \ +# -keyout examples/certs/server.key \ +# -out examples/certs/server.crt \ +# -days 365 -nodes -subj "/CN=localhost" +# +# Run: +# cargo run --features tls --example tls +# +# Test: +# curl -k https://localhost:8443/health +# curl -k https://localhost:8443/users/123 + +localhost:8443 { + tls examples/certs/server.crt examples/certs/server.key + + handle_path /users/* { + reverse_proxy localhost:9001 + } + + handle_path /health { + respond 200 "OK" + } +} diff --git a/examples/tls.rs b/examples/tls.rs new file mode 100644 index 0000000..bbd656d --- /dev/null +++ b/examples/tls.rs @@ -0,0 +1,56 @@ +//! TLS termination example +//! +//! This example demonstrates how to: +//! - Configure tiny-proxy with TLS (HTTPS) termination +//! - Use `start_all()` to auto-detect HTTP/TLS listeners +//! - Generate self-signed certificates for local testing +//! +//! # Setup +//! +//! Generate certificates first: +//! ```bash +//! mkdir -p examples/certs +//! openssl req -x509 -newkey rsa:2048 \ +//! -keyout examples/certs/server.key \ +//! -out examples/certs/server.crt \ +//! -days 365 -nodes -subj "/CN=localhost" +//! ``` +//! +//! # Run +//! +//! ```bash +//! cargo run --features tls --example tls +//! ``` +//! +//! # Test +//! +//! ```bash +//! curl -k https://localhost:8443/health +//! curl -k https://localhost:8443/users/123 +//! ``` + +use tiny_proxy::{Config, Proxy}; +use tracing::info; +use tracing_subscriber::{fmt, EnvFilter}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); + + info!("Starting tiny-proxy TLS example"); + + let config = Config::from_file("examples/tls.caddy")?; + info!("Loaded configuration for {} site(s)", config.sites.len()); + + let proxy = Proxy::new(config); + + // start_all() auto-detects TLS sites and starts: + // - HTTPS listeners for sites with `tls` directive + // - HTTP→HTTPS redirect on port 80 (for TLS on 443) + info!("Starting listeners (auto-detect from config)"); + proxy.start_all().await?; + + Ok(()) +} diff --git a/src/cli/args.rs b/src/cli/args.rs index deb6f22..011be80 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -8,9 +8,11 @@ pub struct Cli { #[arg(short, long, default_value = "./file.caddy")] pub config: String, - /// Address for proxy server to listen on - #[arg(short = 'a', long, default_value = "127.0.0.1:8080")] - pub addr: String, + /// Address for proxy server to listen on. + /// When omitted, auto-detects listeners from config (one per site address). + /// Use this to override when you want a single listener on a specific address. + #[arg(short = 'a', long)] + pub addr: Option, /// Max concurrent connections (default: CPU cores * 256, use 0 for default) #[arg(long, default_value_t = 0)] diff --git a/src/config/address.rs b/src/config/address.rs new file mode 100644 index 0000000..c5d9d19 --- /dev/null +++ b/src/config/address.rs @@ -0,0 +1,99 @@ +use std::net::SocketAddr; + +/// Resolve a config address string to a [`SocketAddr`] suitable for binding. +/// +/// - `"example.com:443"` → `"0.0.0.0:443"` (bind to all interfaces) +/// - `"0.0.0.0:9090"` → `"0.0.0.0:9090"` +pub fn resolve_listen_addr(address: &str) -> anyhow::Result { + if let Ok(addr) = address.parse::() { + return Ok(addr); + } + + let port = address + .rsplit(':') + .next() + .and_then(|p| p.parse::().ok()) + .ok_or_else(|| anyhow::anyhow!("Cannot parse address: {}", address))?; + + Ok(SocketAddr::from(([0, 0, 0, 0], port))) +} + +/// Extract the hostname portion from an address string (used as SNI key). +/// +/// - `"example.com:443"` → `"example.com"` +/// - `"[::1]:443"` → `"::1"` +/// - `"0.0.0.0:9090"` → `"0.0.0.0"` +pub fn extract_hostname(address: &str) -> &str { + if address.starts_with('[') { + if let Some(end) = address.find(']') { + return &address[1..end]; + } + } + address.rsplit(':').next_back().unwrap_or(address) +} + +/// HTTP port for redirect listeners derived from the TLS listen port. +/// +/// Formula: `tls_port - 443 + 80` (443→80, 8443→8080, etc.) +pub fn tls_redirect_port(tls_port: u16) -> u16 { + tls_port.saturating_sub(443).saturating_add(80) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_listen_addr_ip_port() { + let addr = resolve_listen_addr("0.0.0.0:8443").unwrap(); + assert_eq!(addr, "0.0.0.0:8443".parse::().unwrap()); + } + + #[test] + fn test_resolve_listen_addr_hostname_port() { + let addr = resolve_listen_addr("example.com:443").unwrap(); + assert_eq!(addr.port(), 443); + assert_eq!( + addr.ip(), + std::net::IpAddr::from(std::net::Ipv4Addr::new(0, 0, 0, 0)) + ); + } + + #[test] + fn test_resolve_listen_addr_localhost() { + let addr = resolve_listen_addr("localhost:8080").unwrap(); + assert_eq!(addr.port(), 8080); + } + + #[test] + fn test_resolve_listen_addr_invalid() { + assert!(resolve_listen_addr("no-port-here").is_err()); + } + + #[test] + fn test_extract_hostname_ipv4() { + assert_eq!(extract_hostname("example.com:443"), "example.com"); + } + + #[test] + fn test_extract_hostname_ipv6() { + assert_eq!(extract_hostname("[::1]:443"), "::1"); + } + + #[test] + fn test_extract_hostname_ip() { + assert_eq!(extract_hostname("0.0.0.0:9090"), "0.0.0.0"); + } + + #[test] + fn test_extract_hostname_localhost() { + assert_eq!(extract_hostname("localhost:8080"), "localhost"); + } + + #[test] + fn test_tls_redirect_port() { + assert_eq!(tls_redirect_port(443), 80); + assert_eq!(tls_redirect_port(8443), 8080); + assert_eq!(tls_redirect_port(44300), 43937); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 5e0f54e..11339b4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,6 @@ +mod address; mod models; mod parser; -pub use models::{Config, Directive, SiteConfig}; +pub use address::{extract_hostname, resolve_listen_addr, tls_redirect_port}; +pub use models::{Config, Directive, SiteConfig, TlsConfig}; diff --git a/src/config/models.rs b/src/config/models.rs index 86c5399..17c693d 100644 --- a/src/config/models.rs +++ b/src/config/models.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; #[cfg(feature = "api")] use serde::{Deserialize, Serialize}; -// Models remain as same as we designed #[derive(Debug, Clone)] #[cfg_attr(feature = "api", derive(Serialize, Deserialize))] pub struct Config { @@ -15,6 +14,17 @@ pub struct Config { pub struct SiteConfig { pub address: String, pub directives: Vec, + /// TLS configuration for this site. When present, the site listens as HTTPS. + #[cfg_attr(feature = "api", serde(skip_serializing_if = "Option::is_none"))] + pub tls: Option, +} + +/// TLS configuration for a site — paths to certificate chain and private key. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "api", derive(Serialize, Deserialize))] +pub struct TlsConfig { + pub cert_path: String, + pub key_path: String, } #[derive(Debug, Clone)] diff --git a/src/config/parser.rs b/src/config/parser.rs index 4c99db4..3d25531 100644 --- a/src/config/parser.rs +++ b/src/config/parser.rs @@ -1,6 +1,8 @@ +use crate::config::address::{extract_hostname, resolve_listen_addr}; use crate::config::{Config, Directive, SiteConfig}; use crate::error::ProxyError; use std::collections::HashMap; +use std::net::SocketAddr; use std::str::FromStr; #[derive(Debug)] @@ -67,6 +69,7 @@ impl FromStr for Config { fn from_str(content: &str) -> Result { let mut sites = HashMap::new(); let mut current_site_address: Option = None; + let mut current_site_tls: Option = None; let mut directive_stack: Vec> = vec![vec![]]; let mut block_stack: Vec = vec![]; @@ -155,11 +158,19 @@ impl FromStr for Config { let site_directives = directive_stack .pop() .expect("site directive_stack is non-empty"); + if sites.contains_key(&address) { + return Err(ProxyError::Parse(format!( + "Duplicate site address '{}'. \ + Each address may appear only once in the configuration.", + address + ))); + } sites.insert( address.clone(), SiteConfig { address, directives: site_directives, + tls: current_site_tls.take(), }, ); directive_stack.push(vec![]); @@ -217,6 +228,33 @@ impl FromStr for Config { } } + // Special handling: tls directive at site level + if directive_name == "tls" && block_stack.is_empty() { + let cert_path = args.first().cloned().ok_or_else(|| { + ProxyError::Parse(format!( + "Missing cert path for tls directive on line {}", + line_num + 1 + )) + })?; + let key_path = args.get(1).cloned().ok_or_else(|| { + ProxyError::Parse(format!( + "Missing key path for tls directive on line {}", + line_num + 1 + )) + })?; + if current_site_tls.is_some() { + return Err(ProxyError::Parse(format!( + "Duplicate tls directive on line {}. Only one tls per site is allowed.", + line_num + 1 + ))); + } + current_site_tls = Some(crate::config::TlsConfig { + cert_path: cert_path.to_string(), + key_path: key_path.to_string(), + }); + continue; + } + // Regular directive parsing let directive = match directive_name { "reverse_proxy" => { @@ -319,10 +357,52 @@ impl FromStr for Config { .push(directive); } + validate_listen_sockets(&sites)?; + Ok(Config { sites }) } } +/// Validate TLS/plain consistency and unique SNI hostnames per listen socket. +fn validate_listen_sockets(sites: &HashMap) -> Result<(), ProxyError> { + let mut socket_tls: HashMap = HashMap::new(); + let mut socket_sni: HashMap> = HashMap::new(); + + for site in sites.values() { + let listen_addr = + resolve_listen_addr(&site.address).map_err(|e| ProxyError::Parse(e.to_string()))?; + let is_tls = site.tls.is_some(); + + if let Some(&prev_tls) = socket_tls.get(&listen_addr) { + if prev_tls != is_tls { + return Err(ProxyError::Parse(format!( + "Mixed TLS and non-TLS sites on the same listen address {} is not supported. \ + Site '{}' is {} but conflicts with another site on this socket.", + listen_addr, + site.address, + if is_tls { "TLS" } else { "plain HTTP" } + ))); + } + } else { + socket_tls.insert(listen_addr, is_tls); + } + + if is_tls { + let sni = extract_hostname(&site.address).to_ascii_lowercase(); + let sni_map = socket_sni.entry(listen_addr).or_default(); + if let Some(existing) = sni_map.get(&sni) { + return Err(ProxyError::Parse(format!( + "Duplicate SNI hostname '{}' on listen address {} (sites '{}' and '{}')", + sni, listen_addr, existing, site.address + ))); + } + sni_map.insert(sni, site.address.clone()); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -437,4 +517,136 @@ mod tests { let err_msg = format!("{}", result.unwrap_err()); assert!(err_msg.contains("Unexpected directive"), "{}", err_msg); } + + #[test] + fn test_parse_tls_directive() { + let config = r#"example.com:443 { + tls /etc/ssl/cert.pem /etc/ssl/key.pem + reverse_proxy backend:8080 +}"#; + let result: Config = config.parse().unwrap(); + let site = result.sites.get("example.com:443").unwrap(); + + assert!(site.tls.is_some()); + let tls = site.tls.as_ref().unwrap(); + assert_eq!(tls.cert_path, "/etc/ssl/cert.pem"); + assert_eq!(tls.key_path, "/etc/ssl/key.pem"); + + assert_eq!(site.directives.len(), 1); + match &site.directives[0] { + Directive::ReverseProxy { to, .. } => { + assert_eq!(to, "backend:8080"); + } + _ => panic!("Expected ReverseProxy directive"), + } + } + + #[test] + fn test_parse_tls_missing_cert_path() { + let config = "example.com:443 {\n tls\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("Missing cert path"), "{}", err_msg); + } + + #[test] + fn test_parse_tls_missing_key_path() { + let config = "example.com:443 {\n tls /etc/ssl/cert.pem\n}"; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("Missing key path"), "{}", err_msg); + } + + #[test] + fn test_parse_tls_duplicate_rejected() { + let config = r#"example.com:443 { + tls /a/cert.pem /a/key.pem + tls /b/cert.pem /b/key.pem + reverse_proxy backend:8080 +}"#; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("Duplicate tls"), "{}", err_msg); + } + + #[test] + fn test_parse_mixed_tls_and_non_tls_sites() { + let config = r#"localhost:8080 { + reverse_proxy backend:3000 +} +example.com:443 { + tls /etc/ssl/cert.pem /etc/ssl/key.pem + reverse_proxy backend:8080 +}"#; + let result: Config = config.parse().unwrap(); + + // HTTP site + let http_site = result.sites.get("localhost:8080").unwrap(); + assert!(http_site.tls.is_none()); + + // HTTPS site + let https_site = result.sites.get("example.com:443").unwrap(); + assert!(https_site.tls.is_some()); + } + + #[test] + fn test_parse_duplicate_address_rejected() { + let config = r#"example.com:443 { + tls /a/cert.pem /a/key.pem + reverse_proxy backend:8080 +} +example.com:443 { + reverse_proxy backend:9000 +}"#; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("Duplicate site address"), + "Expected 'Duplicate site address' error, got: {}", + err_msg + ); + } + + #[test] + fn test_parse_mixed_tls_on_same_listen_socket_rejected() { + let config = r#"example.com:443 { + tls /etc/ssl/cert.pem /etc/ssl/key.pem + reverse_proxy backend:8080 +} +0.0.0.0:443 { + reverse_proxy backend:3000 +}"#; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("Mixed TLS and non-TLS"), + "got: {}", + err_msg + ); + } + + #[test] + fn test_parse_duplicate_sni_on_same_listen_socket_rejected() { + let config = r#"Example.com:8443 { + tls /a/cert.pem /a/key.pem + respond 200 "A" +} +example.com:8443 { + tls /b/cert.pem /b/key.pem + respond 200 "B" +}"#; + let result: Result = config.parse(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("Duplicate SNI hostname"), + "got: {}", + err_msg + ); + } } diff --git a/src/lib.rs b/src/lib.rs index a210c9a..00ef2f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,7 @@ pub mod api; // Re-export commonly used types for convenience pub use config::Config; +pub use config::TlsConfig; pub use error::{ProxyError, Result}; pub use proxy::{ActionResult, Proxy}; diff --git a/src/main.rs b/src/main.rs index a80cd89..1b79189 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,18 +17,15 @@ use tiny_proxy::Proxy; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - // Initialize tracing tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) .init(); - // Parse CLI arguments let cli = Cli::parse(); info!("Tiny Proxy Server v{}", env!("CARGO_PKG_VERSION")); info!("Loading config from: {}", cli.config); - // Load configuration let config = Config::from_file(&cli.config)?; #[cfg(feature = "api")] @@ -53,25 +50,44 @@ async fn run_proxy_only(cli: Cli, config: Config) -> Result<(), anyhow::Error> { proxy.set_max_concurrency(cli.max_concurrency); } - info!("Starting proxy server on {}", cli.addr); - // Setup graceful shutdown let shutdown_signal = setup_shutdown_signal(); - // Use tokio::select to wait for either proxy completion or shutdown signal - tokio::select! { - result = proxy.start(&cli.addr) => { - if let Err(e) = result { - error!("Proxy server error: {}", e); - Err(e) - } else { + if let Some(ref addr) = cli.addr { + // Explicit address → single listener + info!("Starting proxy server on {}", addr); + tokio::select! { + result = proxy.start(addr) => { + if let Err(e) = result { + error!("Proxy server error: {}", e); + Err(e) + } else { + Ok(()) + } + }, + _ = shutdown_signal => { + info!("Shutdown signal received"); + info!("Proxy server shutting down..."); + Ok(()) + } + } + } else { + // No address → auto-detect from config + info!("Auto-detecting listeners from config"); + tokio::select! { + result = proxy.start_all() => { + if let Err(e) = result { + error!("Proxy server error: {}", e); + Err(e) + } else { + Ok(()) + } + }, + _ = shutdown_signal => { + info!("Shutdown signal received"); + info!("Proxy server shutting down..."); Ok(()) } - }, - _ = shutdown_signal => { - info!("Shutdown signal received"); - info!("Proxy server shutting down..."); - Ok(()) } } } @@ -85,7 +101,11 @@ async fn run_with_api(cli: Cli, config: Config) -> Result<(), anyhow::Error> { // Create shutdown channel let (shutdown_tx, _) = broadcast::channel::<()>(1); - info!("Starting proxy server on {}", cli.addr); + if let Some(ref addr) = cli.addr { + info!("Starting proxy server on {}", addr); + } else { + info!("Starting proxy server (auto-detect from config)"); + } info!("Starting API server on {}", cli.api_addr); // Spawn API server task @@ -184,7 +204,7 @@ async fn run_with_api(cli: Cli, config: Config) -> Result<(), anyhow::Error> { /// Run proxy server with shared config and shutdown support #[cfg(feature = "api")] async fn run_proxy_server( - addr: String, + addr: Option, max_concurrency: usize, shared_config: Arc>, mut shutdown_rx: broadcast::Receiver<()>, @@ -200,13 +220,25 @@ async fn run_proxy_server( } // Run proxy server - tokio::select! { - result = proxy.start(&addr) => { - result - }, - _ = shutdown_rx.recv() => { - info!("Proxy server received shutdown signal"); - Ok(()) + if let Some(ref a) = addr { + tokio::select! { + result = proxy.start(a) => { + result + }, + _ = shutdown_rx.recv() => { + info!("Proxy server received shutdown signal"); + Ok(()) + } + } + } else { + tokio::select! { + result = proxy.start_all() => { + result + }, + _ = shutdown_rx.recv() => { + info!("Proxy server received shutdown signal"); + Ok(()) + } } } } diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index e858ebc..0f5fc95 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -16,7 +16,7 @@ use tracing::info_span; #[cfg(feature = "logging")] use tracing::Instrument; -use crate::config::Config; +use crate::config::{extract_hostname, Config, SiteConfig}; #[cfg(feature = "logging")] use crate::proxy::access_log::AccessLogGuard; use crate::proxy::access_log::{ensure_request_id, final_request_id}; @@ -142,6 +142,7 @@ pub async fn proxy( client: Client, Incoming>, config: Arc, remote_addr: std::net::SocketAddr, + is_tls: bool, ) -> Result, Error> { // Generate or reuse request ID let initial_request_id = ensure_request_id(&mut req); @@ -171,8 +172,10 @@ pub async fn proxy( host.clone(), ); - // Find site configuration by host (with port!) - let site_config = match config.sites.get(&host) { + // Find site configuration by host + // Browsers send Host: example.com (no port) for default ports, + // but config keys may be "example.com:443". Try both. + let site_config = match find_site(&config, &host, is_tls) { Some(config) => config, None => { error!("No configuration found for host: {}", host); @@ -297,23 +300,11 @@ pub async fn proxy( req.headers_mut().insert("X-Forwarded-Host", host_value); } - // X-Forwarded-Proto: scheme from original request - let original_scheme = req.uri().scheme_str().unwrap_or("http"); - match original_scheme { - "http" => { - req.headers_mut().insert( - "X-Forwarded-Proto", - hyper::header::HeaderValue::from_static("http"), - ); - } - "https" => { - req.headers_mut().insert( - "X-Forwarded-Proto", - hyper::header::HeaderValue::from_static("https"), - ); - } - _ => {} - } + // X-Forwarded-Proto: based on whether the connection is TLS + req.headers_mut().insert( + "X-Forwarded-Proto", + hyper::header::HeaderValue::from_static(if is_tls { "https" } else { "http" }), + ); // X-Forwarded-For: real client IP if let Ok(ip_value) = @@ -469,3 +460,168 @@ pub fn match_pattern(pattern: &str, path: &str) -> Option { None } } + +/// Find a site configuration matching the given Host header value. +/// +/// Browsers on default ports omit the port from the Host header: +/// - HTTPS on 443 → `Host: example.com` (no `:443`) +/// - HTTP on 80 → `Host: example.com` (no `:80`) +/// +/// But config keys include the port: `"example.com:443"`, `"example.com:80"`. +/// +/// This function tries multiple lookup strategies: +/// 1. Exact match: `host` as-is +/// 2. If `host` has no port → try `host:` based on `is_tls` +/// 3. If `host` has a port → also try just the hostname (in case config has no port) +/// +/// # Limitations +/// +/// For non-default TLS ports (e.g., 8443), browsers always include the port +/// in the `Host` header (`Host: example.com:8443`), so strategy 1 (exact match) +/// works fine. The fallback in strategy 2 only tries ports 443 (TLS) and 80 (HTTP). +/// This means a non-browser client sending `Host: example.com` without a port to +/// a TLS listener on :8443 will get a 404 — this is a protocol violation by the client. +pub fn find_site<'a>(config: &'a Config, host: &str, is_tls: bool) -> Option<&'a SiteConfig> { + // 1. Exact match + if let Some(site) = config.sites.get(host) { + return Some(site); + } + + // Determine if host already contains a port + // IPv6: [::1]:8080 — port is after the last ']' + ':' + // IPv4/hostname: example.com:443 + let has_port = if host.starts_with('[') { + // IPv6 — look for port after ']' + if let Some(bracket_end) = host.find(']') { + host[bracket_end..].contains(':') + } else { + false + } + } else { + host.contains(':') + }; + + if !has_port { + // 2. Host has no port — try appending the default port + let default_port = if is_tls { 443 } else { 80 }; + let candidate = format!("{}:{}", host, default_port); + if let Some(site) = config.sites.get(&candidate) { + return Some(site); + } + + // 3. TLS on a non-standard port — match by SNI hostname if unambiguous + if is_tls { + let mut matches = config.sites.values().filter(|s| { + s.tls.is_some() && extract_hostname(&s.address).eq_ignore_ascii_case(host) + }); + if let Some(site) = matches.next() { + if matches.next().is_none() { + return Some(site); + } + } + } + } else { + // 4. Host has a port — try just the hostname (strip port) + let hostname = if host.starts_with('[') { + // IPv6 [::1]:port → ::1 + let end = host.find(']').unwrap_or(host.len()); + host[1..end].to_string() + } else { + host.rsplit(':').next_back().unwrap_or(host).to_string() + }; + if let Some(site) = config.sites.get(&hostname) { + return Some(site); + } + } + + None +} + +#[cfg(test)] +mod find_site_tests { + use super::*; + use std::collections::HashMap; + + fn make_config(sites: Vec<(&str, bool)>) -> Config { + let mut map = HashMap::new(); + for (addr, has_tls) in sites { + map.insert( + addr.to_string(), + crate::config::SiteConfig { + address: addr.to_string(), + directives: vec![], + tls: if has_tls { + Some(crate::config::TlsConfig { + cert_path: "/fake/cert.pem".to_string(), + key_path: "/fake/key.pem".to_string(), + }) + } else { + None + }, + }, + ); + } + Config { sites: map } + } + + #[test] + fn test_exact_match() { + let config = make_config(vec![("example.com:443", true)]); + assert!(find_site(&config, "example.com:443", true).is_some()); + } + + #[test] + fn test_tls_host_without_port_finds_443() { + let config = make_config(vec![("example.com:443", true)]); + // Browser sends Host: example.com (no :443) on HTTPS + assert!( + find_site(&config, "example.com", true).is_some(), + "Should find example.com:443 when Host has no port and is_tls=true" + ); + } + + #[test] + fn test_http_host_without_port_finds_80() { + let config = make_config(vec![("example.com:80", false)]); + // Browser sends Host: example.com (no :80) on HTTP + assert!( + find_site(&config, "example.com", false).is_some(), + "Should find example.com:80 when Host has no port and is_tls=false" + ); + } + + #[test] + fn test_tls_host_without_port_no_match_on_80() { + let config = make_config(vec![("example.com:80", false)]); + // Host: example.com on TLS should NOT match :80 + assert!( + find_site(&config, "example.com", true).is_none(), + "TLS on port 443 should not find :80 site" + ); + } + + #[test] + fn test_host_with_port_strips_port_fallback() { + let config = make_config(vec![("example.com", false)]); + // Config has "example.com" (no port), Host has "example.com:8080" + assert!( + find_site(&config, "example.com:8080", false).is_some(), + "Should strip port from Host and find config without port" + ); + } + + #[test] + fn test_tls_host_without_port_finds_non_standard_port() { + let config = make_config(vec![("alpha.local:8443", true)]); + assert!( + find_site(&config, "alpha.local", true).is_some(), + "Should find alpha.local:8443 when Host has no port on TLS" + ); + } + + #[test] + fn test_no_match() { + let config = make_config(vec![("other.com:443", true)]); + assert!(find_site(&config, "example.com", true).is_none()); + } +} diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index 65af6df..31e180a 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -5,5 +5,8 @@ pub mod handler; mod proxy; mod types; +#[cfg(feature = "tls")] +mod tls; + pub use proxy::Proxy; pub use types::ActionResult; diff --git a/src/proxy/proxy.rs b/src/proxy/proxy.rs index a95adab..78d00b5 100644 --- a/src/proxy/proxy.rs +++ b/src/proxy/proxy.rs @@ -5,14 +5,18 @@ use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioIo; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; use tokio::sync::{RwLock, Semaphore}; -use tracing::{info, warn}; +use tracing::{error, info, warn}; -use crate::config::Config; +#[cfg(feature = "tls")] +use crate::proxy::tls::{build_tls_acceptor, listen_http_redirect, listen_tls}; + +use crate::config::{extract_hostname, resolve_listen_addr, tls_redirect_port, Config}; use crate::proxy::handler::proxy; /// HTTP Proxy server that can be embedded into other applications @@ -175,9 +179,13 @@ impl Proxy { /// This method blocks indefinitely, handling incoming connections. /// To run the proxy in the background, spawn it in a tokio task. /// + /// Starts a **single** listener on `addr`. If matching sites use TLS, an HTTPS + /// listener is started; otherwise plain HTTP. Does **not** start HTTP→HTTPS + /// redirect servers — use [`Self::start_all`] for auto-detect multi-listener mode. + /// /// # Arguments /// - /// * `addr` - Address to listen on (e.g., "127.0.0.1:8080" or "0.0.0.0:8080") + /// * `addr` - Address to listen on (e.g., "127.0.0.1:8080" or "0.0.0.0:8443") /// /// # Example /// @@ -191,23 +199,6 @@ impl Proxy { /// # Ok(()) /// # } /// ``` - /// - /// To run in background: - /// ```no_run - /// # use tiny_proxy::{Config, Proxy}; - /// # #[tokio::main] - /// # async fn main() -> anyhow::Result<()> { - /// # let config = Config::from_file("config.caddy")?; - /// # let proxy = std::sync::Arc::new(Proxy::new(config)); - /// let handle = tokio::spawn(async move { - /// if let Err(e) = proxy.start("127.0.0.1:8080").await { - /// eprintln!("Proxy error: {}", e); - /// } - /// }); - /// # handle.await?; - /// # Ok(()) - /// # } - /// ``` pub async fn start(&self, addr: &str) -> anyhow::Result<()> { let addr: SocketAddr = addr.parse()?; self.start_with_addr(addr).await @@ -215,17 +206,208 @@ impl Proxy { /// Start the proxy server with a parsed SocketAddr /// - /// This is a convenience method if you already have a parsed SocketAddr. + /// Same as [`Self::start`]: one listener on `addr`, HTTPS or HTTP depending on + /// site TLS config. No automatic HTTP→HTTPS redirect — see [`Self::start_all`]. /// /// # Arguments /// /// * `addr` - Parsed SocketAddr to listen on pub async fn start_with_addr(&self, addr: SocketAddr) -> anyhow::Result<()> { - let listener = TcpListener::bind(&addr).await?; + // Check if any site on this address has TLS configured + let config_snapshot = self.config.read().await.clone(); + let tls_sites: Vec<(String, crate::config::TlsConfig)> = config_snapshot + .sites + .values() + .filter(|site| { + // Check if the site's address matches the listening addr + // Site address can be "host:port" or just ":port" + site_addr_matches(&site.address, &addr) && site.tls.is_some() + }) + .filter_map(|site| { + // Extract hostname for SNI, TLS config + let hostname = extract_hostname(&site.address); + site.tls.clone().map(|tls| (hostname.to_string(), tls)) + }) + .collect(); + + if !tls_sites.is_empty() { + #[cfg(feature = "tls")] + { + self.start_tls(addr, tls_sites).await + } + #[cfg(not(feature = "tls"))] + { + anyhow::bail!( + "TLS configuration found for {} but 'tls' feature is disabled. \ + Refusing to start as plain HTTP (security risk). \ + Rebuild with --features tls or remove 'tls' from config.", + addr + ); + } + } else { + self.start_http(addr).await + } + } + + /// Start all listeners defined in the configuration (auto-detect mode). + /// + /// Scans the config for all unique listen addresses and starts a listener + /// for each. TLS sites get HTTPS listeners with SNI; non-TLS sites get HTTP. + /// + /// For each distinct TLS port, also starts an HTTP→HTTPS redirect listener: + /// `redirect_port = tls_port - 443 + 80` (e.g. 443→80, 8443→8080). + /// Redirect bind is best-effort: if the redirect port is in use, HTTPS still works. + /// + /// Unlike [`Self::start`] / [`Self::start_with_addr`], this method spawns multiple + /// listeners and redirect servers. Use this when the config defines several site + /// addresses (CLI without `--addr`). + /// + /// This method blocks until all listener tasks finish (typically forever). + /// + /// # Example + /// + /// ```no_run + /// # use tiny_proxy::{Config, Proxy}; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// # let config = Config::from_file("config.caddy")?; + /// # let proxy = std::sync::Arc::new(Proxy::new(config)); + /// proxy.start_all().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn start_all(&self) -> anyhow::Result<()> { + let config_snapshot = self.config.read().await.clone(); + + // Group sites by resolved listen socket (multiple hostnames may share one port) + let mut socket_groups: HashMap> = + HashMap::new(); + for site in config_snapshot.sites.values() { + let listen_addr = resolve_listen_addr(&site.address)?; + socket_groups.entry(listen_addr).or_default().push(site); + } + + let mut http_handles = Vec::new(); + let mut tls_redirects: HashSet<(SocketAddr, u16)> = HashSet::new(); // (redirect bind addr, tls_port) + + for (listen_addr, sites) in socket_groups { + let tls_sites: Vec<_> = sites.iter().copied().filter(|s| s.tls.is_some()).collect(); + let has_tls = !tls_sites.is_empty(); + let has_plain = tls_sites.len() != sites.len(); + + if has_tls && has_plain { + anyhow::bail!( + "Mixed TLS and non-TLS sites on the same listen address {} is not supported", + listen_addr + ); + } + + if has_tls { + #[cfg(feature = "tls")] + { + let tls_entries: Vec<(String, crate::config::TlsConfig)> = tls_sites + .iter() + .filter_map(|s| { + let hostname = extract_hostname(&s.address); + s.tls.clone().map(|tls| (hostname.to_string(), tls)) + }) + .collect(); + + let tls_port = listen_addr.port(); + + let client = self.client.clone(); + let config = self.config.clone(); + let semaphore = self.semaphore.clone(); + + let acceptor = build_tls_acceptor(&tls_entries, None)?; + info!( + "Starting HTTPS listener on {} ({} domain(s))", + listen_addr, + tls_entries.len() + ); + + let handle = tokio::spawn(async move { + if let Err(e) = + listen_tls(listen_addr, acceptor, semaphore, move |req, remote_addr| { + let client = client.clone(); + let config = config.clone(); + async move { + let config_guard = config.read().await; + let config_snapshot = Arc::new(config_guard.clone()); + drop(config_guard); + proxy(req, client, config_snapshot, remote_addr, true).await + } + }) + .await + { + error!("TLS listener error: {}", e); + } + }); + http_handles.push(handle); + + tls_redirects.insert(( + SocketAddr::new(listen_addr.ip(), tls_redirect_port(tls_port)), + tls_port, + )); + } + + #[cfg(not(feature = "tls"))] + { + anyhow::bail!( + "TLS configuration found for {} but 'tls' feature is disabled. \ + Refusing to start as plain HTTP (security risk). \ + Rebuild with --features tls or remove 'tls' from config.", + listen_addr + ); + } + } else { + let client = self.client.clone(); + let config = self.config.clone(); + let semaphore = self.semaphore.clone(); + let max_concurrency = self.max_concurrency; + + let handle = tokio::spawn(async move { + if let Err(e) = + Self::run_http_loop(listen_addr, client, config, semaphore, max_concurrency) + .await + { + error!("HTTP listener error: {}", e); + } + }); + http_handles.push(handle); + } + } + + #[cfg(feature = "tls")] + for (redirect_addr, tls_port) in tls_redirects { + info!( + "Starting HTTP→HTTPS redirect on http://{} → :{}", + redirect_addr, tls_port + ); + let handle = tokio::spawn(async move { + match listen_http_redirect(redirect_addr, tls_port).await { + Ok(()) => {} + Err(e) => { + warn!( + "HTTP redirect on port {} failed (HTTPS on :{} still active): {}", + redirect_addr.port(), + tls_port, + e + ); + } + } + }); + http_handles.push(handle); + } + + if http_handles.is_empty() { + warn!("No listeners configured — proxy has no sites"); + return Ok(()); + } - info!("Tiny Proxy listening on http://{}", addr); info!( - "Max concurrency: {} ({})", + "Started {} listener(s), max concurrency: {} ({})", + http_handles.len(), self.max_concurrency, if self.max_concurrency == num_cpus::get() * 256 { "default" @@ -234,12 +416,46 @@ impl Proxy { } ); + // Wait for any listener to finish (they run forever, so this blocks indefinitely) + // If one fails, the others keep running. + for handle in http_handles { + if let Err(e) = handle.await { + error!("Listener task panicked: {}", e); + } + } + + Ok(()) + } + + /// Start a plain HTTP listener on the given address. + async fn start_http(&self, addr: SocketAddr) -> anyhow::Result<()> { + Self::run_http_loop( + addr, + self.client.clone(), + self.config.clone(), + self.semaphore.clone(), + self.max_concurrency, + ) + .await + } + + /// Core HTTP accept loop — shared between `start_http` and `start_all`. + async fn run_http_loop( + addr: SocketAddr, + client: Client, Incoming>, + config: Arc>, + semaphore: Arc, + max_concurrency: usize, + ) -> anyhow::Result<()> { + let listener = TcpListener::bind(&addr).await?; + info!("Tiny Proxy listening on http://{}", addr); + loop { let (stream, remote_addr) = listener.accept().await?; let io = TokioIo::new(stream); - let client = self.client.clone(); - let config = self.config.clone(); - let semaphore = self.semaphore.clone(); + let client = client.clone(); + let config = config.clone(); + let semaphore = semaphore.clone(); match semaphore.try_acquire_owned() { Ok(permit) => { @@ -254,7 +470,7 @@ impl Proxy { let config_guard = config_clone.read().await; let config_snapshot = Arc::new(config_guard.clone()); drop(config_guard); - proxy(req, client, config_snapshot, remote_addr).await + proxy(req, client, config_snapshot, remote_addr, false).await } }); @@ -267,13 +483,44 @@ impl Proxy { Err(_) => { warn!( "Concurrency limit exceeded ({}), rejecting connection", - self.max_concurrency + max_concurrency ); } } } } + /// Start a TLS listener on the given address with the specified TLS sites. + #[cfg(feature = "tls")] + async fn start_tls( + &self, + addr: SocketAddr, + tls_sites: Vec<(String, crate::config::TlsConfig)>, + ) -> anyhow::Result<()> { + let acceptor = build_tls_acceptor(&tls_sites, None)?; + info!( + "Starting HTTPS listener on https://{} ({} domain(s))", + addr, + tls_sites.len() + ); + + let client = self.client.clone(); + let config = self.config.clone(); + let semaphore = self.semaphore.clone(); + + listen_tls(addr, acceptor, semaphore, move |req, remote_addr| { + let client = client.clone(); + let config = config.clone(); + async move { + let config_guard = config.read().await; + let config_snapshot = Arc::new(config_guard.clone()); + drop(config_guard); + proxy(req, client, config_snapshot, remote_addr, true).await + } + }) + .await + } + /// Get a reference to the shared configuration handle /// /// This returns a clone of the `Arc>`, allowing @@ -326,9 +573,12 @@ impl Proxy { /// Update the configuration at runtime (hot-reload) /// - /// This atomically replaces the configuration. New connections will - /// use the updated configuration immediately. Existing connections - /// will continue to use their original configuration snapshot. + /// Atomically replaces routing configuration. New connections use the updated + /// config immediately; in-flight connections keep their original snapshot. + /// + /// **TLS certificates** are loaded when a listener starts. This method updates + /// site routing and directives only — not cert/key files or `TlsAcceptor`. + /// Restart the proxy (or TLS listener) to pick up new certificates. /// /// # Arguments /// @@ -340,6 +590,45 @@ impl Proxy { } } +/// Check if a site address string matches a SocketAddr. +/// +/// The site address may be `"host:port"`, `"host"` (no port), +/// or `":port"` (any host). This function compares ports and, +/// if the site specifies a hostname (not empty, not `0.0.0.0`), +/// also compares hostnames. +fn site_addr_matches(site_address: &str, listen_addr: &SocketAddr) -> bool { + let mut parts = site_address.rsplitn(2, ':'); + let port_str = parts.next().unwrap_or(""); + let host_str = parts.next().unwrap_or(""); + + let site_port: u16 = match port_str.parse() { + Ok(p) => p, + Err(_) => return false, + }; + + if site_port != listen_addr.port() { + return false; + } + + // If site has a specific hostname, check it + if host_str.is_empty() || host_str == "0.0.0.0" || host_str == "::" { + return true; // wildcard host + } + + // Match against listen addr IP + // site may have "localhost" → resolve to 127.0.0.1 + let site_ip = if host_str == "localhost" { + std::net::IpAddr::from(std::net::Ipv4Addr::new(127, 0, 0, 1)) + } else if let Ok(ip) = host_str.parse::() { + ip + } else { + // hostname-based (e.g. "example.com:443") — match by port only + return true; + }; + + site_ip == listen_addr.ip() +} + #[cfg(test)] mod tests { use super::*; @@ -367,6 +656,7 @@ mod tests { crate::config::SiteConfig { address: "localhost:8080".to_string(), directives: vec![], + tls: None, }, ); @@ -393,6 +683,7 @@ mod tests { crate::config::SiteConfig { address: "test.local".to_string(), directives: vec![], + tls: None, }, ); @@ -419,6 +710,7 @@ mod tests { crate::config::SiteConfig { address: "shared.local".to_string(), directives: vec![], + tls: None, }, ); } @@ -446,6 +738,7 @@ mod tests { crate::config::SiteConfig { address: "from-shared.local".to_string(), directives: vec![], + tls: None, }, ); } @@ -453,4 +746,37 @@ mod tests { assert_eq!(snapshot.sites.len(), 1); assert!(snapshot.sites.contains_key("from-shared.local")); } + + // --- site_addr_matches tests --- + + #[test] + fn test_site_addr_matches_localhost() { + let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert!(site_addr_matches("localhost:8080", &addr)); + } + + #[test] + fn test_site_addr_matches_ip() { + let addr: SocketAddr = "0.0.0.0:443".parse().unwrap(); + assert!(site_addr_matches("0.0.0.0:443", &addr)); + } + + #[test] + fn test_site_addr_matches_hostname_by_port() { + let addr: SocketAddr = "0.0.0.0:443".parse().unwrap(); + // Domain-based address matches by port only + assert!(site_addr_matches("example.com:443", &addr)); + } + + #[test] + fn test_site_addr_matches_port_mismatch() { + let addr: SocketAddr = "0.0.0.0:443".parse().unwrap(); + assert!(!site_addr_matches("example.com:8443", &addr)); + } + + #[test] + fn test_site_addr_matches_wildcard_host() { + let addr: SocketAddr = "0.0.0.0:9090".parse().unwrap(); + assert!(site_addr_matches(":9090", &addr)); + } } diff --git a/src/proxy/tls.rs b/src/proxy/tls.rs new file mode 100644 index 0000000..cd9bc68 --- /dev/null +++ b/src/proxy/tls.rs @@ -0,0 +1,399 @@ +//! TLS termination for incoming HTTPS connections. +//! +//! This module provides: +//! - Certificate/key loading from PEM files +//! - SNI-based certificate resolution (multiple domains on one port) +//! - HTTPS listener that wraps `tokio::net::TcpStream` with `tokio-rustls` +//! - HTTP → HTTPS redirect server (port derived from TLS port: `tls_port - 443 + 80`) + +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; +use std::net::SocketAddr; +use std::sync::Arc; + +use http_body_util::BodyExt; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; +use rustls::ServerConfig; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; +use tracing::{debug, error, info, warn}; + +use crate::config::TlsConfig; + +// --------------------------------------------------------------------------- +// Certificate loading helpers +// --------------------------------------------------------------------------- + +/// Load a certificate chain from a PEM file. +/// +/// Returns all certificates found in the file (typically one leaf + intermediates). +fn load_certs( + path: &str, +) -> Result>, std::io::Error> { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let certs: Vec<_> = rustls_pemfile::certs(&mut reader).collect::, _>>()?; + if certs.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("No certificates found in {}", path), + )); + } + Ok(certs) +} + +/// Load a private key from a PEM file. +/// +/// Supports RSA and EC keys. Returns the first key found. +fn load_key(path: &str) -> Result, std::io::Error> { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + // private_key() tries RSA, PKCS8, EC, in order + rustls_pemfile::private_key(&mut reader)?.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("No private key found in {}", path), + ) + }) +} + +// --------------------------------------------------------------------------- +// SNI-based certificate resolver +// --------------------------------------------------------------------------- + +/// Maps SNI hostnames to pre-loaded `CertifiedKey` pairs. +/// +/// When a TLS ClientHello arrives with an SNI extension, the resolver +/// looks up the hostname (case-insensitive) and returns the matching +/// certificate chain + signing key. +/// +/// A single default certificate can be set for clients that don't send SNI. +struct SniCertResolver { + entries: HashMap>, + default: Option>, +} + +impl std::fmt::Debug for SniCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SniCertResolver") + .field("entries", &self.entries.keys().collect::>()) + .finish() + } +} + +impl SniCertResolver { + fn new( + entries: HashMap>, + default: Option>, + ) -> Self { + Self { entries, default } + } +} + +impl ResolvesServerCert for SniCertResolver { + fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { + if let Some(sni) = client_hello.server_name() { + if let Some(cert) = self.entries.get(&sni.to_ascii_lowercase()) { + return Some(Arc::clone(cert)); + } + } + self.default.clone() + } +} + +// --------------------------------------------------------------------------- +// Public builder: create a TlsAcceptor from site configs +// --------------------------------------------------------------------------- + +/// Build a `TlsAcceptor` from a collection of `{ hostname -> TlsConfig }` entries. +/// +/// All entries share the same listener / port (the caller is responsible for +/// grouping sites by port before calling this). +/// +/// If `default_hostname` is provided, that domain's certificate is used as the +/// fallback for clients that don't send SNI. +/// +/// # Errors +/// +/// Returns an error if any certificate or key file cannot be loaded or parsed. +pub fn build_tls_acceptor( + sites: &[(String, TlsConfig)], // (hostname, tls_config) + default_hostname: Option<&str>, +) -> Result { + if sites.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Cannot build TLS acceptor: no TLS sites provided", + )); + } + + let mut entries: HashMap> = HashMap::new(); + let mut default: Option> = None; + + for (hostname, tls_cfg) in sites { + let certs = load_certs(&tls_cfg.cert_path)?; + let key = load_key(&tls_cfg.key_path)?; + + let signing_key = + rustls::crypto::aws_lc_rs::sign::any_supported_type(&key).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Unsupported key type in {} for host {}: {}", + tls_cfg.key_path, hostname, e + ), + ) + })?; + + let certified_key = Arc::new(CertifiedKey::new(certs, signing_key)); + + if default_hostname.is_some() + && hostname.eq_ignore_ascii_case(default_hostname.unwrap_or("")) + { + default = Some(Arc::clone(&certified_key)); + } + + entries.insert(hostname.to_ascii_lowercase(), certified_key); + } + + // If no explicit default, use the lexicographically first SNI hostname + if default.is_none() { + let mut hostnames: Vec<_> = entries.keys().cloned().collect(); + hostnames.sort(); + if let Some(first) = hostnames.first() { + default = entries.get(first).cloned(); + } + } + + let resolver = Arc::new(SniCertResolver::new(entries, default)); + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver); + + Ok(TlsAcceptor::from(Arc::new(config))) +} + +// --------------------------------------------------------------------------- +// HTTPS listener +// --------------------------------------------------------------------------- + +/// Bind a TLS listener and serve incoming connections. +/// +/// Each accepted connection goes through: +/// 1. TLS handshake via `TlsAcceptor` +/// 2. HTTP/1.1 parsing via hyper +/// 3. Dispatched to the same `service_fn` that the plain-HTTP path uses +/// +/// The `service_fn` closure is constructed per-connection and receives +/// `(req, remote_addr)` — exactly the same data as the non-TLS path. +/// +/// This function runs forever (until the listener is closed or an unrecoverable error). +pub async fn listen_tls( + addr: SocketAddr, + acceptor: TlsAcceptor, + semaphore: Arc, + make_service: F, +) -> anyhow::Result<()> +where + F: Fn(hyper::Request, std::net::SocketAddr) -> Fut + + Clone + + Send + + 'static, + Fut: std::future::Future< + Output = Result< + hyper::Response< + http_body_util::combinators::BoxBody< + bytes::Bytes, + Box, + >, + >, + anyhow::Error, + >, + > + Send, +{ + let listener = TcpListener::bind(addr).await?; + info!("TLS listener bound on https://{}", addr); + + loop { + let (tcp_stream, remote_addr) = listener.accept().await?; + let io = hyper_util::rt::TokioIo::new(tcp_stream); + let acceptor = acceptor.clone(); + let semaphore = semaphore.clone(); + let make_service = make_service.clone(); + + let permit = match semaphore.try_acquire_owned() { + Ok(p) => p, + Err(_) => { + warn!( + "TLS concurrency limit exceeded, rejecting connection from {}", + remote_addr + ); + continue; + } + }; + + tokio::task::spawn(async move { + let _permit = permit; // held until task completes + + let tls_stream = match acceptor.accept(io.into_inner()).await { + Ok(s) => s, + Err(e) => { + // Handshake failures are common (wrong SNI, expired cert, etc.) + // Don't log at error level to avoid noise + info!("TLS handshake failed from {}: {}", remote_addr, e); + return; + } + }; + + let io = hyper_util::rt::TokioIo::new(tls_stream); + let make_service = make_service.clone(); + + let service = hyper::service::service_fn(move |req| { + let make_service = make_service.clone(); + make_service(req, remote_addr) + }); + + let mut builder = hyper::server::conn::http1::Builder::new(); + builder.keep_alive(true).pipeline_flush(false); + + if let Err(e) = builder.serve_connection(io, service).await { + error!("TLS connection error from {}: {:?}", remote_addr, e); + } + }); + } +} + +// --------------------------------------------------------------------------- +// HTTP → HTTPS redirect server +// --------------------------------------------------------------------------- + +/// Strip the port from a `Host` header value, handling IPv6 (`[::1]:8080` → `::1`). +fn hostname_from_host_header(host: &str) -> &str { + if host.starts_with('[') { + if let Some(end) = host.find(']') { + return &host[1..end]; + } + } + host.rsplit(':').next_back().unwrap_or(host) +} + +/// Start a plain-HTTP server that redirects all requests to HTTPS. +/// +/// Listens on `addr` (the computed redirect port) and responds with `301 Moved Permanently`. +/// The `Location` header targets `https://{host}{uri}` when `tls_port` is 443, or +/// `https://{host}:{tls_port}{uri}` for non-standard TLS ports (e.g. 8443). +/// +/// For each request, the `Host` header is used to construct the redirect target: +/// ```text +/// HTTP/1.1 301 Moved Permanently +/// Location: https://{host}{uri} (tls_port == 443) +/// Location: https://{host}:8443{uri} (tls_port == 8443) +/// ``` +/// +/// If the `Host` header is missing, `"localhost"` is used as the hostname. +pub async fn listen_http_redirect(addr: SocketAddr, tls_port: u16) -> anyhow::Result<()> { + let listener = TcpListener::bind(addr).await?; + info!( + "HTTP→HTTPS redirect server listening on http://{} → :{}", + addr, tls_port + ); + + loop { + let (stream, remote_addr) = listener.accept().await?; + let io = hyper_util::rt::TokioIo::new(stream); + + tokio::task::spawn(async move { + let service = + hyper::service::service_fn(move |req: hyper::Request| { + let host = req + .headers() + .get(hyper::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"); + + // Strip port from host if present — we'll add the TLS port + let hostname = hostname_from_host_header(host); + + let uri = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + + let location = if tls_port == 443 { + format!("https://{}{}", hostname, uri) + } else { + format!("https://{}:{}{}", hostname, tls_port, uri) + }; + + let body = format!( + "301 Moved\ +

Moved Permanently

The document has moved \ + here.

", + location + ); + let body_len = body.len(); + + let response = hyper::Response::builder() + .status(301) + .header("Location", &location) + .header("Content-Type", "text/html; charset=utf-8") + .header("Content-Length", body_len) + .body( + http_body_util::Full::new(bytes::Bytes::from(body)) + .map_err(|e| { + Box::new(e) as Box + }) + .boxed(), + ) + .expect("static redirect response build"); + + std::future::ready(Ok::<_, hyper::Error>(response)) + }); + + let mut builder = hyper::server::conn::http1::Builder::new(); + builder.keep_alive(false).pipeline_flush(false); + + if let Err(e) = builder.serve_connection(io, service).await { + debug!("Redirect connection error from {}: {:?}", remote_addr, e); + } + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_tls_acceptor_empty_sites() { + let result = build_tls_acceptor(&[], None); + assert!(result.is_err()); + } + + #[test] + fn test_hostname_from_host_header_ipv6() { + assert_eq!(hostname_from_host_header("[::1]:8080"), "::1"); + assert_eq!(hostname_from_host_header("example.com:8080"), "example.com"); + assert_eq!(hostname_from_host_header("example.com"), "example.com"); + } + + #[test] + fn test_load_certs_nonexistent_file() { + let result = load_certs("/nonexistent/path/cert.pem"); + assert!(result.is_err()); + } + + #[test] + fn test_load_key_nonexistent_file() { + let result = load_key("/nonexistent/path/key.pem"); + assert!(result.is_err()); + } +} diff --git a/tests/tls_integration.rs b/tests/tls_integration.rs new file mode 100644 index 0000000..7490fed --- /dev/null +++ b/tests/tls_integration.rs @@ -0,0 +1,459 @@ +//! Integration tests for TLS termination. +//! +//! Generates self-signed certificates at runtime using `rcgen`, +//! then exercises the full HTTPS pipeline: handshake, SNI, redirect, X-Forwarded-Proto. + +use http_body_util::BodyExt; +use tiny_proxy::config::tls_redirect_port; + +/// Generate a self-signed certificate + key pair (PEM) for the given hostname. +fn generate_self_signed_cert(hostname: &str) -> (String, String) { + let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()]).unwrap(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, hostname); + + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + (cert_pem, key_pem) +} + +/// Write PEM data to a temp file, return the path. +fn write_temp_pem(prefix: &str, data: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::with_prefix(prefix).unwrap(); + use std::io::Write; + f.write_all(data.as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +/// Build a Config with a single HTTPS site for the given hostname + port. +fn make_tls_config(hostname_port: &str, cert_path: &str, key_path: &str) -> tiny_proxy::Config { + let mut sites = std::collections::HashMap::new(); + sites.insert( + hostname_port.to_string(), + tiny_proxy::config::SiteConfig { + address: hostname_port.to_string(), + directives: vec![tiny_proxy::config::Directive::Respond { + status: 200, + body: "TLS OK".to_string(), + }], + tls: Some(tiny_proxy::config::TlsConfig { + cert_path: cert_path.to_string(), + key_path: key_path.to_string(), + }), + }, + ); + tiny_proxy::Config { sites } +} + +/// Build a TLS root certificate store that trusts our self-signed cert. +fn build_tls_root_store(cert_pem: &str) -> rustls::RootCertStore { + let mut roots = rustls::RootCertStore::empty(); + let cert_der = rustls_pemfile::certs(&mut std::io::BufReader::new(cert_pem.as_bytes())) + .collect::, _>>() + .unwrap(); + roots.add(cert_der[0].clone()).unwrap(); + roots +} + +/// Build a hyper HTTP client that trusts our self-signed cert. +fn build_tls_https_client( + roots: rustls::RootCertStore, +) -> hyper_util::client::legacy::Client< + hyper_rustls::HttpsConnector, + http_body_util::Empty, +> { + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + let mut http = hyper_util::client::legacy::connect::HttpConnector::new(); + http.enforce_http(false); // Allow HTTPS URLs to pass through + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_config) + .https_or_http() + .enable_http1() + .wrap_connector(http); + + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build::<_, http_body_util::Empty>(https) +} + +/// Collect response body bytes. +async fn body_bytes(body: hyper::body::Incoming) -> Vec { + body.collect().await.unwrap().to_bytes().to_vec() +} + +/// Bind to a random port, return the address. Drops the listener +/// so the port is available for the proxy to bind. +async fn get_random_port_addr() -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + addr +} + +#[tokio::test] +async fn test_tls_handshake_and_response() { + // 1. Generate self-signed cert + let (cert_pem, key_pem) = generate_self_signed_cert("localhost"); + let cert_file = write_temp_pem("cert", &cert_pem); + let key_file = write_temp_pem("key", &key_pem); + + // 2. Build client trust store + let roots = build_tls_root_store(&cert_pem); + + // 3. Get a random port + let addr = get_random_port_addr().await; + + // 4. Build proxy config using the actual port + let config = make_tls_config( + &format!("localhost:{}", addr.port()), + cert_file.path().to_str().unwrap(), + key_file.path().to_str().unwrap(), + ); + + // 5. Start proxy + let proxy = tiny_proxy::Proxy::new(config); + let proxy_handle = tokio::spawn(async move { proxy.start(&addr.to_string()).await }); + + // Give the proxy time to start + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // 6. Make HTTPS request + let client = build_tls_https_client(roots); + let uri = format!("https://localhost:{}/", addr.port()) + .parse::() + .unwrap(); + + let resp = client.get(uri).await.unwrap(); + assert_eq!(resp.status(), 200); + + let body = body_bytes(resp.into_body()).await; + assert_eq!(&body[..], b"TLS OK"); + + proxy_handle.abort(); +} + +#[tokio::test] +async fn test_tls_x_forwarded_proto_https() { + let (cert_pem, key_pem) = generate_self_signed_cert("localhost"); + let cert_file = write_temp_pem("cert", &cert_pem); + let key_file = write_temp_pem("key", &key_pem); + + let roots = build_tls_root_store(&cert_pem); + + // Start backend on random port — echoes X-Forwarded-Proto + let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend_listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = backend_listener.accept().await.unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + let service = hyper::service::service_fn( + |req: hyper::Request| async move { + let proto = req + .headers() + .get("X-Forwarded-Proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing") + .to_string(); + let body = http_body_util::Full::new(bytes::Bytes::from(proto)) + .map_err(|e| -> std::convert::Infallible { e }) + .boxed(); + Ok::<_, std::convert::Infallible>(hyper::Response::new(body)) + }, + ); + tokio::spawn(async move { + hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await + .ok(); + }); + } + }); + + // Get a random port for the proxy + let proxy_addr = get_random_port_addr().await; + + // Proxy config pointing to backend + let mut sites = std::collections::HashMap::new(); + sites.insert( + format!("localhost:{}", proxy_addr.port()), + tiny_proxy::config::SiteConfig { + address: format!("localhost:{}", proxy_addr.port()), + directives: vec![tiny_proxy::config::Directive::ReverseProxy { + to: format!("http://127.0.0.1:{}", backend_addr.port()), + connect_timeout: None, + read_timeout: None, + }], + tls: Some(tiny_proxy::config::TlsConfig { + cert_path: cert_file.path().to_str().unwrap().to_string(), + key_path: key_file.path().to_str().unwrap().to_string(), + }), + }, + ); + + let config = tiny_proxy::Config { sites }; + let proxy = tiny_proxy::Proxy::new(config); + + let proxy_handle = tokio::spawn(async move { proxy.start(&proxy_addr.to_string()).await }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Make HTTPS request through proxy + let client = build_tls_https_client(roots); + let uri = format!("https://localhost:{}/", proxy_addr.port()) + .parse::() + .unwrap(); + + let resp = client.get(uri).await.unwrap(); + assert_eq!(resp.status(), 200); + + let body = body_bytes(resp.into_body()).await; + assert_eq!(&body[..], b"https", "X-Forwarded-Proto should be 'https'"); + + proxy_handle.abort(); +} + +#[tokio::test] +async fn test_find_site_tls_port_normalization() { + // This tests the Host header normalization: browser sends "example.com" + // on HTTPS, but config key is "example.com:443" + let config = tiny_proxy::Config { + sites: { + let mut m = std::collections::HashMap::new(); + m.insert( + "example.com:443".to_string(), + tiny_proxy::config::SiteConfig { + address: "example.com:443".to_string(), + directives: vec![], + tls: Some(tiny_proxy::config::TlsConfig { + cert_path: "/fake/cert.pem".to_string(), + key_path: "/fake/key.pem".to_string(), + }), + }, + ); + m + }, + }; + + // Exact match + let exact = tiny_proxy::proxy::handler::find_site(&config, "example.com:443", true); + assert!(exact.is_some()); + + // Browser-style Host without port (TLS → try :443) + let no_port = tiny_proxy::proxy::handler::find_site(&config, "example.com", true); + assert!( + no_port.is_some(), + "Should find example.com:443 when Host='example.com' and is_tls=true" + ); + + // Non-TLS → try :80, should NOT find :443 + let wrong_proto = tiny_proxy::proxy::handler::find_site(&config, "example.com", false); + assert!( + wrong_proto.is_none(), + "Should NOT find :443 site when is_tls=false (would try :80)" + ); +} + +/// TLS GET with an explicit SNI hostname (connects to 127.0.0.1). +async fn tls_http_get( + sni_host: &str, + port: u16, + roots: &rustls::RootCertStore, + path: &str, +) -> (u16, Vec) { + use rustls::pki_types::ServerName; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_rustls::TlsConnector; + + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP connect"); + let config = std::sync::Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots.clone()) + .with_no_client_auth(), + ); + let connector = TlsConnector::from(config); + let server_name = ServerName::try_from(sni_host.to_string()).expect("valid SNI"); + let mut tls = connector + .connect(server_name, stream) + .await + .expect("TLS handshake"); + + let request = format!( + "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + path, sni_host + ); + tls.write_all(request.as_bytes()) + .await + .expect("write request"); + + let mut buf = vec![0u8; 8192]; + let n = tls.read(&mut buf).await.expect("read response"); + buf.truncate(n); + + let response = String::from_utf8_lossy(&buf); + let status_line = response.lines().next().unwrap_or(""); + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let body_start = response.find("\r\n\r\n").map(|i| i + 4).unwrap_or(0); + (status, buf[body_start..].to_vec()) +} + +/// Plain HTTP GET (for redirect tests). +async fn plain_http_get(host: &str, port: u16, path: &str) -> (u16, Option) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP connect"); + let request = format!( + "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + path, host + ); + stream + .write_all(request.as_bytes()) + .await + .expect("write request"); + + let mut buf = vec![0u8; 8192]; + let n = stream.read(&mut buf).await.expect("read response"); + buf.truncate(n); + + let response = String::from_utf8_lossy(&buf); + let status_line = response.lines().next().unwrap_or(""); + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let location = response.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("location") { + Some(value.trim().to_string()) + } else { + None + } + }); + + (status, location) +} + +fn make_multi_sni_config( + port: u16, + sites: &[(&str, &str, &str, &str)], // (hostname, cert_path, key_path, body) +) -> tiny_proxy::Config { + let mut map = std::collections::HashMap::new(); + for (hostname, cert_path, key_path, body) in sites { + let address = format!("{}:{}", hostname, port); + map.insert( + address.clone(), + tiny_proxy::config::SiteConfig { + address, + directives: vec![tiny_proxy::config::Directive::Respond { + status: 200, + body: (*body).to_string(), + }], + tls: Some(tiny_proxy::config::TlsConfig { + cert_path: (*cert_path).to_string(), + key_path: (*key_path).to_string(), + }), + }, + ); + } + tiny_proxy::Config { sites: map } +} + +#[tokio::test] +async fn test_start_all_multi_sni() { + let (cert_a, key_a) = generate_self_signed_cert("alpha.local"); + let (cert_b, key_b) = generate_self_signed_cert("beta.local"); + let cert_file_a = write_temp_pem("cert-a", &cert_a); + let key_file_a = write_temp_pem("key-a", &key_a); + let cert_file_b = write_temp_pem("cert-b", &cert_b); + let key_file_b = write_temp_pem("key-b", &key_b); + + let tls_port = get_random_port_addr().await.port(); + let config = make_multi_sni_config( + tls_port, + &[ + ( + "alpha.local", + cert_file_a.path().to_str().unwrap(), + key_file_a.path().to_str().unwrap(), + "ALPHA", + ), + ( + "beta.local", + cert_file_b.path().to_str().unwrap(), + key_file_b.path().to_str().unwrap(), + "BETA", + ), + ], + ); + + let mut roots = rustls::RootCertStore::empty(); + for pem in [&cert_a, &cert_b] { + let cert_der = rustls_pemfile::certs(&mut std::io::BufReader::new(pem.as_bytes())) + .collect::, _>>() + .unwrap(); + roots.add(cert_der[0].clone()).unwrap(); + } + + let proxy = tiny_proxy::Proxy::new(config); + let proxy_handle = tokio::spawn(async move { proxy.start_all().await }); + + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + let (status_a, body_a) = tls_http_get("alpha.local", tls_port, &roots, "/").await; + assert_eq!(status_a, 200); + assert_eq!(&body_a[..], b"ALPHA"); + + let (status_b, body_b) = tls_http_get("beta.local", tls_port, &roots, "/").await; + assert_eq!(status_b, 200); + assert_eq!(&body_b[..], b"BETA"); + + proxy_handle.abort(); +} + +#[tokio::test] +async fn test_start_all_http_to_https_redirect() { + let (cert_pem, key_pem) = generate_self_signed_cert("redirect.local"); + let cert_file = write_temp_pem("cert", &cert_pem); + let key_file = write_temp_pem("key", &key_pem); + + let tls_port = get_random_port_addr().await.port(); + let redirect_port = tls_redirect_port(tls_port); + + let config = make_tls_config( + &format!("redirect.local:{}", tls_port), + cert_file.path().to_str().unwrap(), + key_file.path().to_str().unwrap(), + ); + + let proxy = tiny_proxy::Proxy::new(config); + let proxy_handle = tokio::spawn(async move { proxy.start_all().await }); + + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + let (status, location) = plain_http_get("redirect.local", redirect_port, "/hello?x=1").await; + assert_eq!(status, 301); + let expected = format!("https://redirect.local:{}/hello?x=1", tls_port); + assert_eq!(location.as_deref(), Some(expected.as_str())); + + proxy_handle.abort(); +}