475 lines
19 KiB
Rust
475 lines
19 KiB
Rust
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::<std::net::IpAddr>().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("<main>");
|
|
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<String> {
|
|
if let Some(bare) = host.strip_prefix("www.") {
|
|
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().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::<std::net::IpAddr>().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<Vec<String>> {
|
|
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<Option<(String, String)>> {
|
|
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<Vec<Listener>> {
|
|
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<Vec<Listener>> {
|
|
let mut candidates: Vec<Listener> = 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<Listener> = 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)
|
|
}
|