This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 09:30:21 +02:00
1 parent 1dea7f55c8
commit c819f0a335
151 files changed
+7610 -4264

No files matched your search

+50 -51
View File
@@ -140,7 +140,6 @@ pub fn compile_reusing(
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>>>,
@@ -148,64 +147,64 @@ type LocalHostBuild = (
);
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)?);
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 {
redirects
.entry(name.clone())
.or_default()
.push(compiled.clone());
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());
}
}
}
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
);
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());
}
}
claimed.extend(bindings.iter().copied());
}
if host.enabled {
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
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 {
not_found_hosts
.entry(name.clone())
.or_default()
.push(compiled.clone());
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))
}
+4 -1
View File
@@ -47,7 +47,10 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
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
+356 -348
View File
@@ -9,381 +9,389 @@ fn compile_routing(
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 {
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
);
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());
}
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);
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));
}
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!(
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(),
method_policy: host.method_policy.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_profile_id: None,
profile_acl: None,
profile_country_access: crate::rate_limit::CountryAccess::default(),
acl: None,
country_access: crate::rate_limit::CountryAccess::default(),
method_policy: crate::vhost::MethodPolicy::default(),
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 (route_profile_acl, route_profile_country_access) = match route.acl_profile_id.as_deref() {
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 {} location {} references unknown ACL profile {}",
host.id,
route.id,
id
)
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
})?,
None => (None, crate::rate_limit::CountryAccess::default()),
};
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 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(),
method_policy: host.method_policy.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 compiled = CompiledRoute {
backend_id: route.backend_id.clone(),
let fallback = CompiledRoute {
backend_id: None,
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_profile_id: route.acl_profile_id.clone(),
profile_acl: route_profile_acl,
profile_country_access: route_profile_country_access,
acl,
country_access: route.country_access.clone(),
method_policy: route.method_policy.clone(),
headers,
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_profile_id: None,
profile_acl: None,
profile_country_access: crate::rate_limit::CountryAccess::default(),
acl: None,
country_access: crate::rate_limit::CountryAccess::default(),
method_policy: crate::vhost::MethodPolicy::default(),
headers: crate::headers::CompiledHeaders::default(),
};
for name in &host.frontend.hosts {
routes
fallbacks
.entry(name.clone())
.or_default()
.push(compiled.clone());
.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 (route_profile_acl, route_profile_country_access) =
match route.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} location {} references unknown ACL profile {}",
host.id,
route.id,
id
)
})?,
None => (None, crate::rate_limit::CountryAccess::default()),
};
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_profile_id: route.acl_profile_id.clone(),
profile_acl: route_profile_acl,
profile_country_access: route_profile_country_access,
acl,
country_access: route.country_access.clone(),
method_policy: route.method_policy.clone(),
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);
}
}
}
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,
+1 -2
View File
@@ -26,8 +26,7 @@ impl Snapshot {
&& frontend.websocket_rfc8441
&& frontend.http2_enabled
&& frontend.bindings.iter().any(|binding| {
binding.matches(local, tls)
&& if tls { binding.http2 } else { binding.h2c }
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
})
})
}
+410 -402
View File
@@ -1,423 +1,431 @@
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(),
}
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 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}");
}
#[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());
}
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")));
#[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());
}
c.vhosts[0].acl_profile_id = Some("missing".into());
assert!(compile(c, 2, &policy()).is_err());
}
#[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();
#[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}");
let mut second = first.clone();
second.id = "shared-second".into();
second.frontend.hosts = vec!["second.localhost".into()];
config.vhosts.push(second);
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));
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"));
}
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}"
);
#[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}");
}
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}");
#[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"]}
}]))
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();
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")));
assert_eq!(frontend.https_redirect_port, Some(443));
c.vhosts[0].acl_profile_id = Some("missing".into());
assert!(compile(c, 2, &policy()).is_err());
}
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());
#[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.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.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!([{
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());
}
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();
#[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()));
}
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 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 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 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);
}
+195 -195
View File
@@ -1,207 +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 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}");
}
#[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();
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 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 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 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();
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
.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()
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()
.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>"}
}]))
.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();
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));
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());
}
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 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());
}
#[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());
}