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