v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,220 @@
|
||||
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, &[])
|
||||
}
|
||||
|
||||
|
||||
type LocalHostBuild = (
|
||||
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
HashMap<String, Vec<crate::vhost::Listener>>,
|
||||
);
|
||||
|
||||
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((redirects, not_found_hosts, claimed_hosts))
|
||||
}
|
||||
|
||||
struct RoutingBuild {
|
||||
routes: HashMap<String, Vec<CompiledRoute>>,
|
||||
fallbacks: HashMap<String, Vec<CompiledRoute>>,
|
||||
frontends: HashMap<String, Vec<Arc<CompiledFrontend>>>,
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
pools: Vec<Arc<crate::balance::Pool>>,
|
||||
vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
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 RoutingBuild {
|
||||
routes,
|
||||
fallbacks,
|
||||
frontends,
|
||||
redirects,
|
||||
not_found_hosts,
|
||||
pools,
|
||||
vhost_metrics,
|
||||
} = compile_routing(
|
||||
&mut config,
|
||||
&desired_policy,
|
||||
&guarded_bindings,
|
||||
&acl_profiles,
|
||||
&web_services,
|
||||
previous,
|
||||
&effective_tls,
|
||||
&tls,
|
||||
)?;
|
||||
let quic_tls = tls
|
||||
.as_ref()
|
||||
.map(|tls| crate::http3::crypto(tls, &effective_tls.policy))
|
||||
.transpose()?;
|
||||
ensure!(
|
||||
!listeners.iter().any(|l| l.http3.is_some()) || quic_tls.is_some(),
|
||||
"HTTP/3 requires available TLS certificates"
|
||||
);
|
||||
ensure!(
|
||||
!listeners.iter().any(|l| l.http3.is_some()) || effective_tls.policy.allows_http3(),
|
||||
"HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy"
|
||||
);
|
||||
Ok(Snapshot {
|
||||
vhost_metrics,
|
||||
forwarding,
|
||||
quic_tls,
|
||||
listeners,
|
||||
pools,
|
||||
tls,
|
||||
tls_http1,
|
||||
frontends,
|
||||
envelope: Envelope { revision, config },
|
||||
routes,
|
||||
fallbacks,
|
||||
redirects,
|
||||
not_found_hosts,
|
||||
host_acls,
|
||||
headers: global_headers,
|
||||
})
|
||||
}
|
||||
fn normalize_prefix(prefix: &mut String) -> Result<()> {
|
||||
ensure!(
|
||||
prefix.starts_with('/')
|
||||
&& prefix.len() <= 512
|
||||
&& !prefix.contains('?')
|
||||
&& !prefix.contains('#')
|
||||
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid path prefix"
|
||||
);
|
||||
if prefix.len() > 1 {
|
||||
*prefix = prefix.trim_end_matches('/').to_owned();
|
||||
ensure!(!prefix.is_empty(), "invalid path prefix");
|
||||
}
|
||||
*prefix = crate::acl::routing_path(prefix)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_route_rewrite(
|
||||
route: &crate::vhost::PathRoute,
|
||||
regex: Option<®ex::Regex>,
|
||||
) -> Result<()> {
|
||||
use crate::vhost::{PathMatch, RewriteMode};
|
||||
ensure!(
|
||||
route.rewrite.value.len() <= 2048,
|
||||
"location rewrite value is too long"
|
||||
);
|
||||
ensure!(
|
||||
!route.rewrite.value.chars().any(char::is_control),
|
||||
"location rewrite contains a control character"
|
||||
);
|
||||
match route.rewrite.mode {
|
||||
RewriteMode::Preserve => ensure!(
|
||||
route.rewrite.value.is_empty(),
|
||||
"preserve rewrite has no value"
|
||||
),
|
||||
RewriteMode::StripPrefix => ensure!(
|
||||
matches!(
|
||||
route.match_type,
|
||||
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
|
||||
),
|
||||
"strip-prefix rewrite requires a prefix/exact location"
|
||||
),
|
||||
RewriteMode::ReplacePrefix => {
|
||||
ensure!(
|
||||
matches!(
|
||||
route.match_type,
|
||||
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
|
||||
),
|
||||
"replace-prefix rewrite requires a prefix/exact location"
|
||||
);
|
||||
let mut replacement = route.rewrite.value.clone();
|
||||
normalize_prefix(&mut replacement).context("invalid location replacement prefix")?;
|
||||
}
|
||||
RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
|
||||
let Some(response) = response else {
|
||||
return Ok(());
|
||||
};
|
||||
ensure!(
|
||||
(200..=599).contains(&response.status),
|
||||
"location response status must be 200..599"
|
||||
);
|
||||
ensure!(
|
||||
response.body.len() <= 65_536,
|
||||
"location response body is too large"
|
||||
);
|
||||
ensure!(
|
||||
!response.content_type.is_empty()
|
||||
&& response.content_type.len() <= 256
|
||||
&& http::HeaderValue::from_str(&response.content_type).is_ok(),
|
||||
"invalid location response content type"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn demo_config(addr: SocketAddr) -> Config {
|
||||
let mut host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({
|
||||
"id":"demo", "frontend":{"hosts":["localhost","127.0.0.1","demo.localhost"],
|
||||
"listener_ids":["http"]},
|
||||
"backends":[{"id":"demo", "url":format!("http://{addr}"), "allow_private":true}],
|
||||
"load_balancing":{"health_check":{"mode":"off"}}, "routes":[{"id":"root"}]
|
||||
}))
|
||||
.expect("static vhost demo");
|
||||
host.load_balancing.health_check.mode = crate::balance::HealthMode::Off;
|
||||
Config {
|
||||
listeners: crate::settings::Listeners::from_startup(
|
||||
"127.0.0.1:8080".parse().expect("static demo listener"),
|
||||
None,
|
||||
),
|
||||
vhosts: vec![host],
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn compile_routing(
|
||||
config: &mut Config,
|
||||
desired_policy: &ValidationPolicy,
|
||||
guarded_bindings: &[SocketAddr],
|
||||
acl_profiles: &HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)>,
|
||||
web_services: &HashMap<String, Arc<crate::web_server::CompiledService>>,
|
||||
previous: Option<&Snapshot>,
|
||||
effective_tls: &crate::tls::Settings,
|
||||
tls: &Option<Arc<rustls::ServerConfig>>,
|
||||
) -> Result<RoutingBuild> {
|
||||
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 (redirects, not_found_hosts, mut claimed_hosts) = compile_local_hosts(config)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RoutingBuild {
|
||||
routes,
|
||||
fallbacks,
|
||||
frontends,
|
||||
redirects,
|
||||
not_found_hosts,
|
||||
pools,
|
||||
vhost_metrics,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#[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());
|
||||
}
|
||||
Reference in new issue
Block a user