//! Desired configuration and its validated, immutable routing snapshot. use crate::{ acl::{Acl, CompiledAcl}, headers::{CompiledHeaders, HeaderRules}, }; use anyhow::{ensure, Context, Result}; use http::{header::HeaderValue, uri::Authority}; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf, sync::Arc, }; use url::{Host, Url}; pub const MAX_CONFIG_BYTES: usize = 262_144; pub const MAX_BACKENDS: usize = 128; pub const MAX_ROUTES: usize = 256; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] pub administration: crate::settings::Administration, #[serde(default)] pub logging: crate::logging::Settings, #[serde(default)] pub error_log: crate::logging::ErrorLogSettings, #[serde(default)] pub prometheus: crate::prometheus::Settings, #[serde(default)] pub listeners: crate::settings::Listeners, /// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet /// persist an explicit `binding.http2_settings` object. #[serde(default)] pub http2: crate::settings::Http2Connections, #[serde(default)] pub docker_agents: crate::docker::Settings, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub streams: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub redirects: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub not_found_hosts: Vec, #[serde(default)] pub cache: crate::cache::Settings, #[serde(default)] pub transform: crate::transform::Settings, #[serde(default)] pub pages: crate::pages::GlobalPages, #[serde(default)] pub compression: crate::compression::Settings, #[serde(default)] pub server_header: crate::settings::ServerHeader, #[serde(default)] pub diagnostic_headers: crate::settings::DiagnosticHeaders, #[serde(default)] pub forwarding: crate::forwarding::Settings, #[serde(default, skip_serializing_if = "crate::tls::Settings::is_empty")] pub tls: crate::tls::Settings, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub web_services: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub vhosts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub host_policies: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub acl_profiles: Vec, #[serde(default, skip_serializing_if = "HeaderRules::is_empty")] pub headers: HeaderRules, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HostPolicy { pub hosts: Vec, pub acl: Acl, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AclProfile { pub id: String, pub name: String, pub acl: Option, pub country_access: crate::rate_limit::CountryAccess, } impl Default for AclProfile { fn default() -> Self { Self { id: String::new(), name: String::new(), acl: None, country_access: Default::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Backend { #[serde(default)] pub protocol: UpstreamProtocol, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub address_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_ca_pem: Option, /// Optional DNS name used for TLS SNI and certificate hostname verification. /// TCP/DNS connection targeting remains pinned to `url`. #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_server_name: Option, /// Verify the HTTPS backend certificate chain and server identity. /// Disabled by default for reverse-proxy compatibility; enable explicitly when required. #[serde(default)] pub tls_verify: bool, pub id: String, pub url: String, #[serde(default)] pub allow_private: bool, /// Explicitly allow a backend that resolves to a local RustProxy listener. /// This can expose the control plane or create a proxy loop and is off by default. #[serde(default)] pub accept_local_listener_risk: bool, #[serde(default)] pub preserve_host: bool, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum UpstreamProtocol { #[default] Http1, Http2, Auto, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { pub revision: u64, pub config: Config, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Update { pub expected_revision: u64, pub config: Config, } #[derive(Debug, Clone)] pub struct ValidationPolicy { pub tls_addr: Option, pub cert_dir: Option, /// Resolved runtime data directory. Exposed only through authenticated admin status. pub data_dir: Option, pub proxy_addr: SocketAddr, pub admin_addr: SocketAddr, } #[derive(Debug)] pub struct CompiledBackend { pub address_policy: crate::upstream::AddressPolicy, pub client: crate::upstream::ProxyClient, pub websocket_client: crate::upstream::ProxyClient, pub id: String, /// Configured network origin used by raw TCP probes and policy checks. pub origin: String, /// HTTP request origin. For HTTPS this may carry a separate verified TLS name. pub request_origin: String, pub host_header: HeaderValue, pub preserve_host: bool, pub protocol: UpstreamProtocol, /// Whether backend TLS certificate chain/name verification is enabled. /// Retained in the compiled snapshot for failure diagnostics only. pub tls_verify: bool, pub accept_local_listener_risk: bool, /// True only when this backend URL names the configured administration socket /// directly. Used by the guarded control-plane reverse-proxy path. pub targets_admin_listener: bool, } #[derive(Debug, Clone)] pub enum CompiledSource { ReverseProxy, WebServer(Arc), } #[derive(Debug, Clone)] pub struct CompiledRoute { pub backend_id: Option, pub bindings: Vec, pub frontend: Option>, pub pool: Option>, pub cache: Option, pub id: String, pub path_prefix: String, pub match_type: crate::vhost::PathMatch, pub path_regex: Option, pub rewrite: crate::vhost::PathRewrite, pub response: Option, pub source: CompiledSource, pub backend: Option>, pub acl: Option, pub headers: CompiledHeaders, } #[derive(Debug)] pub struct CompiledFrontend { pub websockets: bool, pub websocket_legacy_support: bool, pub websocket_rfc8441: bool, pub websocket_rfc9220: bool, pub force_ssl: bool, pub forward_client_ip: bool, /// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected. pub https_redirect_port: Option, pub http2_enabled: bool, pub http3_enabled: bool, pub tls_policy: crate::tls::ProtocolPolicy, pub tls: Option>, pub tls_http1: Option>, pub cloudflare_client_ip: bool, pub timing_headers: bool, pub backend_header: bool, pub backend_timing_header: bool, pub rate_limit: crate::rate_limit::Settings, pub basic_auth: Option, pub metrics: Arc, pub server_header: crate::settings::ServerHeader, pub diagnostic_headers: crate::settings::DiagnosticHeaders, pub tinyauth: Option, pub id: String, pub bindings: Vec, pub acl_profile_id: Option, pub profile_acl: Option, pub profile_country_access: crate::rate_limit::CountryAccess, pub acl: Option, pub headers: CompiledHeaders, pub security: crate::security::CompiledSecurity, pub cache: crate::cache::Settings, pub transform: crate::transform::Settings, pub pages: crate::pages::VhostPages, pub proxy_timeouts: crate::vhost::ProxyTimeouts, } pub struct Snapshot { pub vhost_metrics: std::collections::BTreeMap>, pub forwarding: crate::forwarding::Compiled, pub quic_tls: Option>, pub listeners: Vec, pub pools: Vec>, pub tls_http1: Option>, frontends: HashMap>>, pub tls: Option>, pub envelope: Envelope, routes: HashMap>, /// Main vhost backend fallback, kept separate from user Custom locations. fallbacks: HashMap>, redirects: HashMap>>, not_found_hosts: HashMap>>, host_acls: HashMap>, pub headers: CompiledHeaders, } impl std::fmt::Debug for Snapshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // QUIC crypto has no Debug implementation; never format private keys. f.debug_struct("Snapshot") .field("revision", &self.envelope.revision) .field("listeners", &self.listeners) .field("quic_tls", &self.quic_tls.is_some()) .finish_non_exhaustive() } } impl Snapshot { fn host_patterns(host: &str) -> Vec { let mut patterns = vec![host.to_owned()]; for (index, _) in host.match_indices('.') { let suffix = &host[index + 1..]; if suffix.contains('.') { patterns.push(format!("*.{suffix}")); } } patterns } pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool { for pattern in Self::host_patterns(host) { if let Some(acl) = self.host_acls.get(&pattern) { return acl.allows(peer); } } true } /// RFC 8441 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is /// advertised only when at least one explicitly opted-in vhost is bound to /// this HTTP/2 listener. Request routing still enforces the per-vhost opt-in. pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool { self.frontends.values().flatten().any(|frontend| { frontend.websockets && frontend.websocket_rfc8441 && frontend.http2_enabled && frontend.bindings.iter().any(|binding| { binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c } }) }) } /// RFC 9220 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is /// advertised only when at least one explicitly opted-in vhost is bound to /// this QUIC listener. Request routing still enforces the per-vhost opt-in. pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool { self.frontends.values().flatten().any(|frontend| { frontend.websockets && frontend.websocket_rfc9220 && frontend.http3_enabled && frontend.bindings.iter().any(|binding| { binding.tls && binding.http3.is_some() && binding.matches(local, true) }) }) } /// Production routing always uses the TCP connection's local endpoint. /// Exact hostnames win over the longest matching wildcard hostname. pub fn select_on( &self, host: &str, path: &str, local: SocketAddr, tls: bool, ) -> Option<&CompiledRoute> { for pattern in Self::host_patterns(host) { let fallback = self.fallbacks.get(&pattern).and_then(|routes| { routes .iter() .find(|route| route.bindings.iter().any(|l| l.matches(local, tls))) }); // A claimed exact/wildcard vhost owns the request as a unit. First try // its Custom locations, then its main backend fallback; never continue // into a broader wildcard merely because no Custom location matched. if let Some(fallback) = fallback { return self .routes .get(&pattern) .and_then(|routes| { select_route(routes, path, |r| { r.bindings.iter().any(|l| l.matches(local, tls)) }) }) .or(Some(fallback)); } } None } pub fn frontend( &self, host: &str, local: SocketAddr, tls: bool, ) -> Option<&Arc> { for pattern in Self::host_patterns(host) { if let Some(frontend) = self.frontends.get(&pattern).and_then(|frontends| { frontends .iter() .find(|f| f.bindings.iter().any(|l| l.matches(local, tls))) }) { return Some(frontend); } } None } /// Select a redirect host/rule using the accepted socket, TLS state and normalized path. /// Exact hostnames win over the longest matching wildcard, matching vhost behavior. pub fn redirect_on( &self, host: &str, path: &str, local: SocketAddr, tls: bool, ) -> Option<( &Arc, &crate::redirect::CompiledRule, )> { for pattern in Self::host_patterns(host) { if let Some((redirect, rule)) = self.redirects.get(&pattern).and_then(|redirects| { redirects.iter().find_map(|redirect| { if !redirect .bindings .iter() .any(|binding| binding.matches(local, tls)) { return None; } redirect .rules .iter() .find(|rule| rule.matches(path, tls)) .map(|rule| (redirect, rule)) }) }) { return Some((redirect, rule)); } } None } /// Select a host-owned local 404/static response using the accepted socket. pub fn not_found_host_on( &self, host: &str, local: SocketAddr, tls: bool, ) -> Option<&Arc> { for pattern in Self::host_patterns(host) { if let Some(item) = self.not_found_hosts.get(&pattern).and_then(|items| { items.iter().find(|item| { item.bindings .iter() .any(|binding| binding.matches(local, tls)) }) }) { return Some(item); } } None } /// Legacy inspection helper only; network handlers must call select_on. pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { for pattern in Self::host_patterns(host) { if let Some(fallback) = self .fallbacks .get(&pattern) .and_then(|routes| routes.first()) { return self .routes .get(&pattern) .and_then(|routes| select_route(routes, path, |_| true)) .or(Some(fallback)); } } None } } fn select_route<'a, F>( routes: &'a [CompiledRoute], path: &str, allowed: F, ) -> Option<&'a CompiledRoute> where F: Fn(&CompiledRoute) -> bool, { if let Some(exact) = routes.iter().find(|route| { allowed(route) && route.match_type == crate::vhost::PathMatch::Exact && route.path_prefix == path }) { return Some(exact); } let prefix = routes .iter() .filter(|route| { allowed(route) && matches!( route.match_type, crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority ) && prefix_matches(&route.path_prefix, path) }) .max_by_key(|route| route.path_prefix.len()); if prefix.is_some_and(|route| route.match_type == crate::vhost::PathMatch::PrefixPriority) { return prefix; } if let Some(regex) = routes.iter().find(|route| { allowed(route) && matches!( route.match_type, crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive ) && route .path_regex .as_ref() .is_some_and(|regex| regex.is_match(path)) }) { return Some(regex); } prefix } impl CompiledRoute { /// Return a normalized replacement path for the upstream hop. `None` means /// preserve the client's exact origin-form path bytes. pub fn rewritten_path(&self, path: &str) -> Result> { use crate::vhost::RewriteMode; let rewritten = match self.rewrite.mode { RewriteMode::Preserve => return Ok(None), RewriteMode::StripPrefix => { let tail = path.strip_prefix(&self.path_prefix).unwrap_or(""); if tail.is_empty() { "/".to_owned() } else { tail.to_owned() } } RewriteMode::ReplacePrefix => { let tail = path.strip_prefix(&self.path_prefix).unwrap_or(""); let base = self.rewrite.value.trim_end_matches('/'); if tail.is_empty() { if base.is_empty() { "/".to_owned() } else { base.to_owned() } } else if base.is_empty() || base == "/" { tail.to_owned() } else { format!("{base}{tail}") } } RewriteMode::Regex => self .path_regex .as_ref() .expect("regex rewrite was validated") .replace(path, self.rewrite.value.as_str()) .into_owned(), }; crate::acl::validate_decoded_routing_path(&rewritten) .context("invalid rewritten location path")?; Ok(Some(rewritten)) } } pub fn prefix_matches(prefix: &str, path: &str) -> bool { prefix == "/" || path == prefix || path .strip_prefix(prefix) .is_some_and(|tail| tail.starts_with('/')) } pub(crate) fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4. pub fn normalize_host(value: &str) -> Result { let host = value .strip_suffix('.') .unwrap_or(value) .to_ascii_lowercase(); ensure!( !host.is_empty() && host.len() <= 253, "hostname length must be 1..253" ); ensure!( host.split('.').all(|label| { !label.is_empty() && label.len() <= 63 && !label.starts_with('-') && !label.ends_with('-') && label .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-') }), "invalid hostname: use an ASCII hostname without a port or wildcard" ); Ok(host) } /// Configured frontend patterns may use a single leading `*.` wildcard. /// Runtime Host values remain exact and are normalized by `normalize_host`. pub fn normalize_host_pattern(value: &str) -> Result { let value = value.trim(); if let Some(base) = value.strip_prefix("*.") { ensure!( !base.contains('*'), "wildcard hostname allows only one leading *." ); let base = normalize_host(base)?; ensure!( base.contains('.') && base.parse::().is_err(), "wildcard hostname requires a DNS suffix such as *.example.com" ); return Ok(format!("*.{base}")); } ensure!( !value.contains('*'), "wildcard hostname allows only one leading *." ); normalize_host(value) } pub fn request_host(value: &str) -> Result { ensure!(!value.contains('@'), "invalid Host"); let authority: Authority = value.parse().context("invalid Host authority")?; if let Some(port) = authority.port() { let number: u16 = port.as_str().parse().context("invalid Host port")?; ensure!(number > 0, "invalid Host port"); } let host = authority .host() .trim_start_matches('[') .trim_end_matches(']'); if let Ok(ip) = host.parse::() { return Ok(ip.to_string()); } normalize_host(host) } fn ipv4_policy(ip: Ipv4Addr, allow_private: bool) -> Result<()> { let [a, b, _, _] = ip.octets(); ensure!( a != 0 && a < 224 && !ip.is_broadcast(), "unspecified/reserved/multicast target blocked" ); ensure!( !(a == 169 && b == 254), "link-local and metadata targets are blocked" ); ensure!( !(a == 100 && (64..=127).contains(&b)), "shared-address/metadata range is blocked" ); ensure!( !(a == 198 && (b == 18 || b == 19)), "benchmark range is blocked" ); ensure!( !ip.is_documentation(), "documentation address is not a backend" ); ensure!( allow_private || (!ip.is_private() && !ip.is_loopback()), "private/loopback backend requires allow_private=true" ); Ok(()) } pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> { match ip { IpAddr::V4(ip) => ipv4_policy(ip, allow_private), IpAddr::V6(ip) => { if let Some(v4) = ip.to_ipv4_mapped() { return ipv4_policy(v4, allow_private); } let s = ip.segments(); ensure!( !ip.is_unspecified() && !ip.is_multicast(), "unspecified/multicast target blocked" ); ensure!( s[0] & 0xffc0 != 0xfe80, "IPv6 link-local targets are blocked" ); ensure!( !(s[0] == 0x2001 && s[1] == 0x0db8), "documentation address is not a backend" ); // Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges. let ula = s[0] & 0xfe00 == 0xfc00; let global = s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0); ensure!( global || ula || ip.is_loopback(), "special IPv6 target blocked" ); ensure!( allow_private || (!ula && !ip.is_loopback()), "private/loopback backend requires allow_private=true" ); Ok(()) } } } pub(crate) fn compile_backend( backend: &Backend, policy: &ValidationPolicy, bindings: &[SocketAddr], keepalive: &crate::balance::Keepalive, ) -> Result { compile_backend_with_timeout( backend, policy, bindings, keepalive, std::time::Duration::from_secs(10), ) } pub(crate) fn compile_backend_with_timeout( backend: &Backend, policy: &ValidationPolicy, bindings: &[SocketAddr], keepalive: &crate::balance::Keepalive, connect_timeout: std::time::Duration, ) -> Result { ensure!(valid_id(&backend.id), "invalid backend id"); ensure!(backend.url.len() <= 512, "backend URL too long"); let url = Url::parse(&backend.url).context("invalid backend URL")?; ensure!( matches!(url.scheme(), "http" | "https"), "backend scheme must be http or https" ); ensure!(backend.protocol == UpstreamProtocol::Http1 || !backend.preserve_host, "HTTP/2 or auto upstream requires preserve_host=false; use http1 for original Host preservation"); ensure!( backend.tls_ca_pem.is_none() || url.scheme() == "https", "backend additional CA can only be used with an https:// URL; remove the CA or change the backend URL to HTTPS" ); ensure!( backend.tls_server_name.is_none() || url.scheme() == "https", "backend TLS server name can only be used with an https:// URL; remove the TLS server name or change the backend URL to HTTPS" ); let tls_server_name = backend .tls_server_name .as_deref() .map(normalize_host) .transpose()?; if let Some(name) = &tls_server_name { ensure!( name.parse::().is_err(), "backend TLS server name must be the DNS name present in the certificate (for example origin.example.internal), not an IP address" ); } ensure!( url.username().is_empty() && url.password().is_none(), "URL credentials are forbidden" ); ensure!( url.path() == "/" && url.query().is_none() && url.fragment().is_none(), "backend URL must be an origin, without a path, query or fragment" ); let address_policy = crate::upstream::AddressPolicy::new(backend)? .with_listeners(policy) .with_bindings(bindings); match url.host().context("backend host missing")? { Host::Ipv4(ip) => address_policy.check(IpAddr::V4(ip))?, Host::Ipv6(ip) => address_policy.check(IpAddr::V6(ip))?, Host::Domain(domain) => { normalize_host(domain)?; } } let port = url .port_or_known_default() .context("backend port missing")?; ensure!(port > 0, "backend port must be nonzero"); let targets_admin_listener = match url.host().context("backend host missing")? { Host::Ipv4(ip) => { crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V4(ip), port), policy.admin_addr) } Host::Ipv6(ip) => { crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr) } // DNS destinations remain subject to the normal self-target guard, but // are never trusted for administration-cookie passthrough. The special // control-plane path must point at the listener explicitly by IP. Host::Domain(_) => false, }; // Exact local listener targets are rejected by AddressPolicy by default, including DNS // answers. An explicit per-backend risk acceptance can override only that self-target // guard; remote origins on any valid backend port, including 9090, remain valid. let authority = match url.host().context("backend host missing")? { Host::Ipv6(ip) => format!("[{ip}]:{port}"), host => format!("{host}:{port}"), }; let request_authority = tls_server_name .as_deref() .map(|name| format!("{name}:{port}")) .unwrap_or_else(|| authority.clone()); let client = crate::upstream::client_with_timeout( backend, address_policy.clone(), false, keepalive, connect_timeout, )?; let websocket_client = if backend.protocol == UpstreamProtocol::Http1 { client.clone() } else { crate::upstream::client_with_timeout( backend, address_policy.clone(), true, keepalive, connect_timeout, )? }; Ok(CompiledBackend { address_policy, id: backend.id.clone(), origin: format!("{}://{authority}", url.scheme()), request_origin: format!("{}://{request_authority}", url.scheme()), host_header: authority.parse()?, preserve_host: backend.preserve_host, protocol: backend.protocol, tls_verify: backend.tls_verify, accept_local_listener_risk: backend.accept_local_listener_risk, targets_admin_listener, client, websocket_client, }) } pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Result { compile_with_defaults( config, revision, policy, (policy.proxy_addr, policy.tls_addr), ) } /// Validate both active sockets and the sockets that will be used after restart. pub fn compile_with_defaults( config: Config, revision: u64, policy: &ValidationPolicy, defaults: (SocketAddr, Option), ) -> Result { compile_reusing(config, revision, policy, defaults, None) } /// Rebuild clients and route policies, but retain compatible health observations, /// full-exchange counters and affinity tokens through unrelated config edits. pub fn compile_reusing( config: Config, revision: u64, policy: &ValidationPolicy, defaults: (SocketAddr, Option), previous: Option<&Snapshot>, ) -> Result { compile_with_active(config, revision, policy, defaults, previous, &[]) } pub fn compile_with_active( mut config: Config, revision: u64, policy: &ValidationPolicy, defaults: (SocketAddr, Option), previous: Option<&Snapshot>, active: &[crate::vhost::Listener], ) -> Result { config.administration.validate(&config.tls)?; config.logging.validate()?; config.error_log.validate()?; let desired_admin = config.administration.resolved_listen(policy.admin_addr); // A persisted non-loopback address is only accepted with administration TLS. // When `listen` is omitted, the startup/CLI address remains authoritative and // its legacy plain-HTTP risk gate is enforced by main before compilation. ensure!( config.administration.listen.is_none() || desired_admin.ip().is_loopback() || config.administration.tls, "a persisted remote administration listener requires administration TLS" ); let mut desired_policy = policy.clone(); desired_policy.admin_addr = desired_admin; config.listeners.validate(&desired_policy)?; config.http2.validate()?; config.docker_agents.validate(&config.listeners)?; let certificate_ids: HashSet<_> = config .tls .certificates .iter() .map(|certificate| certificate.id.as_str()) .collect(); for agent in &config.docker_agents.agents { if let Some(id) = agent.certificate_id.as_deref() { ensure!( certificate_ids.contains(id), "Docker agent {} references unknown certificate id {}", agent.id, id ); } } config.prometheus.validate()?; config.cache.validate()?; config.transform.validate()?; config.pages.validate()?; config.compression.validate()?; config.server_header.validate()?; let forwarding = config.forwarding.compile()?; ensure!( config.web_services.len() <= 128, "too many Web Services (max 128)" ); let mut web_services = HashMap::new(); for service in &mut config.web_services { service .validate() .with_context(|| format!("Web Service {}", service.id))?; let compiled = Arc::new( service .compile() .with_context(|| format!("Web Service {}", service.id))?, ); ensure!( web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id" ); } ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)"); let mut vhost_ids = HashSet::new(); for host in &mut config.vhosts { host.validate() .with_context(|| format!("vhost {}", host.id))?; let validate_source = |source: &crate::vhost::Source| -> Result<()> { if let crate::vhost::Source::WebServer { web_service_id } = source { ensure!( web_services.contains_key(web_service_id), "vhost {} references unknown Web Service {}", host.id, web_service_id ); } Ok(()) }; validate_source(&host.source)?; for route in &host.routes { if route.response.is_none() { validate_source(&route.source)?; } } ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id"); } ensure!( config.redirects.len() <= 64, "too many redirect hosts (max 64)" ); let mut redirect_ids = HashSet::new(); for redirect in &mut config.redirects { redirect .validate() .with_context(|| format!("redirect {}", redirect.id))?; ensure!( redirect_ids.insert(redirect.id.clone()), "duplicate redirect id" ); } ensure!( config.not_found_hosts.len() <= 64, "too many 404 hosts (max 64)" ); let mut not_found_ids = HashSet::new(); for host in &mut config.not_found_hosts { host.validate() .with_context(|| format!("404 host {}", host.id))?; ensure!( not_found_ids.insert(host.id.clone()), "duplicate 404 host id" ); } let effective_tls = crate::tls::for_all_frontends( &config.tls, &config.vhosts, &config.redirects, &config.not_found_hosts, )?; let tls = crate::tls::compile(&effective_tls, policy.cert_dir.as_deref())?; let tls_http1 = tls .as_ref() .map(|base| crate::tls::variant(base, &effective_tls.policy, false)) .transpose()?; ensure!( config .vhosts .iter() .map(|h| h.backends.len()) .sum::() <= MAX_BACKENDS, "too many backends across all pools (max 128)" ); ensure!( config.vhosts.iter().map(|h| h.routes.len()).sum::() <= MAX_ROUTES, "too many paths across all vhosts (max 256)" ); ensure!( config .redirects .iter() .map(|h| h.rules.len()) .sum::() <= MAX_ROUTES, "too many rules across all redirect hosts (max 256)" ); ensure!( serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES, "configuration too large" ); let listeners = crate::vhost::listener_plan(&config, &desired_policy)?; crate::stream::validate( &mut config.streams, &listeners, desired_policy.admin_addr, config .prometheus .enabled .then_some(config.prometheus.listen), )?; if config.prometheus.enabled { ensure!( !crate::vhost::socket_overlap(config.prometheus.listen, desired_policy.admin_addr) && !listeners .iter() .any(|l| crate::vhost::socket_overlap(config.prometheus.listen, l.address)), "metrics: exporter address overlaps a proxy/administration listener" ); } let mut guarded_bindings: Vec<_> = listeners.iter().map(|l| l.address).collect(); if let Some(previous) = previous { guarded_bindings.extend(previous.listeners.iter().map(|l| l.address)); } guarded_bindings.extend(active.iter().map(|l| l.address)); // Startup/default addresses remain protected backend destinations even when // the desired named-listener plan changes before a restart. guarded_bindings.push(defaults.0); guarded_bindings.extend(defaults.1); guarded_bindings.extend([ policy.proxy_addr, policy.admin_addr, desired_policy.admin_addr, ]); guarded_bindings.extend(policy.tls_addr); if config.prometheus.enabled { guarded_bindings.push(config.prometheus.listen); } if let Some(previous) = previous { if previous.envelope.config.prometheus.enabled { guarded_bindings.push(previous.envelope.config.prometheus.listen); } } guarded_bindings.sort(); guarded_bindings.dedup(); let global_headers = config.headers.compile()?; ensure!( config.host_policies.len() <= 256, "too many host ACL policies" ); let mut host_acls = HashMap::new(); for policy in &mut config.host_policies { ensure!( !policy.hosts.is_empty(), "host policy requires at least one hostname" ); let compiled = Arc::new(policy.acl.compile()?); for host in &mut policy.hosts { *host = normalize_host_pattern(host)?; ensure!( host_acls.insert(host.clone(), compiled.clone()).is_none(), "duplicate host ACL policy" ); } } ensure!(config.acl_profiles.len() <= 64, "at most 64 ACL profiles"); let mut acl_profiles: HashMap, crate::rate_limit::CountryAccess)> = HashMap::new(); for profile in &mut config.acl_profiles { ensure!(valid_id(&profile.id), "invalid ACL profile id"); ensure!( !profile.name.trim().is_empty() && profile.name.len() <= 96 && !profile.name.chars().any(char::is_control), "ACL profile name must be 1..96 bytes without controls" ); profile.name = profile.name.trim().to_owned(); profile.country_access.validate()?; let acl = profile.acl.as_ref().map(Acl::compile).transpose()?; ensure!( acl_profiles .insert(profile.id.clone(), (acl, profile.country_access.clone())) .is_none(), "duplicate ACL profile id" ); } for agent in &config.docker_agents.agents { if let Some(profile_id) = agent.acl_profile_id.as_deref() { ensure!( acl_profiles.contains_key(profile_id), "Docker agent {} references unknown default ACL profile {}", agent.id, profile_id ); } for assignment in &agent.assignments { if let Some(profile_id) = assignment.acl_profile_id.as_deref() { ensure!( acl_profiles.contains_key(profile_id), "Docker assignment {} on agent {} references unknown ACL profile {}", assignment.service_key, agent.id, profile_id ); } } } let mut routes: HashMap> = HashMap::new(); let mut fallbacks: HashMap> = HashMap::new(); let mut frontends: HashMap>> = HashMap::new(); let mut redirects: HashMap>> = HashMap::new(); let mut not_found_hosts: HashMap>> = HashMap::new(); let mut claimed_hosts: HashMap> = HashMap::new(); for redirect in &config.redirects { let bindings = redirect.frontend.resolved(&config.listeners)?; // Disabled redirect hosts reserve their names as vhosts do, preventing // an overlapping fallback from unexpectedly taking traffic. for name in &redirect.frontend.hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { for b in &bindings { ensure!( !crate::vhost::socket_overlap(a.address, b.address), "overlapping frontend bindings for hostname {}", name ); } } claimed.extend(bindings.iter().copied()); } if redirect.enabled { let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?); for name in &redirect.frontend.hosts { redirects .entry(name.clone()) .or_default() .push(compiled.clone()); } } } for host in &config.not_found_hosts { let bindings = host.frontend.resolved(&config.listeners)?; // Disabled local-response hosts reserve names too, avoiding accidental // exposure through a broader wildcard vhost. for name in &host.frontend.hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { for b in &bindings { ensure!( !crate::vhost::socket_overlap(a.address, b.address), "overlapping frontend bindings for hostname {}", name ); } } claimed.extend(bindings.iter().copied()); } if host.enabled { let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?); for name in &host.frontend.hosts { not_found_hosts .entry(name.clone()) .or_default() .push(compiled.clone()); } } } let mut pools = Vec::new(); let mut vhost_metrics = std::collections::BTreeMap::new(); for host in &mut config.vhosts { let host_bindings = host.frontend.resolved(&config.listeners)?; let tls_ports: HashSet = host_bindings .iter() .filter(|binding| binding.tls) .map(|binding| binding.address.port()) .collect(); if host.websocket_rfc8441 { ensure!( host_bindings.iter().any(|binding| { (binding.tls && binding.http2) || (!binding.tls && binding.h2c) }), "vhost {} RFC 8441 requires at least one selected HTTP/2-capable listener", host.id ); } if host.websocket_rfc9220 { ensure!( host_bindings .iter() .any(|binding| binding.tls && binding.http3.is_some()), "vhost {} RFC 9220 requires at least one selected HTTP/3 listener", host.id ); } let https_redirect_port = if host.force_ssl { ensure!( host_bindings.iter().any(|binding| !binding.tls), "vhost {} Force SSL requires at least one selected plain HTTP listener", host.id ); ensure!( !tls_ports.is_empty(), "vhost {} Force SSL requires at least one selected TLS listener", host.id ); if tls_ports.contains(&443) { Some(443) } else { ensure!( tls_ports.len() == 1, "vhost {} Force SSL cannot choose an HTTPS target: select one TLS port or include port 443", host.id ); tls_ports.iter().copied().next() } } else { None }; let cookie = host .load_balancing .sticky .cookie_name .as_deref() .unwrap_or(""); ensure!( !(cookie.starts_with("__Host-") || cookie.starts_with("__Secure-")) || host_bindings.iter().all(|l| l.tls), "prefixed affinity cookies require TLS on every resolved frontend" ); // Disabled vhosts also reserve their name, preventing fallback exposure. for name in &host.frontend.hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { for b in &host_bindings { ensure!( !crate::vhost::socket_overlap(a.address, b.address), "overlapping frontend bindings for hostname {}", name ); } } claimed.extend(host_bindings.iter().copied()); } let mut compiled_members = Vec::new(); for definition in &host.backends { let mut backend = compile_backend_with_timeout( &definition.backend(), &desired_policy, &guarded_bindings, &host.load_balancing.keepalive, host.proxy_timeouts .connect_timeout() .unwrap_or(std::time::Duration::from_secs(10)), ) .with_context(|| format!("vhost {} backend {}", host.id, definition.id))?; backend.id = format!("{}/{}", host.id, definition.id); compiled_members.push(Arc::new(backend)); } let pool = if compiled_members.is_empty() { None } else { let mut pool = crate::balance::Pool::new( host.id.clone(), host.load_balancing.clone(), &host.backends, compiled_members, )?; if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) { pool.reuse_observations(old); } Some(Arc::new(pool)) }; let metrics = previous .and_then(|s| s.vhost_metrics.get(&host.id)) .cloned() .unwrap_or_else(|| Arc::new(crate::metrics::VhostMetrics::default())); vhost_metrics.insert(host.id.clone(), metrics.clone()); let tls_policy = if let Some(policy) = &host.tls_policy { policy.validate_narrower_than(&effective_tls.policy)?; policy.clone() } else { effective_tls.policy.clone() }; if host.http3_enabled && host_bindings.iter().any(|binding| binding.http3.is_some()) && tls_policy != effective_tls.policy { anyhow::bail!( "vhost {} uses HTTP/3, so its TLS policy must match the global TLS policy; disable HTTP/3 to use a per-vhost TLS override", host.id ); } let frontend_tls = tls .as_ref() .map(|base| crate::tls::variant(base, &tls_policy, true)) .transpose()?; let frontend_tls_http1 = tls .as_ref() .map(|base| crate::tls::variant(base, &tls_policy, false)) .transpose()?; let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() { Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| { anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id) })?, None => (None, crate::rate_limit::CountryAccess::default()), }; let frontend = Arc::new(CompiledFrontend { metrics, websockets: host.websockets, websocket_legacy_support: host.websocket_legacy_support, websocket_rfc8441: host.websocket_rfc8441, websocket_rfc9220: host.websocket_rfc9220, force_ssl: host.force_ssl, forward_client_ip: host.forward_client_ip, https_redirect_port, http2_enabled: host.http2_enabled, http3_enabled: host.http3_enabled, tls_policy, tls: frontend_tls, tls_http1: frontend_tls_http1, cloudflare_client_ip: host.cloudflare_client_ip, timing_headers: host.timing_headers, backend_header: host.backend_header, backend_timing_header: host.backend_timing_header, rate_limit: host.rate_limit.clone(), basic_auth: host.basic_auth.compile()?, server_header: host .server_header .clone() .unwrap_or_else(|| config.server_header.clone()), diagnostic_headers: host .diagnostic_headers .clone() .unwrap_or_else(|| config.diagnostic_headers.clone()), tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?, id: host.id.clone(), bindings: host_bindings, acl_profile_id: host.acl_profile_id.clone(), profile_acl, profile_country_access, acl: host.acl.as_ref().map(Acl::compile).transpose()?, headers: host.headers.compile()?, security: host.security_headers.compile()?, cache: host.cache.resolve(&config.cache)?, transform: host.transform.resolve(&config.transform)?, pages: host.pages.clone(), proxy_timeouts: host.proxy_timeouts.clone(), }); if host.enabled { let (fallback_source, fallback_pool, fallback_backend) = match &host.source { crate::vhost::Source::ReverseProxy => { let pool = pool.as_ref().ok_or_else(|| { anyhow::anyhow!( "vhost {} Reverse Proxy source has no backend pool", host.id ) })?; ( CompiledSource::ReverseProxy, Some(pool.clone()), Some(pool.members[0].backend.clone()), ) } crate::vhost::Source::WebServer { web_service_id } => ( CompiledSource::WebServer(web_services[web_service_id].clone()), None, None, ), }; let fallback = CompiledRoute { backend_id: None, bindings: frontend.bindings.clone(), frontend: Some(frontend.clone()), pool: fallback_pool, cache: None, id: format!("{}/main", host.id), path_prefix: "/".into(), match_type: crate::vhost::PathMatch::Prefix, path_regex: None, rewrite: crate::vhost::PathRewrite::default(), response: None, source: fallback_source, backend: fallback_backend, acl: None, headers: crate::headers::CompiledHeaders::default(), }; for name in &host.frontend.hosts { fallbacks .entry(name.clone()) .or_default() .push(fallback.clone()); } } let mut path_ids = HashSet::new(); let mut path_bindings = HashSet::new(); for route in &mut host.routes { ensure!( valid_id(&route.id) && path_ids.insert(route.id.clone()), "invalid/duplicate vhost path id" ); let path_regex = match route.match_type { crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => { ensure!( !route.path_prefix.is_empty() && route.path_prefix.len() <= 1024 && !route.path_prefix.chars().any(char::is_control), "invalid vhost location regex" ); let mut builder = regex::RegexBuilder::new(&route.path_prefix); builder .case_insensitive( route.match_type == crate::vhost::PathMatch::RegexInsensitive, ) .size_limit(2 * 1024 * 1024) .dfa_size_limit(2 * 1024 * 1024); Some(builder.build().context("invalid vhost location regex")?) } crate::vhost::PathMatch::Exact => { ensure!( !route.path_prefix.contains('?') && !route.path_prefix.contains('#') && !route.path_prefix.bytes().any(|b| b <= 32 || b == 127), "invalid exact location path" ); route.path_prefix = crate::acl::routing_path(&route.path_prefix) .context("invalid exact location path")?; None } crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => { normalize_prefix(&mut route.path_prefix)?; None } }; ensure!( path_bindings.insert((route.match_type, route.path_prefix.clone())), "duplicate vhost location matcher" ); validate_route_rewrite(route, path_regex.as_ref())?; validate_location_response(route.response.as_ref())?; let acl = route.acl.as_ref().map(Acl::compile).transpose()?; let headers = route.headers.compile()?; if route.source.is_reverse_proxy() { if let Some(id) = &route.backend_id { ensure!( host.backends.iter().any(|b| &b.id == id), "vhost path references unknown backend {}", id ); } } if host.enabled && route.enabled { let (source, route_pool, backend) = if route.response.is_some() { // Local return is handled before source dispatch and needs no origin. (CompiledSource::ReverseProxy, None, None) } else { match &route.source { crate::vhost::Source::ReverseProxy => { let pool = pool.as_ref().ok_or_else(|| { anyhow::anyhow!( "vhost {} location {} Reverse Proxy source has no backend pool", host.id, route.id ) })?; let backend = if let Some(id) = route.backend_id.as_deref() { pool.members .iter() .find(|member| member.definition.id == id) .map(|member| member.backend.clone()) .ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))? } else { pool.members[0].backend.clone() }; ( CompiledSource::ReverseProxy, Some(pool.clone()), Some(backend), ) } crate::vhost::Source::WebServer { web_service_id } => ( CompiledSource::WebServer(web_services[web_service_id].clone()), None, None, ), } }; let compiled = CompiledRoute { backend_id: route.backend_id.clone(), bindings: frontend.bindings.clone(), frontend: Some(frontend.clone()), pool: route_pool, cache: route.cache, id: format!("{}/{}", host.id, route.id), path_prefix: route.path_prefix.clone(), match_type: route.match_type, path_regex, rewrite: route.rewrite.clone(), response: route.response.clone(), source, backend, acl, headers, }; for name in &host.frontend.hosts { routes .entry(name.clone()) .or_default() .push(compiled.clone()); } } } if host.enabled { for name in &host.frontend.hosts { frontends .entry(name.clone()) .or_default() .push(frontend.clone()); } if let Some(pool) = pool { pools.push(pool); } } } let quic_tls = tls .as_ref() .map(|tls| crate::http3::crypto(tls, &effective_tls.policy)) .transpose()?; ensure!( !listeners.iter().any(|l| l.http3.is_some()) || quic_tls.is_some(), "HTTP/3 requires available TLS certificates" ); ensure!( !listeners.iter().any(|l| l.http3.is_some()) || effective_tls.policy.allows_http3(), "HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy" ); Ok(Snapshot { vhost_metrics, forwarding, quic_tls, listeners, pools, tls, tls_http1, frontends, envelope: Envelope { revision, config }, routes, fallbacks, redirects, not_found_hosts, host_acls, headers: global_headers, }) } fn normalize_prefix(prefix: &mut String) -> Result<()> { ensure!( prefix.starts_with('/') && prefix.len() <= 512 && !prefix.contains('?') && !prefix.contains('#') && !prefix.bytes().any(|b| b <= 32 || b == 127), "invalid path prefix" ); if prefix.len() > 1 { *prefix = prefix.trim_end_matches('/').to_owned(); ensure!(!prefix.is_empty(), "invalid path prefix"); } *prefix = crate::acl::routing_path(prefix)?; Ok(()) } fn validate_route_rewrite( route: &crate::vhost::PathRoute, regex: Option<®ex::Regex>, ) -> Result<()> { use crate::vhost::{PathMatch, RewriteMode}; ensure!( route.rewrite.value.len() <= 2048, "location rewrite value is too long" ); ensure!( !route.rewrite.value.chars().any(char::is_control), "location rewrite contains a control character" ); match route.rewrite.mode { RewriteMode::Preserve => ensure!( route.rewrite.value.is_empty(), "preserve rewrite has no value" ), RewriteMode::StripPrefix => ensure!( matches!( route.match_type, PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact ), "strip-prefix rewrite requires a prefix/exact location" ), RewriteMode::ReplacePrefix => { ensure!( matches!( route.match_type, PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact ), "replace-prefix rewrite requires a prefix/exact location" ); let mut replacement = route.rewrite.value.clone(); normalize_prefix(&mut replacement).context("invalid location replacement prefix")?; } RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"), } Ok(()) } fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> { let Some(response) = response else { return Ok(()); }; ensure!( (200..=599).contains(&response.status), "location response status must be 200..599" ); ensure!( response.body.len() <= 65_536, "location response body is too large" ); ensure!( !response.content_type.is_empty() && response.content_type.len() <= 256 && http::HeaderValue::from_str(&response.content_type).is_ok(), "invalid location response content type" ); Ok(()) } pub fn demo_config(addr: SocketAddr) -> Config { let mut host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({ "id":"demo", "frontend":{"hosts":["localhost","127.0.0.1","demo.localhost"], "listener_ids":["http"]}, "backends":[{"id":"demo", "url":format!("http://{addr}"), "allow_private":true}], "load_balancing":{"health_check":{"mode":"off"}}, "routes":[{"id":"root"}] })) .expect("static vhost demo"); host.load_balancing.health_check.mode = crate::balance::HealthMode::Off; Config { listeners: crate::settings::Listeners::from_startup( "127.0.0.1:8080".parse().expect("static demo listener"), None, ), vhosts: vec![host], ..Config::default() } } #[cfg(test)] mod tests { use super::*; fn policy() -> ValidationPolicy { ValidationPolicy { tls_addr: None, cert_dir: None, data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), } } #[test] fn missing_http2_settings_use_release_defaults() { let config: Config = serde_json::from_value(serde_json::json!({})).unwrap(); assert_eq!(config.http2, crate::settings::Http2Connections::default()); config.http2.validate().unwrap(); } #[test] fn normalized_unicode_prefix_survives_serialization_and_recompile() { let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into(); let first = compile(config, 0, &policy()).unwrap(); let saved = serde_json::to_string(&first.envelope.config).unwrap(); let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap(); assert!(second.select("localhost", "/caf\u{e9}/hello").is_some()); } #[test] fn web_server_main_source_needs_no_backend_pool() { let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); config.web_services.push(crate::web_server::Service { id: "site".into(), name: "Site".into(), document_root: tmp.path().to_path_buf(), index: vec!["index.html".into()], directory_listing: false, ..Default::default() }); config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "site".into(), }; config.vhosts[0].backends.clear(); config.vhosts[0].routes.clear(); let snapshot = compile(config, 1, &policy()).unwrap(); let route = snapshot.select("localhost", "/").unwrap(); assert!(matches!(&route.source, CompiledSource::WebServer(_))); assert!(route.pool.is_none()); assert!(route.backend.is_none()); assert!(snapshot.pools.is_empty()); } #[test] fn web_server_and_reverse_proxy_can_share_one_vhost() { let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); config.web_services.push(crate::web_server::Service { id: "site".into(), name: "Site".into(), document_root: tmp.path().to_path_buf(), index: vec!["index.html".into()], directory_listing: false, ..Default::default() }); let host = &mut config.vhosts[0]; host.source = crate::vhost::Source::WebServer { web_service_id: "site".into(), }; host.routes = vec![crate::vhost::PathRoute { id: "api".into(), path_prefix: "/api".into(), source: crate::vhost::Source::ReverseProxy, ..Default::default() }]; let snapshot = compile(config, 1, &policy()).unwrap(); let root = snapshot.select("localhost", "/").unwrap(); let api = snapshot.select("localhost", "/api/test").unwrap(); assert!(matches!(&root.source, CompiledSource::WebServer(_))); assert!(matches!(&api.source, CompiledSource::ReverseProxy)); assert!(api.pool.is_some()); assert!(api.backend.is_some()); } #[test] fn one_web_service_can_be_reused_by_multiple_vhosts() { let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); config.web_services.push(crate::web_server::Service { id: "shared-site".into(), name: "Shared site".into(), document_root: tmp.path().to_path_buf(), index: vec!["index.html".into()], directory_listing: false, ..Default::default() }); let first = &mut config.vhosts[0]; first.source = crate::vhost::Source::WebServer { web_service_id: "shared-site".into(), }; first.backends.clear(); first.routes.clear(); let mut second = first.clone(); second.id = "shared-second".into(); second.frontend.hosts = vec!["second.localhost".into()]; config.vhosts.push(second); let snapshot = compile(config, 1, &policy()).unwrap(); let first_route = snapshot.select("localhost", "/").unwrap(); let second_route = snapshot.select("second.localhost", "/").unwrap(); assert!( matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") ); assert!( matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") ); } #[test] fn unknown_web_service_reference_is_rejected() { let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "missing".into(), }; let error = compile(config, 1, &policy()).unwrap_err().to_string(); assert!(error.contains("unknown Web Service"), "{error}"); } #[test] fn missing_source_fields_remain_reverse_proxy_for_old_configs() { let source: crate::vhost::Source = serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); assert_eq!(source, crate::vhost::Source::ReverseProxy); let route: crate::vhost::PathRoute = serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); assert_eq!(route.source, crate::vhost::Source::ReverseProxy); } #[test] fn host_normalization() { assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com"); for invalid in [ "", "a..b", "*.test", "a/b", "user@host", "-bad.test", "a:99999", ] { assert!(request_host(invalid).is_err(), "{invalid}"); } } #[test] fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() { assert_eq!( normalize_host_pattern("*.Example.TEST.").unwrap(), "*.example.test" ); for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] { assert!(normalize_host_pattern(invalid).is_err(), "{invalid}"); } let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].id = "wildcard".into(); c.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; let mut exact = c.vhosts[0].clone(); exact.id = "exact".into(); exact.frontend.hosts = vec!["api.example.test".into()]; c.vhosts.push(exact); let snapshot = compile(c, 1, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); assert_eq!( snapshot .select_on("api.example.test", "/", local, false) .unwrap() .id, "exact/main" ); assert_eq!( snapshot .select_on("www.example.test", "/", local, false) .unwrap() .id, "wildcard/main" ); } #[test] fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.acl_profiles = serde_json::from_value(serde_json::json!([{ "id":"office", "name":"Office", "acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}, "country_access":{"mode":"block","countries":["RU"]} }])) .unwrap(); c.vhosts[0].acl_profile_id = Some("office".into()); let snapshot = compile(c.clone(), 1, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let frontend = snapshot.frontend("localhost", local, false).unwrap(); assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); assert!(frontend .profile_acl .as_ref() .unwrap() .allows("10.2.3.4".parse().unwrap())); assert!(!frontend .profile_acl .as_ref() .unwrap() .allows("192.0.2.1".parse().unwrap())); assert!(!frontend.profile_country_access.allows(Some("RU"))); c.vhosts[0].acl_profile_id = Some("missing".into()); assert!(compile(c, 2, &policy()).is_err()); } #[test] fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].websockets = true; c.vhosts[0].websocket_rfc8441 = true; let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); assert!(error.contains("HTTP/2-capable listener"), "{error}"); c.listeners.endpoints[0].binding.h2c = true; let snapshot = compile(c.clone(), 2, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); assert!(snapshot.websocket_rfc8441_on(local, false)); assert!(!snapshot.websocket_rfc8441_on(local, true)); c.vhosts[0].websocket_rfc8441 = false; let snapshot = compile(c, 3, &policy()).unwrap(); assert!(!snapshot.websocket_rfc8441_on(local, false)); } #[test] fn force_ssl_requires_plain_http_and_chooses_https_target() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].force_ssl = true; let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); assert!( error.contains("at least one selected TLS listener"), "{error}" ); c.listeners.endpoints.push(crate::settings::NamedListener { id: "https-a".into(), name: "HTTPS A".into(), scope: crate::settings::ListenerScope::Internal, enabled: true, binding: crate::vhost::Listener { address: "127.0.0.1:8443".parse().unwrap(), tls: true, http2: true, http2_settings: Some(crate::settings::Http2Connections::default()), h2c: false, http3: None, }, additional_addresses: vec![], }); c.listeners.endpoints.push(crate::settings::NamedListener { id: "https-b".into(), name: "HTTPS B".into(), scope: crate::settings::ListenerScope::Internal, enabled: true, binding: crate::vhost::Listener { address: "127.0.0.1:9443".parse().unwrap(), tls: true, http2: true, http2_settings: Some(crate::settings::Http2Connections::default()), h2c: false, http3: None, }, additional_addresses: vec![], }); c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()]; let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string(); assert!(error.contains("cannot choose an HTTPS target"), "{error}"); c.listeners .endpoints .iter_mut() .find(|item| item.id == "https-b") .unwrap() .binding .address = "127.0.0.1:443".parse().unwrap(); let snapshot = compile(c.clone(), 3, &policy()).unwrap(); let frontend = snapshot .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) .unwrap(); assert_eq!(frontend.https_redirect_port, Some(443)); c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()]; let error = compile(c, 4, &policy()).unwrap_err().to_string(); assert!(error.contains("plain HTTP listener"), "{error}"); } #[test] fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); c.vhosts[0].backends[0].allow_private = true; c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into()); let s = compile(c, 1, &policy()).unwrap(); let backend = &s.pools[0].members[0].backend; assert_eq!(backend.origin, "https://10.87.20.2:443"); assert_eq!( backend.request_origin, "https://backend.unitra.internal:443" ); } #[test] fn tls_server_name_is_https_dns_only() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into()); assert!(compile(c.clone(), 1, &policy()).is_err()); c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into()); assert!(compile(c, 1, &policy()).is_err()); } #[test] fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); assert!(!c.vhosts[0].backends[0].tls_verify); assert!(compile(c.clone(), 1, &policy()).is_ok()); c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); c.vhosts[0].backends[0].allow_private = true; c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into()); assert!(compile(c.clone(), 1, &policy()).is_ok()); c.vhosts[0].backends[0].tls_verify = true; assert!(compile(c, 1, &policy()).is_err()); } #[test] fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.redirects = serde_json::from_value(serde_json::json!([{ "id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]}, "rules":[{"id":"root","target":"https://new.localhost/","status_code":308}] }])).unwrap(); let snapshot = compile(c, 1, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let (_host, rule) = snapshot .redirect_on("old.localhost", "/a", local, false) .unwrap(); assert_eq!(rule.id, "root"); assert!(snapshot .select_on("old.localhost", "/a", local, false) .is_none()); } #[test] fn custom_locations_override_but_main_vhost_remains_fallback() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); // The demo document still contains the pre-0.16 synthetic root route. // Validation must normalize it away and keep the main backend internally. let mut custom = c.vhosts[0].routes[0].clone(); custom.id = "test".into(); custom.path_prefix = "/test".into(); custom.response = Some(crate::vhost::LocationResponse { status: 200, content_type: "text/plain".into(), body: "ok".into(), }); c.vhosts[0].routes.push(custom); let snapshot = compile(c, 1, &policy()).unwrap(); assert_eq!( snapshot.select("localhost", "/test").unwrap().id, "demo/test" ); assert_eq!( snapshot.select("localhost", "/test/a").unwrap().id, "demo/test" ); assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main"); assert_eq!( snapshot.select("localhost", "/abc").unwrap().id, "demo/main" ); assert_eq!( snapshot.select("localhost", "/static/a.js").unwrap().id, "demo/main" ); assert!(snapshot.envelope.config.vhosts[0] .routes .iter() .all(|route| !route.is_legacy_main_fallback())); } #[test] fn deliberate_root_custom_location_can_override_main_fallback() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let root = &mut c.vhosts[0].routes[0]; root.response = Some(crate::vhost::LocationResponse { status: 204, content_type: "text/plain".into(), body: String::new(), }); let snapshot = compile(c, 1, &policy()).unwrap(); assert_eq!( snapshot.select("localhost", "/anything").unwrap().id, "demo/root" ); } #[test] fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.administration.listen = Some("0.0.0.0:9090".parse().unwrap()); c.administration.tls = true; c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into(); c.vhosts[0].backends[0].allow_private = true; c.vhosts[0].backends[0].accept_local_listener_risk = true; let snapshot = compile(c, 1, &policy()).unwrap(); assert!(snapshot.pools[0].members[0].backend.targets_admin_listener); } #[test] fn backend_diagnostic_flags_compile_to_frontend() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].backend_header = true; c.vhosts[0].backend_timing_header = true; let snapshot = compile(c, 1, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let frontend = snapshot.frontend("localhost", local, false).unwrap(); assert!(frontend.backend_header); assert!(frontend.backend_timing_header); } #[test] fn path_boundaries() { assert!(prefix_matches("/api", "/api")); assert!(prefix_matches("/api", "/api/users")); assert!(!prefix_matches("/api", "/apix")); assert!(prefix_matches("/", "/anything")); } #[test] fn private_requires_opt_in_and_metadata_always_denied() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); assert!(compile(c.clone(), 1, &policy()).is_ok()); c.vhosts[0].backends[0].allow_private = false; assert!(compile(c.clone(), 1, &policy()).is_err()); c.vhosts[0].backends[0].allow_private = true; for target in [ "http://169.254.169.254", "http://100.100.100.200", "http://0.0.0.0", "http://127.0.0.1:9090", "http://[::ffff:169.254.169.254]", "http://[fe80::1]", "http://user:pass@127.0.0.1:9000", ] { c.vhosts[0].backends[0].url = target.into(); assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}"); } c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into(); c.vhosts[0].backends[0].accept_local_listener_risk = true; assert!(compile(c.clone(), 1, &policy()).is_ok()); c.vhosts[0].backends[0].allow_private = false; assert!(compile(c, 1, &policy()).is_err()); } #[test] fn longest_prefix_and_aliases() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut specific = c.vhosts[0].routes[0].clone(); specific.id = "api".into(); specific.path_prefix = "/api".into(); c.vhosts[0].routes.push(specific); let s = compile(c, 7, &policy()).unwrap(); assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api"); assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main"); assert!(s.select("unknown", "/").is_none()); assert_eq!(s.envelope.revision, 7); } #[test] fn nginx_style_location_precedence_exact_priority_regex_prefix() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].routes[0].id = "root".into(); let mut prefix = c.vhosts[0].routes[0].clone(); prefix.id = "api-prefix".into(); prefix.path_prefix = "/api".into(); let mut regex = c.vhosts[0].routes[0].clone(); regex.id = "api-regex".into(); regex.match_type = crate::vhost::PathMatch::Regex; regex.path_prefix = r"^/api/v[0-9]+/".into(); let mut exact = c.vhosts[0].routes[0].clone(); exact.id = "api-exact".into(); exact.match_type = crate::vhost::PathMatch::Exact; exact.path_prefix = "/api/v1/fixed".into(); let mut trailing_exact = c.vhosts[0].routes[0].clone(); trailing_exact.id = "trailing-exact".into(); trailing_exact.match_type = crate::vhost::PathMatch::Exact; trailing_exact.path_prefix = "/trailing/".into(); c.vhosts[0] .routes .extend([prefix.clone(), regex, exact, trailing_exact]); let snapshot = compile(c.clone(), 1, &policy()).unwrap(); assert_eq!( snapshot.select("localhost", "/api/v1/fixed").unwrap().id, "demo/api-exact" ); assert_eq!( snapshot.select("localhost", "/api/v2/users").unwrap().id, "demo/api-regex" ); assert_eq!( snapshot.select("localhost", "/api/plain").unwrap().id, "demo/api-prefix" ); assert_eq!( snapshot.select("localhost", "/trailing/").unwrap().id, "demo/trailing-exact" ); assert_eq!( snapshot.select("localhost", "/trailing").unwrap().id, "demo/main" ); c.vhosts[0].routes.retain(|route| route.id != "api-prefix"); prefix.match_type = crate::vhost::PathMatch::PrefixPriority; prefix.id = "api-priority".into(); c.vhosts[0].routes.push(prefix); let snapshot = compile(c, 2, &policy()).unwrap(); assert_eq!( snapshot.select("localhost", "/api/v2/users").unwrap().id, "demo/api-priority" ); } #[test] fn location_rewrites_are_normalized_and_regex_captures_expand() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let route = &mut c.vhosts[0].routes[0]; route.match_type = crate::vhost::PathMatch::Regex; route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into(); route.rewrite = crate::vhost::PathRewrite { mode: crate::vhost::RewriteMode::Regex, value: "/service/${1}${2}".into(), }; let snapshot = compile(c, 1, &policy()).unwrap(); let selected = snapshot.select("localhost", "/api/v42/items").unwrap(); assert_eq!( selected .rewritten_path("/api/v42/items") .unwrap() .as_deref(), Some("/service/42/items") ); assert_eq!( selected .rewritten_path("/api/v42/Ubuntu 14.04 LTS") .unwrap() .as_deref(), Some("/service/42/Ubuntu 14.04 LTS") ); } #[test] fn custom_location_response_is_validated() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse { status: 200, content_type: "application/json".into(), body: r#"{"ok":true}"#.into(), }); assert!(compile(c.clone(), 1, &policy()).is_ok()); c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199; assert!(compile(c.clone(), 1, &policy()).is_err()); c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200; c.vhosts[0].routes[0] .response .as_mut() .unwrap() .content_type = "text/plain\r\nx-bad: 1".into(); assert!(compile(c, 1, &policy()).is_err()); } #[test] fn local_response_hosts_reserve_frontends_and_do_not_require_backends() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.not_found_hosts = serde_json::from_value(serde_json::json!([{ "id":"missing", "name":"Missing site", "frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]}, "page":{"kind":"html","status":404,"html":"

