use http::HeaderMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{LazyLock, RwLock};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub struct ProxyConfig {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default)]
pub rate_limit: RateLimitDefaults,
#[serde(default)]
pub routes: Vec<Route>,
#[serde(default)]
pub upstreams: Vec<Upstream>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerConfig {
#[serde(default = "default_http_host")]
pub http_host: String,
#[serde(default = "default_http_port")]
pub http_port: u16,
#[serde(default)]
pub http2_port: Option<u16>,
#[serde(default = "default_https_host")]
pub https_host: String,
#[serde(default = "default_https_port")]
pub https_port: u16,
#[serde(default)]
pub https_enabled: bool,
#[serde(default = "default_true")]
pub database_config: bool,
pub config_file: Option<String>,
#[serde(default = "default_max_connections")]
pub max_connections: usize,
#[serde(default = "default_header_read_timeout_secs")]
pub header_read_timeout_secs: u64,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_http2_keepalive_interval_secs")]
pub http2_keepalive_interval_secs: u64,
#[serde(default = "default_http2_keepalive_timeout_secs")]
pub http2_keepalive_timeout_secs: u64,
#[serde(default = "default_tunnel_idle_timeout_secs")]
pub tunnel_idle_timeout_secs: u64,
#[serde(default = "default_allowed_upgrade_protocols")]
pub allowed_upgrade_protocols: Vec<String>,
#[serde(default)]
pub trust_forwarded_headers: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
http_host: default_http_host(),
http_port: default_http_port(),
http2_port: None,
https_host: default_https_host(),
https_port: default_https_port(),
https_enabled: false,
database_config: true,
config_file: None,
max_connections: default_max_connections(),
header_read_timeout_secs: default_header_read_timeout_secs(),
idle_timeout_secs: default_idle_timeout_secs(),
http2_keepalive_interval_secs: default_http2_keepalive_interval_secs(),
http2_keepalive_timeout_secs: default_http2_keepalive_timeout_secs(),
tunnel_idle_timeout_secs: default_tunnel_idle_timeout_secs(),
allowed_upgrade_protocols: default_allowed_upgrade_protocols(),
trust_forwarded_headers: false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TlsConfig {
#[serde(default)]
pub acme_enabled: bool,
#[serde(default = "default_acme_directory")]
pub acme_directory: String,
pub acme_email: Option<String>,
#[serde(default = "default_cert_dir")]
pub cert_dir: String,
#[serde(default)]
pub acme_staging: bool,
#[serde(default)]
pub cert_file: Option<String>,
#[serde(default)]
pub key_file: Option<String>,
#[serde(default)]
pub acme_directory_url: Option<String>,
#[serde(default)]
pub acme_root_pem: Option<String>,
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
acme_enabled: false,
acme_directory: default_acme_directory(),
acme_email: None,
cert_dir: default_cert_dir(),
acme_staging: false,
cert_file: None,
key_file: None,
acme_directory_url: None,
acme_root_pem: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RateLimitDefaults {
#[serde(default = "default_rate_limit_requests")]
pub requests: u32,
#[serde(default = "default_rate_limit_window")]
pub window_secs: u32,
#[serde(default = "default_burst")]
pub burst: u32,
#[serde(default = "default_max_tracked_keys")]
pub max_tracked_keys: usize,
}
impl Default for RateLimitDefaults {
fn default() -> Self {
Self {
requests: default_rate_limit_requests(),
window_secs: default_rate_limit_window(),
burst: default_burst(),
max_tracked_keys: default_max_tracked_keys(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Route {
pub id: Option<Uuid>,
pub name: String,
pub description: Option<String>,
#[serde(default, rename = "match")]
pub match_: RouteMatch,
#[serde(default = "default_priority")]
pub priority: i32,
pub upstream: String,
#[serde(default)]
pub strip_path: bool,
#[serde(default)]
pub add_headers: HashMap<String, String>,
#[serde(default)]
pub remove_headers: Vec<String>,
pub rate_limit: Option<RouteRateLimit>,
#[serde(default = "default_timeout")]
pub timeout_secs: u32,
#[serde(default)]
pub retry_count: u32,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RouteMatch {
pub host: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub path_type: PathMatchType,
#[serde(default)]
pub headers: HashMap<String, String>,
pub methods: Option<Vec<String>>,
}
static REGEX_CACHE: LazyLock<RwLock<HashMap<String, Option<Regex>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
fn compiled_regex(pattern: &str) -> Option<Regex> {
if let Ok(cache) = REGEX_CACHE.read() {
if let Some(entry) = cache.get(pattern) {
return entry.clone();
}
}
let compiled = match Regex::new(pattern) {
Ok(re) => Some(re),
Err(error) => {
tracing::warn!(
%pattern, %error,
"route path regex does not compile; the route matches nothing"
);
None
}
};
if let Ok(mut cache) = REGEX_CACHE.write() {
cache.insert(pattern.to_owned(), compiled.clone());
}
compiled
}
impl RouteMatch {
pub fn matches(&self, host: &str, path: &str, method: &str, headers: &HeaderMap) -> bool {
self.host_matches(host)
&& self.path_matches(path)
&& self.method_matches(method)
&& self.headers_match(headers)
}
fn host_matches(&self, host: &str) -> bool {
let Some(pattern) = self.host.as_deref() else {
return true;
};
let pattern = pattern.trim().to_ascii_lowercase();
let host = host.trim().to_ascii_lowercase();
if pattern == "*" || pattern.is_empty() {
return true;
}
if pattern == host {
return true;
}
match pattern.strip_prefix("*.") {
Some(suffix) if !suffix.is_empty() => host
.strip_suffix(&format!(".{suffix}"))
.is_some_and(|label| !label.is_empty() && !label.contains('.')),
_ => false,
}
}
fn path_matches(&self, path: &str) -> bool {
let Some(pattern) = self.path.as_deref() else {
return true;
};
match self.path_type {
PathMatchType::Prefix => path_has_prefix(path, pattern),
PathMatchType::Exact => path == pattern,
PathMatchType::Regex => match compiled_regex(pattern) {
Some(re) => re.is_match(path),
None => false,
},
}
}
fn method_matches(&self, method: &str) -> bool {
match self.methods.as_deref() {
None | Some([]) => true,
Some(allowed) => allowed.iter().any(|m| m.eq_ignore_ascii_case(method)),
}
}
fn headers_match(&self, headers: &HeaderMap) -> bool {
self.headers.iter().all(|(name, expected)| {
headers
.get(name.as_str())
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == expected)
})
}
}
pub fn path_has_prefix(path: &str, prefix: &str) -> bool {
if prefix.is_empty() || prefix.ends_with('/') {
return path.starts_with(prefix);
}
match path.strip_prefix(prefix) {
Some(rest) => rest.is_empty() || rest.starts_with('/'),
None => false,
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PathMatchType {
#[default]
Prefix,
Exact,
Regex,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouteRateLimit {
pub requests: u32,
pub window_secs: u32,
#[serde(default)]
pub key: RateLimitKey,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RateLimitKey {
#[default]
ClientIp,
Header(String),
Route,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Upstream {
pub id: Option<Uuid>,
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub lb_strategy: LoadBalanceStrategy,
#[serde(default)]
pub backends: Vec<Backend>,
#[serde(default)]
pub health_check: HealthCheckConfig,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Upstream {
pub fn resolved_id(&self) -> Uuid {
self.id
.unwrap_or_else(|| Uuid::new_v5(&Uuid::NAMESPACE_OID, self.name.as_bytes()))
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoadBalanceStrategy {
#[default]
RoundRobin,
LeastConnections,
Weighted,
Random,
Sticky,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Backend {
pub id: Option<Uuid>,
pub address: String,
#[serde(default = "default_scheme")]
pub scheme: String,
#[serde(default = "default_weight")]
pub weight: u32,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub http_version: UpstreamHttpVersion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamHttpVersion {
#[default]
#[serde(alias = "http1", alias = "http/1.1")]
Http11,
#[serde(alias = "http2", alias = "h2")]
H2c,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HealthCheckConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_health_path")]
pub path: String,
#[serde(default = "default_health_interval")]
pub interval_secs: u32,
#[serde(default = "default_health_timeout")]
pub timeout_secs: u32,
#[serde(default = "default_healthy_threshold")]
pub healthy_threshold: u32,
#[serde(default = "default_unhealthy_threshold")]
pub unhealthy_threshold: u32,
}
impl Default for HealthCheckConfig {
fn default() -> Self {
Self {
enabled: true,
path: default_health_path(),
interval_secs: default_health_interval(),
timeout_secs: default_health_timeout(),
healthy_threshold: default_healthy_threshold(),
unhealthy_threshold: default_unhealthy_threshold(),
}
}
}
fn default_http_host() -> String {
"0.0.0.0".into()
}
fn default_http_port() -> u16 {
8080
}
fn default_https_host() -> String {
"0.0.0.0".into()
}
fn default_https_port() -> u16 {
8443
}
fn default_true() -> bool {
true
}
fn default_acme_directory() -> String {
"https://acme-v02.api.letsencrypt.org/directory".into()
}
fn default_cert_dir() -> String {
"./certs".into()
}
fn default_rate_limit_requests() -> u32 {
1000
}
fn default_rate_limit_window() -> u32 {
60
}
fn default_burst() -> u32 {
50
}
fn default_max_tracked_keys() -> usize {
100_000
}
fn default_max_connections() -> usize {
10_000
}
fn default_header_read_timeout_secs() -> u64 {
30
}
fn default_idle_timeout_secs() -> u64 {
60
}
fn default_http2_keepalive_interval_secs() -> u64 {
20
}
fn default_http2_keepalive_timeout_secs() -> u64 {
20
}
fn default_tunnel_idle_timeout_secs() -> u64 {
300
}
fn default_allowed_upgrade_protocols() -> Vec<String> {
vec!["websocket".into()]
}
fn default_priority() -> i32 {
100
}
fn default_timeout() -> u32 {
30
}
fn default_scheme() -> String {
"http".into()
}
fn default_weight() -> u32 {
100
}
fn default_health_path() -> String {
"/health".into()
}
fn default_health_interval() -> u32 {
10
}
fn default_health_timeout() -> u32 {
5
}
fn default_healthy_threshold() -> u32 {
2
}
fn default_unhealthy_threshold() -> u32 {
3
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct AcmeConfig {
#[serde(default)]
pub enabled: bool,
pub email: Option<String>,
#[serde(default)]
pub staging: bool,
#[serde(default)]
pub domains: Vec<String>,
}
#[cfg(test)]
mod upstream_version_tests {
use super::*;
fn header_map(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
http::HeaderValue::from_str(value).unwrap(),
);
}
map
}
fn any() -> RouteMatch {
RouteMatch::default()
}
#[test]
fn an_empty_match_matches_anything() {
let m = any();
assert!(m.matches("example.com", "/anything", "GET", &HeaderMap::new()));
assert!(m.matches("", "/", "DELETE", &HeaderMap::new()));
}
#[test]
fn a_prefix_path_matches_by_prefix() {
let m = RouteMatch {
path: Some("/v1".into()),
..any()
};
assert!(m.matches("h", "/v1", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/v2", "GET", &HeaderMap::new()));
}
#[test]
fn a_prefix_matches_on_segment_boundaries_only() {
let m = RouteMatch {
path: Some("/public".into()),
..any()
};
assert!(m.matches("h", "/public", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/public/", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/public/a/b", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/publicity", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/public-admin", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/publi", "GET", &HeaderMap::new()));
}
#[test]
fn a_prefix_with_a_trailing_slash_keeps_its_meaning() {
let m = RouteMatch {
path: Some("/public/".into()),
..any()
};
assert!(m.matches("h", "/public/", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/public/x", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/public", "GET", &HeaderMap::new()));
let root = RouteMatch {
path: Some("/".into()),
..any()
};
assert!(root.matches("h", "/", "GET", &HeaderMap::new()));
assert!(root.matches("h", "/anything/at/all", "GET", &HeaderMap::new()));
}
#[test]
fn the_new_server_settings_have_defaults() {
let server: ServerConfig = toml::from_str("http_port = 8080").unwrap();
assert_eq!(server.max_connections, 10_000);
assert_eq!(server.header_read_timeout_secs, 30);
assert_eq!(server.idle_timeout_secs, 60);
assert_eq!(server.tunnel_idle_timeout_secs, 300);
assert_eq!(server.allowed_upgrade_protocols, vec!["websocket"]);
assert!(!server.trust_forwarded_headers);
let limits: RateLimitDefaults = toml::from_str("requests = 10").unwrap();
assert_eq!(limits.max_tracked_keys, 100_000);
}
#[test]
fn an_exact_path_does_not_match_a_longer_one() {
let m = RouteMatch {
path: Some("/health".into()),
path_type: PathMatchType::Exact,
..any()
};
assert!(m.matches("h", "/health", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/health-internal", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/health/deep", "GET", &HeaderMap::new()));
}
#[test]
fn a_regex_path_matches_the_pattern() {
let m = RouteMatch {
path: Some(r"^/v[0-9]+/users$".into()),
path_type: PathMatchType::Regex,
..any()
};
assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/v22/users", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/v1/users/1", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/users", "GET", &HeaderMap::new()));
}
#[test]
fn a_regex_is_compiled_once_and_reused() {
let pattern = format!("^/cache-probe-{}/[0-9]+$", uuid::Uuid::new_v4());
let m = RouteMatch {
path: Some(pattern.clone()),
path_type: PathMatchType::Regex,
..any()
};
assert!(!REGEX_CACHE.read().unwrap().contains_key(&pattern));
assert!(m.matches("h", "/nope", "GET", &HeaderMap::new()) || true);
assert!(
REGEX_CACHE.read().unwrap().contains_key(&pattern),
"the compiled regex was not cached"
);
let path = pattern
.trim_start_matches('^')
.trim_end_matches("/[0-9]+$")
.to_string();
assert!(m.matches("h", &format!("{path}/42"), "GET", &HeaderMap::new()));
}
#[test]
fn a_broken_regex_is_cached_as_a_failure() {
let pattern = format!("/broken-{}/(unclosed", uuid::Uuid::new_v4());
let m = RouteMatch {
path: Some(pattern.clone()),
path_type: PathMatchType::Regex,
..any()
};
assert!(!m.matches("h", "/anything", "GET", &HeaderMap::new()));
let cache = REGEX_CACHE.read().unwrap();
let entry = cache
.get(&pattern)
.expect("a pattern that does not compile should still be cached");
assert!(
entry.is_none(),
"it should be cached as a failure, not as a compiled regex"
);
}
#[test]
fn a_regex_that_does_not_compile_matches_nothing() {
let m = RouteMatch {
path: Some("/v1/(unclosed".into()),
path_type: PathMatchType::Regex,
..any()
};
assert!(!m.matches("h", "/v1/anything", "GET", &HeaderMap::new()));
assert!(!m.matches("h", "/v1/(unclosed", "GET", &HeaderMap::new()));
}
#[test]
fn methods_are_honoured_case_insensitively() {
let m = RouteMatch {
methods: Some(vec!["GET".into(), "head".into()]),
..any()
};
assert!(m.matches("h", "/", "GET", &HeaderMap::new()));
assert!(m.matches("h", "/", "get", &HeaderMap::new()));
assert!(m.matches("h", "/", "HEAD", &HeaderMap::new()));
assert!(!m.matches("h", "/", "DELETE", &HeaderMap::new()));
assert!(!m.matches("h", "/", "POST", &HeaderMap::new()));
}
#[test]
fn an_empty_method_list_means_any() {
let m = RouteMatch {
methods: Some(vec![]),
..any()
};
assert!(m.matches("h", "/", "PATCH", &HeaderMap::new()));
}
#[test]
fn every_named_header_must_be_present_with_that_value() {
let m = RouteMatch {
headers: HashMap::from([
("x-tenant".to_string(), "acme".to_string()),
("x-env".to_string(), "prod".to_string()),
]),
..any()
};
assert!(m.matches(
"h",
"/",
"GET",
&header_map(&[("x-tenant", "acme"), ("x-env", "prod")])
));
assert!(!m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
assert!(!m.matches(
"h",
"/",
"GET",
&header_map(&[("x-tenant", "other"), ("x-env", "prod")])
));
assert!(!m.matches("h", "/", "GET", &HeaderMap::new()));
}
#[test]
fn header_names_are_case_insensitive() {
let m = RouteMatch {
headers: HashMap::from([("X-Tenant".to_string(), "acme".to_string())]),
..any()
};
assert!(m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
}
#[test]
fn a_host_matches_exactly_or_by_a_single_label_wildcard() {
let exact = RouteMatch {
host: Some("api.example.com".into()),
..any()
};
assert!(exact.matches("api.example.com", "/", "GET", &HeaderMap::new()));
assert!(!exact.matches("www.example.com", "/", "GET", &HeaderMap::new()));
let wild = RouteMatch {
host: Some("*.example.com".into()),
..any()
};
assert!(wild.matches("api.example.com", "/", "GET", &HeaderMap::new()));
assert!(!wild.matches("example.com", "/", "GET", &HeaderMap::new()));
assert!(!wild.matches("a.b.example.com", "/", "GET", &HeaderMap::new()));
assert!(!wild.matches("api.example.com.evil.test", "/", "GET", &HeaderMap::new()));
}
#[test]
fn a_star_host_matches_anything() {
let m = RouteMatch {
host: Some("*".into()),
..any()
};
assert!(m.matches("anything.test", "/", "GET", &HeaderMap::new()));
assert!(m.matches("", "/", "GET", &HeaderMap::new()));
}
#[test]
fn host_matching_is_case_insensitive() {
let m = RouteMatch {
host: Some("API.Example.COM".into()),
..any()
};
assert!(m.matches("api.example.com", "/", "GET", &HeaderMap::new()));
}
#[test]
fn every_set_criterion_has_to_hold() {
let m = RouteMatch {
host: Some("api.example.com".into()),
path: Some("/v1".into()),
path_type: PathMatchType::Prefix,
methods: Some(vec!["POST".into()]),
headers: HashMap::from([("x-key".to_string(), "k".to_string())]),
};
let headers = header_map(&[("x-key", "k")]);
assert!(m.matches("api.example.com", "/v1/x", "POST", &headers));
assert!(!m.matches("other.example.com", "/v1/x", "POST", &headers));
assert!(!m.matches("api.example.com", "/v2/x", "POST", &headers));
assert!(!m.matches("api.example.com", "/v1/x", "GET", &headers));
assert!(!m.matches("api.example.com", "/v1/x", "POST", &HeaderMap::new()));
}
#[test]
fn test_backend_defaults_to_http11() {
let backend: Backend = toml::from_str(r#"address = "127.0.0.1:8080""#).unwrap();
assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
}
#[test]
fn test_backend_h2c_aliases() {
for spelling in ["h2c", "h2", "http2"] {
let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
let backend: Backend = toml::from_str(&toml_src).unwrap();
assert_eq!(
backend.http_version,
UpstreamHttpVersion::H2c,
"spelling {spelling} should select h2c"
);
}
}
#[test]
fn test_backend_http11_aliases() {
for spelling in ["http11", "http1"] {
let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
let backend: Backend = toml::from_str(&toml_src).unwrap();
assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
}
}
#[test]
fn test_http2_port_is_optional_and_off_by_default() {
let server: ServerConfig = toml::from_str("").unwrap();
assert_eq!(server.http2_port, None);
let server: ServerConfig = toml::from_str("http2_port = 19081").unwrap();
assert_eq!(server.http2_port, Some(19081));
}
}