Files
RustProxy/src/config/compile.rs
T
2026-11-08 09:46:32 +01:00

555 lines
19 KiB
Rust

type PhpEndpointPolicy = (
String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
);
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 {:?}: every profile needs a unique ID",
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, PhpEndpointPolicy> = 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 {:?}: every Web Service needs a unique ID",
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 {:?}: every vhost needs a unique ID",
host.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 {:?}: every redirect needs a unique ID",
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 {:?}: every 404 host needs a unique ID",
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)"
);
let config_bytes = serde_json::to_vec(&config)?.len();
ensure!(
config_bytes <= MAX_CONFIG_BYTES,
"configuration too large ({config_bytes} bytes, maximum {MAX_CONFIG_BYTES} bytes)"
);
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 {:?}: use 1-64 letters, digits, '-' or '_'",
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 {:?}: every ACL profile needs a unique ID",
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: it must start with '/', be at most 512 bytes and contain no '?', '#', spaces or control characters"
);
if prefix.len() > 1 {
*prefix = prefix.trim_end_matches('/').to_owned();
ensure!(!prefix.is_empty(), "invalid path prefix");
}
*prefix = crate::acl::routing_path(prefix)?;
Ok(())
}
fn validate_route_rewrite(
route: &crate::vhost::PathRoute,
regex: Option<&regex::Regex>,
) -> Result<()> {
use crate::vhost::{PathMatch, RewriteMode};
ensure!(
route.rewrite.value.len() <= 2048,
"location rewrite value is too long"
);
ensure!(
!route.rewrite.value.chars().any(char::is_control),
"location rewrite contains a control character"
);
match route.rewrite.mode {
RewriteMode::Preserve => ensure!(
route.rewrite.value.is_empty(),
"preserve rewrite has no value"
),
RewriteMode::StripPrefix => ensure!(
matches!(
route.match_type,
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
),
"strip-prefix rewrite requires a prefix/exact location"
),
RewriteMode::ReplacePrefix => {
ensure!(
matches!(
route.match_type,
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
),
"replace-prefix rewrite requires a prefix/exact location"
);
let mut replacement = route.rewrite.value.clone();
normalize_prefix(&mut replacement).context("invalid location replacement prefix")?;
}
RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"),
}
Ok(())
}
fn validate_location_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()
}
}