Not found

"} }])) .unwrap(); let snapshot = compile(c.clone(), 1, &policy()).unwrap(); let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let host = snapshot .not_found_host_on("missing.localhost", local, false) .unwrap(); assert_eq!(host.id, "missing"); assert_eq!(host.page.status, Some(404)); c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()]; assert!(compile(c, 2, &policy()).is_err()); } #[test] fn conflicts_are_rejected() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut duplicate = c.vhosts[0].routes[0].clone(); duplicate.id = "other".into(); c.vhosts[0].routes.push(duplicate); assert!(compile(c, 0, &policy()).is_err()); } #[test] fn tls12_only_vhost_override_is_valid_without_http3_listener() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy { tls12: true, tls13: false, cipher_suites: vec![], }); assert!(compile(c, 1, &policy()).is_ok()); } #[test] fn persisted_remote_administration_requires_tls() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); c.administration.listen = Some("192.0.2.10:9090".parse().unwrap()); assert!(compile(c.clone(), 1, &policy()).is_err()); c.administration.tls = true; assert!(compile(c, 1, &policy()).is_ok()); } #[test] fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() { let mut p = policy(); p.admin_addr = "0.0.0.0:9090".parse().unwrap(); let c = demo_config("127.0.0.1:9000".parse().unwrap()); assert!(c.administration.listen.is_none()); assert!(compile(c, 1, &p).is_ok()); } }