This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+403
View File
@@ -0,0 +1,403 @@
pub fn compile_with_active(
mut config: Config,
revision: u64,
policy: &ValidationPolicy,
defaults: (SocketAddr, Option<SocketAddr>),
previous: Option<&Snapshot>,
active: &[crate::vhost::Listener],
) -> Result<Snapshot> {
config.administration.validate(&config.tls)?;
config.logging.validate()?;
config.error_log.validate()?;
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
// A persisted non-loopback address is only accepted with administration TLS.
// When `listen` is omitted, the startup/CLI address remains authoritative and
// its legacy plain-HTTP risk gate is enforced by main before compilation.
ensure!(
config.administration.listen.is_none()
|| desired_admin.ip().is_loopback()
|| config.administration.tls,
"a persisted remote administration listener requires administration TLS"
);
let mut desired_policy = policy.clone();
desired_policy.admin_addr = desired_admin;
config.listeners.validate(&desired_policy)?;
config.http2.validate()?;
config.docker_agents.validate(&config.listeners)?;
let certificate_ids: HashSet<_> = config
.tls
.certificates
.iter()
.map(|certificate| certificate.id.as_str())
.collect();
for agent in &config.docker_agents.agents {
if let Some(id) = agent.certificate_id.as_deref() {
ensure!(
certificate_ids.contains(id),
"Docker agent {} references unknown certificate id {}",
agent.id,
id
);
}
}
config.prometheus.validate()?;
config.cache.validate()?;
config.transform.validate()?;
config.pages.validate()?;
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
let mut web_services = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
let compiled = Arc::new(
service
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
for host in &mut config.vhosts {
host.validate()
.with_context(|| format!("vhost {}", host.id))?;
let validate_source = |source: &crate::vhost::Source| -> Result<()> {
if let crate::vhost::Source::WebServer { web_service_id } = source {
ensure!(
web_services.contains_key(web_service_id),
"vhost {} references unknown Web Service {}",
host.id,
web_service_id
);
}
Ok(())
};
validate_source(&host.source)?;
for route in &host.routes {
if route.response.is_none() {
validate_source(&route.source)?;
}
}
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
}
ensure!(
config.redirects.len() <= 64,
"too many redirect hosts (max 64)"
);
let mut redirect_ids = HashSet::new();
for redirect in &mut config.redirects {
redirect
.validate()
.with_context(|| format!("redirect {}", redirect.id))?;
ensure!(
redirect_ids.insert(redirect.id.clone()),
"duplicate redirect id"
);
}
ensure!(
config.not_found_hosts.len() <= 64,
"too many 404 hosts (max 64)"
);
let mut not_found_ids = HashSet::new();
for host in &mut config.not_found_hosts {
host.validate()
.with_context(|| format!("404 host {}", host.id))?;
ensure!(
not_found_ids.insert(host.id.clone()),
"duplicate 404 host id"
);
}
let effective_tls = crate::tls::for_all_frontends(
&config.tls,
&config.vhosts,
&config.redirects,
&config.not_found_hosts,
)?;
let tls = crate::tls::compile(&effective_tls, policy.cert_dir.as_deref())?;
let tls_http1 = tls
.as_ref()
.map(|base| crate::tls::variant(base, &effective_tls.policy, false))
.transpose()?;
ensure!(
config
.vhosts
.iter()
.map(|h| h.backends.len())
.sum::<usize>()
<= MAX_BACKENDS,
"too many backends across all pools (max 128)"
);
ensure!(
config.vhosts.iter().map(|h| h.routes.len()).sum::<usize>() <= MAX_ROUTES,
"too many paths across all vhosts (max 256)"
);
ensure!(
config
.redirects
.iter()
.map(|h| h.rules.len())
.sum::<usize>()
<= MAX_ROUTES,
"too many rules across all redirect hosts (max 256)"
);
ensure!(
serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES,
"configuration too large"
);
let listeners = crate::vhost::listener_plan(&config, &desired_policy)?;
crate::stream::validate(
&mut config.streams,
&listeners,
desired_policy.admin_addr,
config
.prometheus
.enabled
.then_some(config.prometheus.listen),
)?;
if config.prometheus.enabled {
ensure!(
!crate::vhost::socket_overlap(config.prometheus.listen, desired_policy.admin_addr)
&& !listeners
.iter()
.any(|l| crate::vhost::socket_overlap(config.prometheus.listen, l.address)),
"metrics: exporter address overlaps a proxy/administration listener"
);
}
let mut guarded_bindings: Vec<_> = listeners.iter().map(|l| l.address).collect();
if let Some(previous) = previous {
guarded_bindings.extend(previous.listeners.iter().map(|l| l.address));
}
guarded_bindings.extend(active.iter().map(|l| l.address));
// Startup/default addresses remain protected backend destinations even when
// the desired named-listener plan changes before a restart.
guarded_bindings.push(defaults.0);
guarded_bindings.extend(defaults.1);
guarded_bindings.extend([
policy.proxy_addr,
policy.admin_addr,
desired_policy.admin_addr,
]);
guarded_bindings.extend(policy.tls_addr);
if config.prometheus.enabled {
guarded_bindings.push(config.prometheus.listen);
}
if let Some(previous) = previous {
if previous.envelope.config.prometheus.enabled {
guarded_bindings.push(previous.envelope.config.prometheus.listen);
}
}
guarded_bindings.sort();
guarded_bindings.dedup();
let global_headers = config.headers.compile()?;
ensure!(
config.host_policies.len() <= 256,
"too many host ACL policies"
);
let mut host_acls = HashMap::new();
for policy in &mut config.host_policies {
ensure!(
!policy.hosts.is_empty(),
"host policy requires at least one hostname"
);
let compiled = Arc::new(policy.acl.compile()?);
for host in &mut policy.hosts {
*host = normalize_host_pattern(host)?;
ensure!(
host_acls.insert(host.clone(), compiled.clone()).is_none(),
"duplicate host ACL policy"
);
}
}
ensure!(config.acl_profiles.len() <= 64, "at most 64 ACL profiles");
let mut acl_profiles: HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)> =
HashMap::new();
for profile in &mut config.acl_profiles {
ensure!(valid_id(&profile.id), "invalid ACL profile id");
ensure!(
!profile.name.trim().is_empty()
&& profile.name.len() <= 96
&& !profile.name.chars().any(char::is_control),
"ACL profile name must be 1..96 bytes without controls"
);
profile.name = profile.name.trim().to_owned();
profile.country_access.validate()?;
let acl = profile.acl.as_ref().map(Acl::compile).transpose()?;
ensure!(
acl_profiles
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
.is_none(),
"duplicate ACL profile id"
);
}
for agent in &config.docker_agents.agents {
if let Some(profile_id) = agent.acl_profile_id.as_deref() {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}",
agent.id,
profile_id
);
}
for assignment in &agent.assignments {
if let Some(profile_id) = assignment.acl_profile_id.as_deref() {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}",
assignment.service_key,
agent.id,
profile_id
);
}
}
}
let RoutingBuild {
routes,
fallbacks,
frontends,
redirects,
not_found_hosts,
pools,
vhost_metrics,
} = compile_routing(
&mut config,
&desired_policy,
&guarded_bindings,
&acl_profiles,
&web_services,
previous,
&effective_tls,
&tls,
)?;
let quic_tls = tls
.as_ref()
.map(|tls| crate::http3::crypto(tls, &effective_tls.policy))
.transpose()?;
ensure!(
!listeners.iter().any(|l| l.http3.is_some()) || quic_tls.is_some(),
"HTTP/3 requires available TLS certificates"
);
ensure!(
!listeners.iter().any(|l| l.http3.is_some()) || effective_tls.policy.allows_http3(),
"HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy"
);
Ok(Snapshot {
vhost_metrics,
forwarding,
quic_tls,
listeners,
pools,
tls,
tls_http1,
frontends,
envelope: Envelope { revision, config },
routes,
fallbacks,
redirects,
not_found_hosts,
host_acls,
headers: global_headers,
})
}
fn normalize_prefix(prefix: &mut String) -> Result<()> {
ensure!(
prefix.starts_with('/')
&& prefix.len() <= 512
&& !prefix.contains('?')
&& !prefix.contains('#')
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
"invalid path prefix"
);
if prefix.len() > 1 {
*prefix = prefix.trim_end_matches('/').to_owned();
ensure!(!prefix.is_empty(), "invalid path prefix");
}
*prefix = crate::acl::routing_path(prefix)?;
Ok(())
}
fn validate_route_rewrite(
route: &crate::vhost::PathRoute,
regex: Option<&regex::Regex>,
) -> Result<()> {
use crate::vhost::{PathMatch, RewriteMode};
ensure!(
route.rewrite.value.len() <= 2048,
"location rewrite value is too long"
);
ensure!(
!route.rewrite.value.chars().any(char::is_control),
"location rewrite contains a control character"
);
match route.rewrite.mode {
RewriteMode::Preserve => ensure!(
route.rewrite.value.is_empty(),
"preserve rewrite has no value"
),
RewriteMode::StripPrefix => ensure!(
matches!(
route.match_type,
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
),
"strip-prefix rewrite requires a prefix/exact location"
),
RewriteMode::ReplacePrefix => {
ensure!(
matches!(
route.match_type,
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
),
"replace-prefix rewrite requires a prefix/exact location"
);
let mut replacement = route.rewrite.value.clone();
normalize_prefix(&mut replacement).context("invalid location replacement prefix")?;
}
RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"),
}
Ok(())
}
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
let Some(response) = response else {
return Ok(());
};
ensure!(
(200..=599).contains(&response.status),
"location response status must be 200..599"
);
ensure!(
response.body.len() <= 65_536,
"location response body is too large"
);
ensure!(
!response.content_type.is_empty()
&& response.content_type.len() <= 256
&& http::HeaderValue::from_str(&response.content_type).is_ok(),
"invalid location response content type"
);
Ok(())
}
pub fn demo_config(addr: SocketAddr) -> Config {
let mut host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({
"id":"demo", "frontend":{"hosts":["localhost","127.0.0.1","demo.localhost"],
"listener_ids":["http"]},
"backends":[{"id":"demo", "url":format!("http://{addr}"), "allow_private":true}],
"load_balancing":{"health_check":{"mode":"off"}}, "routes":[{"id":"root"}]
}))
.expect("static vhost demo");
host.load_balancing.health_check.mode = crate::balance::HealthMode::Off;
Config {
listeners: crate::settings::Listeners::from_startup(
"127.0.0.1:8080".parse().expect("static demo listener"),
None,
),
vhosts: vec![host],
..Config::default()
}
}