0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+5969
-6926
No files matched your search
@@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout(
|
||||
Host::Ipv6(ip) => {
|
||||
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr)
|
||||
}
|
||||
// DNS destinations remain subject to the normal self-target guard, but
|
||||
// are never trusted for administration-cookie passthrough. The special
|
||||
// control-plane path must point at the listener explicitly by IP.
|
||||
Host::Domain(_) => false,
|
||||
};
|
||||
// Exact local listener targets are rejected by AddressPolicy by default, including DNS
|
||||
// answers. An explicit per-backend risk acceptance can override only that self-target
|
||||
// guard; remote origins on any valid backend port, including 9090, remain valid.
|
||||
let authority = match url.host().context("backend host missing")? {
|
||||
Host::Ipv6(ip) => format!("[{ip}]:{port}"),
|
||||
host => format!("{host}:{port}"),
|
||||
@@ -120,7 +114,6 @@ pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Resu
|
||||
(policy.proxy_addr, policy.tls_addr),
|
||||
)
|
||||
}
|
||||
/// Validate both active sockets and the sockets that will be used after restart.
|
||||
pub fn compile_with_defaults(
|
||||
config: Config,
|
||||
revision: u64,
|
||||
@@ -129,8 +122,6 @@ pub fn compile_with_defaults(
|
||||
) -> Result<Snapshot> {
|
||||
compile_reusing(config, revision, policy, defaults, None)
|
||||
}
|
||||
/// Rebuild route policies and retain compatible backend clients, health observations,
|
||||
/// full-exchange counters and affinity tokens through unrelated config edits.
|
||||
pub fn compile_reusing(
|
||||
config: Config,
|
||||
revision: u64,
|
||||
@@ -154,8 +145,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
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() {
|
||||
@@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
}
|
||||
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() {
|
||||
|
||||
+109
-45
@@ -10,9 +10,6 @@ pub fn compile_with_active(
|
||||
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()
|
||||
@@ -48,29 +45,53 @@ pub fn compile_with_active(
|
||||
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)");
|
||||
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");
|
||||
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)");
|
||||
ensure!(
|
||||
config.web_services.len() <= 128,
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
let mut web_services = HashMap::new();
|
||||
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
|
||||
// workers. Different directive sets on one socket can leak to the next vhost.
|
||||
// Report unsafe legacy configurations but keep them loadable for migration.
|
||||
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = 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))?;
|
||||
// A profile changes effective runtime settings, not the saved standalone
|
||||
// PHP configuration. Detaching a profile restores local settings exactly.
|
||||
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))?;
|
||||
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;
|
||||
@@ -82,13 +103,25 @@ pub fn compile_with_active(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
php_endpoint_policies.insert(php.endpoint.clone(), (
|
||||
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
|
||||
));
|
||||
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");
|
||||
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();
|
||||
@@ -202,8 +235,6 @@ pub fn compile_with_active(
|
||||
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([
|
||||
@@ -264,22 +295,29 @@ pub fn compile_with_active(
|
||||
"duplicate ACL profile id"
|
||||
);
|
||||
}
|
||||
// Prevent unbounded DNS ACL work across the entire published configuration.
|
||||
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) {
|
||||
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
|
||||
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) {
|
||||
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
|
||||
assignment.service_key,
|
||||
agent.id,
|
||||
profile_id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -392,7 +430,9 @@ fn validate_route_rewrite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
fn validate_location_redirect(
|
||||
redirect: Option<&crate::vhost::LocationRedirect>,
|
||||
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
let Some(redirect) = redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
|
||||
&& !redirect.target.chars().any(char::is_control),
|
||||
"invalid location redirect target"
|
||||
);
|
||||
// Replace capture references with a harmless segment so URL structure can
|
||||
// be validated before requests reach the data plane.
|
||||
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 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 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");
|
||||
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")?)
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
impl Snapshot {
|
||||
/// Resolve a vhost-local internal named location. Internal locations are
|
||||
/// deliberately excluded from ordinary URI matching and can only be reached
|
||||
/// by an explicit internal dispatch such as an error fallback.
|
||||
pub fn select_internal_on(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -25,21 +22,31 @@ impl Snapshot {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| {
|
||||
routes.iter().any(|route| {
|
||||
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
if !owns_host {
|
||||
return None;
|
||||
}
|
||||
// Some(None) stops at the owning hostname even when the named
|
||||
// location is absent. Do not try a less-specific wildcard.
|
||||
Some(self.routes.get(pattern).and_then(|routes| {
|
||||
routes.iter().find(|route| {
|
||||
route.internal
|
||||
&& route.name == target
|
||||
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
&& route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -36,8 +36,6 @@ pub struct Config {
|
||||
pub geoip_updates: crate::geoip::UpdateSettings,
|
||||
#[serde(default)]
|
||||
pub listeners: crate::settings::Listeners,
|
||||
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
|
||||
/// persist an explicit `binding.http2_settings` object.
|
||||
#[serde(default)]
|
||||
pub http2: crate::settings::Http2Connections,
|
||||
#[serde(default)]
|
||||
@@ -112,20 +110,14 @@ pub struct Backend {
|
||||
pub address_allowlist: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tls_ca_pem: Option<String>,
|
||||
/// Optional DNS name used for TLS SNI and certificate hostname verification.
|
||||
/// TCP/DNS connection targeting remains pinned to `url`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tls_server_name: Option<String>,
|
||||
/// Verify the HTTPS backend certificate chain and server identity.
|
||||
/// Disabled by default for reverse-proxy compatibility; enable explicitly when required.
|
||||
#[serde(default)]
|
||||
pub tls_verify: bool,
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub allow_private: bool,
|
||||
/// Explicitly allow a backend that resolves to a local RustProxy listener.
|
||||
/// This can expose the control plane or create a proxy loop and is off by default.
|
||||
#[serde(default)]
|
||||
pub accept_local_listener_risk: bool,
|
||||
#[serde(default)]
|
||||
@@ -136,12 +128,8 @@ pub struct Backend {
|
||||
pub enum UpstreamProtocol {
|
||||
#[default]
|
||||
Http1,
|
||||
/// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge.
|
||||
/// Retain its 0.32.2 meaning when loading unchanged saved configurations.
|
||||
Http2,
|
||||
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
|
||||
Auto,
|
||||
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
|
||||
H2,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -159,7 +147,6 @@ pub struct Update {
|
||||
pub struct ValidationPolicy {
|
||||
pub tls_addr: Option<SocketAddr>,
|
||||
pub cert_dir: Option<PathBuf>,
|
||||
/// Resolved runtime data directory. Exposed only through authenticated admin status.
|
||||
pub data_dir: Option<PathBuf>,
|
||||
pub proxy_addr: SocketAddr,
|
||||
pub admin_addr: SocketAddr,
|
||||
@@ -170,21 +157,14 @@ pub struct CompiledBackend {
|
||||
pub client: crate::upstream::ProxyClient,
|
||||
pub websocket_client: crate::upstream::ProxyClient,
|
||||
pub id: String,
|
||||
/// Configured network origin used by raw TCP probes and policy checks.
|
||||
pub origin: String,
|
||||
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
|
||||
pub request_origin: String,
|
||||
/// Parsed once at configuration publish; reused for every upstream request.
|
||||
pub request_origin_uri: http::Uri,
|
||||
pub host_header: HeaderValue,
|
||||
pub preserve_host: bool,
|
||||
pub protocol: UpstreamProtocol,
|
||||
/// Whether backend TLS certificate chain/name verification is enabled.
|
||||
/// Retained in the compiled snapshot for failure diagnostics only.
|
||||
pub tls_verify: bool,
|
||||
pub accept_local_listener_risk: bool,
|
||||
/// True only when this backend URL names the configured administration socket
|
||||
/// directly. Used by the guarded control-plane reverse-proxy path.
|
||||
pub targets_admin_listener: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -208,7 +188,6 @@ pub struct CompiledRoute {
|
||||
pub frontend: Option<Arc<CompiledFrontend>>,
|
||||
pub pool: Option<Arc<crate::balance::Pool>>,
|
||||
pub cache: Option<bool>,
|
||||
/// Vhost-local stable location ID (`main` for the main source).
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub internal: bool,
|
||||
@@ -237,10 +216,8 @@ pub struct CompiledFrontend {
|
||||
pub websocket_rfc8441: bool,
|
||||
pub websocket_rfc9220: bool,
|
||||
pub force_ssl: bool,
|
||||
/// Optional quick www/non-www canonical redirect (from, to).
|
||||
pub www_redirect: Option<(String, String)>,
|
||||
pub forward_client_ip: bool,
|
||||
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
|
||||
pub https_redirect_port: Option<u16>,
|
||||
pub http2_enabled: bool,
|
||||
pub http3_enabled: bool,
|
||||
@@ -264,7 +241,6 @@ pub struct CompiledFrontend {
|
||||
pub acl_profile_id: Option<String>,
|
||||
pub profile_acl: Option<CompiledAcl>,
|
||||
pub profile_country_access: crate::rate_limit::CountryAccess,
|
||||
/// All Vhost profile scopes, checked before the inline ACL.
|
||||
pub profile_policies: Vec<crate::acl::ProfileAccess>,
|
||||
pub acl: Option<CompiledAcl>,
|
||||
pub headers: CompiledHeaders,
|
||||
@@ -295,7 +271,6 @@ pub struct Snapshot {
|
||||
pub tls: Option<Arc<rustls::ServerConfig>>,
|
||||
pub envelope: Envelope,
|
||||
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
/// Main vhost backend fallback, kept separate from user Custom locations.
|
||||
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
@@ -305,7 +280,6 @@ pub struct Snapshot {
|
||||
}
|
||||
impl std::fmt::Debug for Snapshot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// QUIC crypto has no Debug implementation; never format private keys.
|
||||
f.debug_struct("Snapshot")
|
||||
.field("revision", &self.envelope.revision)
|
||||
.field("listeners", &self.listeners)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
mod route_policy_tests {
|
||||
use crate::{
|
||||
acl::{routing_path, Acl, Action, Rule as AclRule},
|
||||
config::{compile, demo_config, ValidationPolicy},
|
||||
headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule},
|
||||
vhost::{LocationResponse, PathRoute},
|
||||
};
|
||||
use http::HeaderMap;
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_route() -> PathRoute {
|
||||
PathRoute {
|
||||
id: "local-status".into(),
|
||||
path_prefix: "/status".into(),
|
||||
response: Some(LocationResponse {
|
||||
status: 200,
|
||||
content_type: "text/plain; charset=utf-8".into(),
|
||||
body: "ok".into(),
|
||||
}),
|
||||
..PathRoute::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_slashes_route_normally_but_reach_upstream_unchanged() {
|
||||
let raw_target = "/api//v1/items";
|
||||
let routing_target = routing_path(raw_target).unwrap();
|
||||
assert_eq!(routing_target, "/api/v1/items");
|
||||
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].routes = vec![PathRoute {
|
||||
id: "api".into(),
|
||||
path_prefix: "/api".into(),
|
||||
..PathRoute::default()
|
||||
}];
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", &routing_target).unwrap();
|
||||
assert_eq!(selected.id, "demo/api");
|
||||
|
||||
assert_eq!(raw_target, "/api//v1/items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_location_response_uses_response_rules_without_opening_backend() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut route = local_route();
|
||||
route.headers = HeaderRules {
|
||||
request: vec![],
|
||||
response: vec![HeaderRule {
|
||||
op: Operation::Set,
|
||||
name: "x-local-response".into(),
|
||||
value: Some("yes".into()),
|
||||
}],
|
||||
};
|
||||
config.vhosts[0].routes = vec![route];
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/status").unwrap();
|
||||
let response = selected.response.as_ref().expect("route must remain local");
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, "ok");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_headers(&mut headers, &selected.headers.response);
|
||||
assert_eq!(headers.get("x-local-response").unwrap(), "yes");
|
||||
|
||||
assert!(selected.response.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_location_response_still_respects_route_acl() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut route = local_route();
|
||||
route.acl = Some(Acl {
|
||||
default: Action::Deny,
|
||||
rules: vec![AclRule {
|
||||
network: "10.0.0.0/8".into(),
|
||||
action: Action::Allow,
|
||||
}],
|
||||
});
|
||||
config.vhosts[0].routes = vec![route];
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/status").unwrap();
|
||||
let acl = selected.acl.as_ref().expect("route ACL must be compiled");
|
||||
assert!(acl.allows("10.1.2.3".parse().unwrap()));
|
||||
assert!(!acl.allows("192.0.2.1".parse().unwrap()));
|
||||
assert!(selected.response.is_some());
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ fn compile_routing(
|
||||
|| 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 &routing_hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
@@ -91,8 +90,6 @@ fn compile_routing(
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
// Backend definitions are irrelevant to a Web Server main source unless
|
||||
// an enabled Custom Location explicitly uses Reverse Proxy.
|
||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||
|| host.routes.iter().any(|route| {
|
||||
route.enabled
|
||||
@@ -119,8 +116,6 @@ fn compile_routing(
|
||||
None
|
||||
} else {
|
||||
let mut pool_settings = host.load_balancing.clone();
|
||||
// Blank health-check Host means the first exact DNS name of the vhost.
|
||||
// This is important when the backend URL is an IP shared by several vhosts.
|
||||
if pool_settings.health_check.host.is_none()
|
||||
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|
||||
|| host.backends.iter().any(|member| {
|
||||
@@ -369,7 +364,6 @@ fn compile_routing(
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) =
|
||||
if route.response.is_some() || route.redirect.is_some() {
|
||||
// Local return/redirect is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
match &route.source {
|
||||
|
||||
+21
-36
@@ -1,13 +1,8 @@
|
||||
impl Snapshot {
|
||||
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
|
||||
fn find_host_pattern<T>(
|
||||
host: &str,
|
||||
mut lookup: impl FnMut(&str) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
|
||||
if let Some(value) = lookup(host) {
|
||||
return Some(value);
|
||||
}
|
||||
// Skip the final dot: only one label follows it.
|
||||
let last_dot = host.rfind('.')?;
|
||||
let mut wildcard = [0u8; 256];
|
||||
wildcard[..2].copy_from_slice(b"*.");
|
||||
@@ -25,7 +20,6 @@ impl Snapshot {
|
||||
return Some(value);
|
||||
}
|
||||
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
|
||||
// Preserve behavior for unusual overlong internal host inputs.
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +34,6 @@ impl Snapshot {
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
/// RFC 8441 is connection-scoped; requests still enforce vhost opt-in.
|
||||
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
frontend.websockets
|
||||
@@ -51,7 +44,6 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// RFC 9220 is connection-scoped; requests still enforce vhost opt-in.
|
||||
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
frontend.websockets
|
||||
@@ -62,8 +54,6 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Production routing always uses the TCP connection's local endpoint.
|
||||
/// Exact hostnames win over the longest matching wildcard hostname.
|
||||
pub fn select_on(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -73,7 +63,6 @@ impl Snapshot {
|
||||
) -> Option<&CompiledRoute> {
|
||||
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
|
||||
}
|
||||
/// Shared compiled-route handle for middleware and forward().
|
||||
pub fn select_on_arc(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -87,7 +76,6 @@ impl Snapshot {
|
||||
.iter()
|
||||
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
})?;
|
||||
// The claimed vhost owns its fallback ahead of broader wildcards.
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| {
|
||||
@@ -112,7 +100,6 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Resolve protocol-error attribution with the routing precedence and bindings.
|
||||
pub fn resolve_logging_vhost(
|
||||
&self,
|
||||
local: SocketAddr,
|
||||
@@ -136,11 +123,7 @@ impl Snapshot {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (candidate, matched_by) in [
|
||||
(authority, "authority"),
|
||||
(host, "host"),
|
||||
(sni, "sni"),
|
||||
] {
|
||||
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
|
||||
let Some(candidate) = candidate else {
|
||||
continue;
|
||||
};
|
||||
@@ -156,7 +139,6 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Select redirects using accepted socket, TLS state and normalized path.
|
||||
pub fn redirect_on(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -189,7 +171,6 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Select a host-owned local 404/static response using the accepted socket.
|
||||
pub fn not_found_host_on(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -209,10 +190,12 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Legacy inspection helper only; network handlers must call select_on.
|
||||
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
|
||||
let fallback = self
|
||||
.fallbacks
|
||||
.get(pattern)
|
||||
.and_then(|routes| routes.first())?;
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
@@ -269,17 +252,25 @@ where
|
||||
prefix
|
||||
}
|
||||
impl CompiledRoute {
|
||||
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
|
||||
pub fn redirect_location(
|
||||
&self,
|
||||
path: &str,
|
||||
uri: &http::Uri,
|
||||
) -> Result<Option<http::HeaderValue>> {
|
||||
let Some(spec) = &self.redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target = if let Some(regex) = &spec.rewrite_regex {
|
||||
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
|
||||
ensure!(
|
||||
regex.is_match(path),
|
||||
"location redirect rewrite regex did not match"
|
||||
);
|
||||
regex.replace(path, spec.target.as_str()).into_owned()
|
||||
} else {
|
||||
spec.target.clone()
|
||||
};
|
||||
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
|
||||
let mut target =
|
||||
url::Url::parse(&target).context("invalid compiled location redirect target")?;
|
||||
if spec.preserve_query {
|
||||
if let Some(query) = uri.query() {
|
||||
let merged = match target.query() {
|
||||
@@ -289,10 +280,11 @@ impl CompiledRoute {
|
||||
target.set_query(Some(&merged));
|
||||
}
|
||||
}
|
||||
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
|
||||
Ok(Some(
|
||||
http::HeaderValue::from_str(target.as_str())
|
||||
.context("invalid redirect Location header")?,
|
||||
))
|
||||
}
|
||||
/// Return a normalized replacement path for the upstream hop. `None` means
|
||||
/// preserve the client's exact origin-form path bytes.
|
||||
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
|
||||
use crate::vhost::RewriteMode;
|
||||
let rewritten = match self.rewrite.mode {
|
||||
@@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool {
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4.
|
||||
pub fn normalize_host(value: &str) -> Result<String> {
|
||||
let host = value
|
||||
.strip_suffix('.')
|
||||
@@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result<String> {
|
||||
);
|
||||
Ok(host)
|
||||
}
|
||||
/// Configured frontend patterns may use a single leading `*.` wildcard.
|
||||
/// Runtime Host values remain exact and are normalized by `normalize_host`.
|
||||
pub fn normalize_host_pattern(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if let Some(base) = value.strip_prefix("*.") {
|
||||
@@ -395,13 +384,10 @@ pub fn normalize_host_pattern(value: &str) -> Result<String> {
|
||||
pub fn request_host(value: &str) -> Result<String> {
|
||||
ensure!(!value.contains('@'), "invalid Host");
|
||||
let authority: Authority = value.parse().context("invalid Host authority")?;
|
||||
// Authority::port() returns None for both an absent and an invalid port.
|
||||
// Inspect the explicit suffix so an out-of-range port cannot bypass validation.
|
||||
let raw_host = authority.host();
|
||||
let suffix = &authority.as_str()[raw_host.len()..];
|
||||
if !suffix.is_empty() {
|
||||
let port = suffix.strip_prefix(':').context("invalid Host port")?;
|
||||
// HTTP permits an empty port suffix, which selects the default port.
|
||||
if !port.is_empty() {
|
||||
let number: u16 = port.parse().context("invalid Host port")?;
|
||||
ensure!(number > 0, "invalid Host port");
|
||||
@@ -461,7 +447,6 @@ pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
|
||||
!(s[0] == 0x2001 && s[1] == 0x0db8),
|
||||
"documentation address is not a backend"
|
||||
);
|
||||
// Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges.
|
||||
let ula = s[0] & 0xfe00 == 0xfc00;
|
||||
let global =
|
||||
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
|
||||
|
||||
+1138
-1063
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
mod transport_tests {
|
||||
use crate::config::{compile, demo_config, ValidationPolicy};
|
||||
|
||||
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 local_listener_backend_requires_explicit_risk_acceptance() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let backend = &mut config.vhosts[0].backends[0];
|
||||
backend.url = "http://127.0.0.1:9090".into();
|
||||
backend.allow_private = true;
|
||||
backend.accept_local_listener_risk = false;
|
||||
|
||||
let error = compile(config.clone(), 1, &policy())
|
||||
.expect_err("local administration listener must be rejected by default");
|
||||
let detail = format!("{error:#}");
|
||||
assert!(
|
||||
detail.contains("accept_local_listener_risk=true"),
|
||||
"{detail}"
|
||||
);
|
||||
|
||||
config.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
compile(config, 2, &policy())
|
||||
.expect("explicit risk acceptance should permit the local listener target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].websockets = true;
|
||||
config.vhosts[0].websocket_rfc8441 = true;
|
||||
config.vhosts[0].http2_enabled = true;
|
||||
|
||||
let error = compile(config.clone(), 10, &policy())
|
||||
.expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener");
|
||||
assert!(error.to_string().contains("HTTP/2-capable listener"));
|
||||
|
||||
config.listeners.endpoints[0].binding.h2c = true;
|
||||
let snapshot = compile(config, 11, &policy())
|
||||
.expect("explicit h2c prior-knowledge listener should satisfy RFC 8441");
|
||||
assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user