pub fn compile_with_active( mut config: Config, revision: u64, policy: &ValidationPolicy, defaults: (SocketAddr, Option), previous: Option<&Snapshot>, active: &[crate::vhost::Listener], ) -> Result { config.migrate_legacy_user_agent_blocking()?; config.administration.validate(&config.tls)?; config.logging.validate()?; config.error_log.validate()?; let desired_admin = config.administration.resolved_listen(policy.admin_addr); 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.geoip_updates.validate()?; config.cache.validate()?; config.transform.validate()?; config.pages.validate()?; config.compression.validate()?; config.server_header.validate()?; let forwarding = config.forwarding.compile()?; ensure!( config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)" ); let mut php_profiles = HashMap::new(); for profile in &mut config.php_fpm_profiles { profile .validate() .with_context(|| format!("PHP-FPM profile {}", profile.id))?; ensure!( php_profiles .insert(profile.id.clone(), profile.php.clone()) .is_none(), "duplicate PHP-FPM profile id" ); } ensure!( config.web_services.len() <= 128, "too many Web Services (max 128)" ); let mut web_services = HashMap::new(); let mut php_endpoint_policies: HashMap< String, ( String, std::collections::BTreeMap, std::collections::BTreeMap, ), > = HashMap::new(); for service in &mut config.web_services { service .validate() .with_context(|| format!("Web Service {}", service.id))?; let mut effective = service.clone(); if let Some(id) = &service.php_profile_id { effective.php = php_profiles .get(id) .with_context(|| { format!( "Web Service {} references unknown PHP-FPM profile {}", service.id, id ) })? .clone(); effective .validate() .with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; } if effective.php.enabled { let php = &effective.php; if let Some((owner, values, admin_values)) = php_endpoint_policies.get(&php.endpoint) { if values != &php.php_values || admin_values != &php.php_admin_values { tracing::warn!( service = %effective.id, other_service = %owner, endpoint = %php.endpoint, "PHP-FPM endpoint is shared by Web Services with different PHP_VALUE/PHP_ADMIN_VALUE settings; FPM worker settings can leak across requests. Use distinct PHP-FPM pools/sockets (especially for open_basedir)." ); } } else { php_endpoint_policies.insert( php.endpoint.clone(), ( effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), ), ); } } let compiled = Arc::new( effective .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)); 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()?; let global_security = config.global_security.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" ); } crate::acl::dynamic::configured_hostnames(&config)?; for agent in &config.docker_agents.agents { for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) { ensure!( acl_profiles.contains_key(profile_id), "Docker agent {} references unknown default ACL profile {}", agent.id, profile_id ); } for assignment in &agent.assignments { for profile_id in crate::acl::profile_ids( assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids, ) { ensure!( acl_profiles.contains_key(profile_id), "Docker assignment {} on agent {} references unknown ACL profile {}", assignment.service_key, agent.id, profile_id ); } } } let RoutingBuild { routes, fallbacks, frontends, redirects, not_found_hosts, pools, vhost_metrics, } = compile_routing( &mut config, &desired_policy, &guarded_bindings, &acl_profiles, &web_services, previous, &effective_tls, &tls, )?; 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 { web_php_services: web_services, vhost_metrics, forwarding, quic_tls, listeners, pools, tls, tls_http1, frontends, envelope: Envelope { revision, config }, routes, fallbacks, redirects, not_found_hosts, host_acls, global_security, 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_redirect( redirect: Option<&crate::vhost::LocationRedirect>, ) -> Result> { let Some(redirect) = redirect else { return Ok(None); }; ensure!( matches!(redirect.status, 301 | 302 | 303 | 307 | 308), "location redirect status must be 301, 302, 303, 307 or 308" ); ensure!( !redirect.target.is_empty() && redirect.target.len() <= 2048 && !redirect.target.chars().any(char::is_control), "invalid location redirect target" ); let authority_end = redirect .target .find("://") .and_then(|start| { let rest = &redirect.target[start + 3..]; rest.char_indices() .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) .map(|(offset, _)| start + 3 + offset) .or(Some(redirect.target.len())) }) .ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; ensure!( !redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority" ); let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?") .expect("static capture regex") .replace_all(&redirect.target, "capture"); let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; ensure!( matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host" ); ensure!( parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials" ); let rewrite_regex = match redirect.rewrite_pattern.as_deref() { None | Some("") => None, Some(pattern) => { ensure!( pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex" ); let mut builder = regex::RegexBuilder::new(pattern); builder .size_limit(2 * 1024 * 1024) .dfa_size_limit(2 * 1024 * 1024); Some( builder .build() .context("invalid location redirect rewrite regex")?, ) } }; Ok(Some(crate::config::CompiledLocationRedirect { status: redirect.status, target: redirect.target.clone(), rewrite_regex, preserve_query: redirect.preserve_query, })) } 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() } }