impl Default for PathRoute { fn default() -> Self { Self { source: Source::default(), backend_id: None, id: "root".into(), internal: false, path_prefix: root(), match_type: PathMatch::Prefix, enabled: true, cache: None, acl_profile_id: None, acl: None, country_access: crate::rate_limit::CountryAccess::default(), method_policy: MethodPolicy::default(), headers: HeaderRules::default(), rewrite: PathRewrite::default(), response: None, redirect: None, error_fallback: None, } } } impl PathRoute { /// Older documents persisted the main vhost fallback as a synthetic `root` /// custom location. 0.16 keeps that fallback separate from user locations; /// only the untouched synthetic shape is normalized away. A deliberate `/` /// location with any custom behavior remains a real override. pub fn is_legacy_main_fallback(&self) -> bool { self.id == "root" && self.path_prefix == "/" && self.match_type == PathMatch::Prefix && self.enabled && self.source == Source::ReverseProxy && self.backend_id.is_none() && !self.internal && self.cache.is_none() && self.acl_profile_id.is_none() && self.acl.is_none() && self.country_access.mode == crate::rate_limit::CountryMode::Off && self.country_access.countries.is_empty() && self.method_policy == MethodPolicy::default() && self.headers.is_empty() && self.rewrite.mode == RewriteMode::Preserve && self.rewrite.value.is_empty() && self.response.is_none() && self.redirect.is_none() && self.error_fallback.is_none() } } impl Vhost { pub fn validate(&mut self) -> Result<()> { ensure!(crate::config::valid_id(&self.id), "invalid vhost id"); ensure!( !self.websocket_legacy_support || self.websockets, "legacy WebSocket compatibility requires WebSocket support to be enabled" ); ensure!( !self.websocket_rfc8441 || self.websockets, "RFC 8441 requires WebSocket support to be enabled" ); ensure!( !self.websocket_rfc9220 || self.websockets, "RFC 9220 requires WebSocket support to be enabled" ); ensure!( !self.websocket_rfc8441 || self.http2_enabled, "RFC 8441 requires HTTP/2 to be enabled for the vhost" ); ensure!( !self.websocket_rfc9220 || self.http3_enabled, "RFC 9220 requires HTTP/3 to be enabled for the vhost" ); ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname"); let mut hosts = std::collections::HashSet::new(); for host in &mut self.frontend.hosts { *host = crate::config::normalize_host_pattern(host)?; ensure!(hosts.insert(host.clone()), "duplicate vhost hostname"); } if let Some(target) = &mut self.www_redirect_target { *target = crate::config::normalize_host_pattern(target)?; ensure!( !target.starts_with("*.") && target.parse::().is_err(), "www redirect target must be an exact DNS hostname" ); let partner = www_partner(target)?; ensure!( self.frontend.hosts.contains(target) || self.frontend.hosts.contains(&partner), "www redirect target or its www/non-www partner must belong to this vhost" ); } if self.www_redirect { let _ = self.www_redirect_pair()?; let _ = self.routing_hosts()?; } if let Some(id) = &self.acl_profile_id { ensure!(crate::config::valid_id(id), "invalid ACL profile id"); } self.method_policy.validate()?; ensure!( !self.frontend.listener_ids.is_empty(), "vhost requires at least one named listener" ); ensure!( self.frontend.listener_ids.len() <= 16, "vhost allows at most 16 named listeners" ); self.source.validate()?; let needs_proxy = self.source.is_reverse_proxy() || self.routes.iter().any(|route| { route.enabled && route.response.is_none() && route.redirect.is_none() && route.source.is_reverse_proxy() }); ensure!( self.backends.len() <= 32, "vhost allows at most 32 backend members" ); ensure!( !needs_proxy || !self.backends.is_empty() || self.transient_unavailable, "vhost requires at least one backend member for Reverse Proxy sources" ); let mut members = std::collections::HashSet::new(); for member in &self.backends { ensure!( crate::config::valid_id(&member.id) && members.insert(&member.id), "invalid/duplicate pool member id" ); ensure!( (1..=256).contains(&member.weight), "backend weight must be 1..256" ); ensure!( member.max_concurrent_requests <= 1_000_000, "backend max_concurrent_requests must be 0..1000000" ); ensure!( member.queue_timeout_ms <= 60_000, "backend queue_timeout_ms must be 0..60000" ); } // The main vhost backend is an unconditional internal fallback in 0.16. // Normalize the untouched pre-0.16 synthetic root route out of the custom // location list so adding/removing Custom locations cannot delete fallback. self.routes.retain(|route| !route.is_legacy_main_fallback()); if let Some(fallback) = &mut self.error_fallback { fallback.validate()?; } let mut route_ids = std::collections::HashSet::new(); for route in &mut self.routes { ensure!( crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()), "invalid/duplicate vhost path id" ); route.source.validate()?; ensure!( route.response.is_none() || route.redirect.is_none(), "location cannot define both a local response and a redirect" ); if let Some(id) = &route.acl_profile_id { ensure!(crate::config::valid_id(id), "invalid location ACL profile id"); } route.country_access.validate()?; route.method_policy.validate()?; ensure!( route.source.is_reverse_proxy() || route.backend_id.is_none(), "Web Server location cannot pin a reverse-proxy backend" ); if let Some(fallback) = &mut route.error_fallback { fallback.validate()?; } } ensure!( self.routes.len() <= 64, "at most 64 custom locations per vhost" ); let resolve_target = |target: &str| { self.routes .iter() .find(|route| route.id == target && route.enabled && route.internal) }; if let Some(fallback) = &self.error_fallback { ensure!( resolve_target(&fallback.target).is_some(), "vhost error fallback target must reference an enabled internal location" ); } for route in &self.routes { if let Some(fallback) = &route.error_fallback { ensure!( resolve_target(&fallback.target).is_some(), "location error fallback target must reference an enabled internal location" ); } } // Reject cycles at configuration time; a runtime depth guard remains as a // second line of defense for hot-reload/state inconsistencies. let mut starts = vec![None]; starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str()))); for start in starts { let mut seen = std::collections::HashSet::new(); let mut current = start; loop { let key = current.unwrap_or("
"); ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected"); let fallback = match current { None => self.error_fallback.as_ref(), Some(id) => self .routes .iter() .find(|route| route.id == id) .and_then(|route| route.error_fallback.as_ref()), }; let Some(fallback) = fallback else { break }; current = Some(fallback.target.as_str()); } } self.load_balancing.validate()?; self.proxy_timeouts.validate()?; self.rate_limit.validate()?; if let Some(policy) = &self.tls_policy { // Listener-aware HTTP/3 compatibility is checked during full config // compilation. A vhost may keep the H3 feature flag enabled while it // is currently attached only to TCP listeners. policy.validate()?; } if let Some(server) = &self.server_header { server.validate()?; } self.tinyauth.validate()?; self.basic_auth.compile()?; ensure!( !(self.tinyauth.enabled && self.basic_auth.enabled), "choose either Tinyauth or Basic authentication" ); self.pages.validate()?; self.transform.resolve(&crate::transform::Settings::default())?; self.security_headers.compile()?; self.headers.compile()?; if let Some(acl) = &self.acl { acl.compile()?; } Ok(()) } } pub fn canonical_ip(ip: IpAddr) -> IpAddr { match ip { IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), _ => ip, } } impl Listener { pub fn matches(&self, local: SocketAddr, tls: bool) -> bool { if self.tls != tls || self.address.port() != local.port() { return false; } if let (SocketAddr::V6(bound), SocketAddr::V6(actual)) = (self.address, local) { if bound.scope_id() != 0 && bound.scope_id() != actual.scope_id() { return false; } } let bind = canonical_ip(self.address.ip()); let local = canonical_ip(local.ip()); bind == local || (bind.is_unspecified() && (bind.is_ipv6() || bind.is_ipv4() == local.is_ipv4())) } } pub fn socket_overlap(a: SocketAddr, b: SocketAddr) -> bool { if a.port() != b.port() { return false; } let a = canonical_ip(a.ip()); let b = canonical_ip(b.ip()); a == b || ((a.is_unspecified() || b.is_unspecified()) && (a.is_ipv4() == b.is_ipv4() || (a.is_ipv6() && a.is_unspecified()) || (b.is_ipv6() && b.is_unspecified()))) } pub fn validate_listener(listener: Listener) -> Result<()> { ensure!( !listener.tls || !listener.h2c, "h2c is available only on cleartext listeners" ); if let Some(h2) = listener.http2_settings { h2.validate()?; } if let Some(h3) = listener.http3 { ensure!(listener.tls, "HTTP/3 requires TLS on the frontend"); h3.validate()?; } let ip = listener.address.ip(); ensure!( listener.address.port() > 0 && !ip.is_multicast(), "listener needs a unicast/wildcard IP and port 1..65535" ); ensure!( !matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()), "use native IPv4 rather than a mapped IPv6 bind address" ); ensure!( !matches!(ip, IpAddr::V4(v4) if v4.is_broadcast()), "broadcast address cannot be a frontend" ); Ok(()) } /// Return the other side of a www/non-www redirect pair. The target and its /// counterpart must both be routable, even if only one was configured. fn www_partner(host: &str) -> Result { if let Some(bare) = host.strip_prefix("www.") { ensure!(!bare.is_empty() && bare.parse::().is_err(), "www redirect requires a DNS hostname"); crate::config::normalize_host(bare) } else { crate::config::normalize_host(&format!("www.{host}")) } } impl Vhost { fn www_canonical_host(&self) -> Result<&str> { let canonical = self.www_redirect_target.as_deref() .or_else(|| self.frontend.hosts.first().map(String::as_str)) .ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?; ensure!(!canonical.starts_with("*.") && canonical.parse::().is_err(), "www redirect requires an exact DNS hostname"); let partner = www_partner(canonical)?; ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner), "www redirect target or its www/non-www partner must belong to this vhost"); Ok(canonical) } /// Hostnames owned by the vhost at runtime. Both the selected canonical /// host and its www/non-www partner are claimed, even if one is implicit. pub fn routing_hosts(&self) -> Result> { let mut hosts = self.frontend.hosts.clone(); if !self.www_redirect { return Ok(hosts); } let canonical = self.www_canonical_host()?.to_owned(); let companion = www_partner(&canonical)?; for name in [canonical, companion] { if !hosts.contains(&name) { hosts.push(name); } } Ok(hosts) } pub fn www_redirect_pair(&self) -> Result> { if !self.www_redirect { return Ok(None); } let canonical = self.www_canonical_host()?.to_owned(); let companion = www_partner(&canonical)?; Ok(Some((companion, canonical))) } } impl Frontend { pub fn resolved(&self, settings: &crate::settings::Listeners) -> Result> { let mut result = Vec::new(); let mut seen = std::collections::HashSet::new(); for id in &self.listener_ids { ensure!(seen.insert(id), "duplicate named listener reference"); let endpoint = settings .endpoints .iter() .find(|item| &item.id == id) .ok_or_else(|| anyhow::anyhow!("unknown named listener: {id}"))?; if endpoint.enabled { result.extend(endpoint.bindings()); } } let mut addresses = std::collections::HashSet::new(); ensure!( result.iter().all(|l| addresses.insert(l.address)), "duplicate resolved frontend binding" ); Ok(result) } } /// Consolidate identical / same-family wildcard sockets, but never widen routing /// permissions. Vhost selection still checks its own unmerged bindings. pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result> { let mut candidates: Vec = config .listeners .endpoints .iter() .filter(|e| e.enabled) .flat_map(|e| e.bindings()) .collect(); for host in &config.vhosts { if host.enabled { candidates.extend(host.frontend.resolved(&config.listeners)?); } } for redirect in &config.redirects { if redirect.enabled { candidates.extend(redirect.frontend.resolved(&config.listeners)?); } } for host in &config.not_found_hosts { if host.enabled { candidates.extend(host.frontend.resolved(&config.listeners)?); } } let effective = |mut value: Listener| { if !value.tls { value.http2 = false; } else { value.h2c = false; } if value.http2 || value.h2c { if value.http2_settings.is_none() { value.http2_settings = Some(config.http2); } } else { value.http2_settings = None; } value }; let describe = |binding: &Listener| -> String { let mut owners = Vec::new(); let same = |value: Listener| effective(value) == *binding; for item in config .listeners .endpoints .iter() .filter(|e| e.enabled && e.bindings().into_iter().any(|value| same(value))) { owners.push(format!("listener '{}' ({})", item.id, item.name)); } if owners.is_empty() { binding.address.to_string() } else { owners.join(", ") } }; let mut result: Vec = Vec::new(); for listener in candidates { validate_listener(listener)?; let listener = effective(listener); ensure!( listener.address.port() != policy.admin_addr.port(), "frontend cannot share the administration port" ); let mut covered = false; for previous in &result { if !socket_overlap(previous.address, listener.address) { continue; } ensure!(previous.address.is_ipv4() == listener.address.is_ipv4(), "Listener conflict: {} [{}] overlaps {} [{}]. IPv4/IPv6 wildcard sockets cannot be merged; use explicit IP addresses or a different port.", previous.address, describe(previous), listener.address, describe(&listener)); ensure!(previous.tls == listener.tls && previous.http2 == listener.http2 && previous.http2_settings == listener.http2_settings && previous.h2c == listener.h2c && previous.http3 == listener.http3, "Listener conflict: {} [{}] overlaps {} [{}]. Existing: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}; requested: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}. A shared IP/port (including wildcard bindings) must use the same protocols and H2/H3 limits. Select the same named listener, align these settings, or use another IP/port.", previous.address, describe(previous), listener.address, describe(&listener), previous.tls, previous.http2, previous.http2_settings, previous.h2c, previous.http3, listener.tls, listener.http2, listener.http2_settings, listener.h2c, listener.http3); if previous.address.ip().is_unspecified() || previous.address == listener.address { covered = true; } } if covered { continue; } if listener.address.ip().is_unspecified() { result.retain(|p| !socket_overlap(p.address, listener.address)); } result.push(listener); } ensure!( result.len() <= 130, "too many distinct frontend sockets (maximum 130)" ); result.sort_by_key(|l| (l.address, l.tls, l.http2, l.h2c)); Ok(result) }