Files
RustProxy/src/config.rs
T
2026-10-04 09:29:10 +02:00

2263 lines
87 KiB
Rust

//! 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<crate::stream::Host>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redirects: Vec<crate::redirect::Host>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub not_found_hosts: Vec<crate::not_found::Host>,
#[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<crate::web_server::Service>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profiles: Vec<AclProfile>,
#[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<String>,
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<Acl>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_ca_pem: Option<String>,
/// 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<String>,
/// 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<SocketAddr>,
pub cert_dir: Option<PathBuf>,
/// Resolved runtime data directory. Exposed only through authenticated admin status.
pub data_dir: Option<PathBuf>,
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<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
pub bindings: Vec<crate::vhost::Listener>,
pub frontend: Option<Arc<CompiledFrontend>>,
pub pool: Option<Arc<crate::balance::Pool>>,
pub cache: Option<bool>,
pub id: String,
pub path_prefix: String,
pub match_type: crate::vhost::PathMatch,
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl: Option<CompiledAcl>,
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<u16>,
pub http2_enabled: bool,
pub http3_enabled: bool,
pub tls_policy: crate::tls::ProtocolPolicy,
pub tls: Option<Arc<rustls::ServerConfig>>,
pub tls_http1: Option<Arc<rustls::ServerConfig>>,
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<crate::basic_auth::Compiled>,
pub metrics: Arc<crate::metrics::VhostMetrics>,
pub server_header: crate::settings::ServerHeader,
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
pub tinyauth: Option<crate::tinyauth::Compiled>,
pub id: String,
pub bindings: Vec<crate::vhost::Listener>,
pub acl_profile_id: Option<String>,
pub profile_acl: Option<CompiledAcl>,
pub profile_country_access: crate::rate_limit::CountryAccess,
pub acl: Option<CompiledAcl>,
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<String, Arc<crate::metrics::VhostMetrics>>,
pub forwarding: crate::forwarding::Compiled,
pub quic_tls: Option<Arc<quinn::crypto::rustls::QuicServerConfig>>,
pub listeners: Vec<crate::vhost::Listener>,
pub pools: Vec<Arc<crate::balance::Pool>>,
pub tls_http1: Option<Arc<rustls::ServerConfig>>,
frontends: HashMap<String, Vec<Arc<CompiledFrontend>>>,
pub tls: Option<Arc<rustls::ServerConfig>>,
pub envelope: Envelope,
routes: HashMap<String, Vec<CompiledRoute>>,
/// Main vhost backend fallback, kept separate from user Custom locations.
fallbacks: HashMap<String, Vec<CompiledRoute>>,
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
host_acls: HashMap<String, Arc<CompiledAcl>>,
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<String> {
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<CompiledFrontend>> {
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::CompiledHost>,
&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<crate::not_found::CompiledHost>> {
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<Option<String>> {
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<String> {
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<String> {
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::<IpAddr>().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<String> {
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::<IpAddr>() {
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<CompiledBackend> {
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<CompiledBackend> {
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::<IpAddr>().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<Snapshot> {
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<SocketAddr>),
) -> Result<Snapshot> {
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<SocketAddr>),
previous: Option<&Snapshot>,
) -> Result<Snapshot> {
compile_with_active(config, revision, policy, defaults, previous, &[])
}
pub fn compile_with_active(
mut config: Config,
revision: u64,
policy: &ValidationPolicy,
defaults: (SocketAddr, Option<SocketAddr>),
previous: Option<&Snapshot>,
active: &[crate::vhost::Listener],
) -> Result<Snapshot> {
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::<usize>()
<= MAX_BACKENDS,
"too many backends across all pools (max 128)"
);
ensure!(
config.vhosts.iter().map(|h| h.routes.len()).sum::<usize>() <= MAX_ROUTES,
"too many paths across all vhosts (max 256)"
);
ensure!(
config
.redirects
.iter()
.map(|h| h.rules.len())
.sum::<usize>()
<= 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<String, (Option<CompiledAcl>, 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<String, Vec<CompiledRoute>> = HashMap::new();
let mut fallbacks: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
let mut frontends: HashMap<String, Vec<Arc<CompiledFrontend>>> = HashMap::new();
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
HashMap::new();
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = 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<u16> = 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<&regex::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":"<h1>Not found</h1>"}
}]))
.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());
}
}