542 lines
19 KiB
Rust
542 lines
19 KiB
Rust
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.migrate_legacy_user_agent_blocking()?;
|
|
config.administration.validate(&config.tls)?;
|
|
config.logging.validate()?;
|
|
config.error_log.validate()?;
|
|
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
|
|
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.geoip_updates.validate()?;
|
|
config.cache.validate()?;
|
|
config.transform.validate()?;
|
|
config.pages.validate()?;
|
|
config.compression.validate()?;
|
|
config.server_header.validate()?;
|
|
let forwarding = config.forwarding.compile()?;
|
|
ensure!(
|
|
config.php_fpm_profiles.len() <= 128,
|
|
"too many PHP-FPM profiles (max 128)"
|
|
);
|
|
let mut php_profiles = HashMap::new();
|
|
for profile in &mut config.php_fpm_profiles {
|
|
profile
|
|
.validate()
|
|
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
|
ensure!(
|
|
php_profiles
|
|
.insert(profile.id.clone(), profile.php.clone())
|
|
.is_none(),
|
|
"duplicate PHP-FPM profile id"
|
|
);
|
|
}
|
|
ensure!(
|
|
config.web_services.len() <= 128,
|
|
"too many Web Services (max 128)"
|
|
);
|
|
let mut web_services = HashMap::new();
|
|
let mut php_endpoint_policies: HashMap<
|
|
String,
|
|
(
|
|
String,
|
|
std::collections::BTreeMap<String, String>,
|
|
std::collections::BTreeMap<String, String>,
|
|
),
|
|
> = HashMap::new();
|
|
for service in &mut config.web_services {
|
|
service
|
|
.validate()
|
|
.with_context(|| format!("Web Service {}", service.id))?;
|
|
let mut effective = service.clone();
|
|
if let Some(id) = &service.php_profile_id {
|
|
effective.php = php_profiles
|
|
.get(id)
|
|
.with_context(|| {
|
|
format!(
|
|
"Web Service {} references unknown PHP-FPM profile {}",
|
|
service.id, id
|
|
)
|
|
})?
|
|
.clone();
|
|
effective
|
|
.validate()
|
|
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
|
}
|
|
if effective.php.enabled {
|
|
let php = &effective.php;
|
|
if let Some((owner, values, admin_values)) = php_endpoint_policies.get(&php.endpoint) {
|
|
if values != &php.php_values || admin_values != &php.php_admin_values {
|
|
tracing::warn!(
|
|
service = %effective.id, other_service = %owner, endpoint = %php.endpoint,
|
|
"PHP-FPM endpoint is shared by Web Services with different PHP_VALUE/PHP_ADMIN_VALUE settings; FPM worker settings can leak across requests. Use distinct PHP-FPM pools/sockets (especially for open_basedir)."
|
|
);
|
|
}
|
|
} else {
|
|
php_endpoint_policies.insert(
|
|
php.endpoint.clone(),
|
|
(
|
|
effective.id.clone(),
|
|
php.php_values.clone(),
|
|
php.php_admin_values.clone(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
let compiled = Arc::new(
|
|
effective
|
|
.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));
|
|
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()?;
|
|
let global_security = config.global_security.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"
|
|
);
|
|
}
|
|
crate::acl::dynamic::configured_hostnames(&config)?;
|
|
for agent in &config.docker_agents.agents {
|
|
for profile_id in
|
|
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
|
|
{
|
|
ensure!(
|
|
acl_profiles.contains_key(profile_id),
|
|
"Docker agent {} references unknown default ACL profile {}",
|
|
agent.id,
|
|
profile_id
|
|
);
|
|
}
|
|
for assignment in &agent.assignments {
|
|
for profile_id in crate::acl::profile_ids(
|
|
assignment.acl_profile_id.as_deref(),
|
|
&assignment.acl_profile_ids,
|
|
) {
|
|
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 {
|
|
web_php_services: web_services,
|
|
vhost_metrics,
|
|
forwarding,
|
|
quic_tls,
|
|
listeners,
|
|
pools,
|
|
tls,
|
|
tls_http1,
|
|
frontends,
|
|
envelope: Envelope { revision, config },
|
|
routes,
|
|
fallbacks,
|
|
redirects,
|
|
not_found_hosts,
|
|
host_acls,
|
|
global_security,
|
|
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_redirect(
|
|
redirect: Option<&crate::vhost::LocationRedirect>,
|
|
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
|
let Some(redirect) = redirect else {
|
|
return Ok(None);
|
|
};
|
|
ensure!(
|
|
matches!(redirect.status, 301 | 302 | 303 | 307 | 308),
|
|
"location redirect status must be 301, 302, 303, 307 or 308"
|
|
);
|
|
ensure!(
|
|
!redirect.target.is_empty()
|
|
&& redirect.target.len() <= 2048
|
|
&& !redirect.target.chars().any(char::is_control),
|
|
"invalid location redirect target"
|
|
);
|
|
let authority_end = redirect
|
|
.target
|
|
.find("://")
|
|
.and_then(|start| {
|
|
let rest = &redirect.target[start + 3..];
|
|
rest.char_indices()
|
|
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
|
|
.map(|(offset, _)| start + 3 + offset)
|
|
.or(Some(redirect.target.len()))
|
|
})
|
|
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
|
ensure!(
|
|
!redirect.target[..authority_end].contains('$'),
|
|
"location redirect captures are not allowed in the URL authority"
|
|
);
|
|
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
|
|
.expect("static capture regex")
|
|
.replace_all(&redirect.target, "capture");
|
|
let parsed =
|
|
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
|
ensure!(
|
|
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
|
|
"location redirect target must use http/https and include a host"
|
|
);
|
|
ensure!(
|
|
parsed.username().is_empty() && parsed.password().is_none(),
|
|
"location redirect target cannot contain credentials"
|
|
);
|
|
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
|
|
None | Some("") => None,
|
|
Some(pattern) => {
|
|
ensure!(
|
|
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
|
|
"invalid location redirect rewrite regex"
|
|
);
|
|
let mut builder = regex::RegexBuilder::new(pattern);
|
|
builder
|
|
.size_limit(2 * 1024 * 1024)
|
|
.dfa_size_limit(2 * 1024 * 1024);
|
|
Some(
|
|
builder
|
|
.build()
|
|
.context("invalid location redirect rewrite regex")?,
|
|
)
|
|
}
|
|
};
|
|
Ok(Some(crate::config::CompiledLocationRedirect {
|
|
status: redirect.status,
|
|
target: redirect.target.clone(),
|
|
rewrite_regex,
|
|
preserve_query: redirect.preserve_query,
|
|
}))
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|