From 929854898fd5fdd61d60457df17b5b36a61ccce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 18 Oct 2026 10:38:06 +0200 Subject: [PATCH] v0.34.0_12 --- SHA256SUMS | 26 ++--- src/config/snapshot.rs | 167 +++++++++++++-------------- src/config/tests.rs | 49 ++++++++ src/forwarding.rs | 21 ++-- src/metrics/global.rs | 12 +- src/metrics/vhost.rs | 2 +- src/metrics/vhost/tests.rs | 14 --- src/proxy/cache_helpers.rs | 16 --- src/proxy/forward/cache_lookup.rs | 5 +- src/proxy/forward/success_finish.rs | 8 +- src/proxy/forward/success_prepare.rs | 8 +- src/proxy/tests/authority_tests.rs | 28 ----- src/rate_limit.rs | 19 ++- src/rate_limit/tests.rs | 28 ----- src/user_agent.rs | 47 ++------ src/user_agent/tests.rs | 21 ---- 16 files changed, 186 insertions(+), 285 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 9e928b5..511fc09 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -326,7 +326,7 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs 4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs 5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs -2ac87cc55b1eaeb32e8abd3b1f891321bdd20bcc134cf2ea7dbe9154f4799597 src/forwarding.rs +61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs 9c712ed59cfca15b79c3339a031c8903c83468c9d3b5e29f7e59d9a222b97561 src/forwarding/peer_formatting.rs 0259def26930308047a248548ff911f64638b43bdfe7f765ae8c82a87a6aab21 src/forwarding/tests.rs af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs @@ -359,9 +359,9 @@ fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs 49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs -068436f6c5a10d5e6bd1b69299bf99690bfd70da2a9fe5fd0de57c32b982bd22 src/metrics/global.rs -f8a9e1eb9466ca71bd70259248cd96354507d568408f5ec07cd60ce33ba15393 src/metrics/vhost.rs -c24bbdf13406112b6494ae68748bee63077767ecd528f663f32996e5ebd2b407 src/metrics/vhost/tests.rs +201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs +78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs +e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs @@ -376,16 +376,16 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs 2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs -1c0ba20c9c3327d76468d3e70f82c8e53659bce166a119de82020a9f8631dc2f src/proxy/cache_helpers.rs +7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e src/proxy/cache_helpers.rs aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs 676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs -7ee9ee1663d739a7361d97806b4cdbaf07fc0aab13c70a2c4a9804cff76cfb9c src/proxy/forward/cache_lookup.rs +f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs 44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs -39332a8691946191a10b3061128d699ba768bad54045bdbc93266b6bce16b327 src/proxy/forward/success_finish.rs -269082bb95d796decbdf71c1ce8c7cd08ef5efd0d6c6375ae57c90fd48595d25 src/proxy/forward/success_prepare.rs +691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs +0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs 3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs 3b9f27445c637dbf093b07a5b4e77cbff4df2bab2ad622bb87b6772dab245ec6 src/proxy/forward_entry.rs @@ -397,7 +397,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs -de104d9aba11c155e8d72edbb4f37af193c08dc20fc4c4c956b3ffc323cf927a src/proxy/tests/authority_tests.rs +94a50c0bf147565098e06672df3625e44d5af34ef1f12fd355d82cc7a72cf033 src/proxy/tests/authority_tests.rs 1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs @@ -410,8 +410,8 @@ d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/webs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs 15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs -5dc44629340189e8a255992d33bbd78bbb6f2fdaf8a5e0c5bc3175d22c053c7d src/rate_limit.rs -57c879f573ebd10329ba64e2a4ae3776a400fe835a2bc417a157e4c41be514a8 src/rate_limit/tests.rs +545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs +e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs @@ -479,8 +479,8 @@ ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/c 56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs 75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs 8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs -392f03936e176e2ba9a25edb2a273d09474e2c6801ed6fba8eed5bf1a62bd970 src/user_agent.rs -09ef411f04daa61bdcbcc395c5ba144a755f77a0402abfab636475fe6691b5ee src/user_agent/tests.rs +a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs +6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs 3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs 6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs 63e9fc7f7be78dc3e16a89f015a56a2da6ba3aff0197b70ff48321dfb17529bb src/vhost/model.rs diff --git a/src/config/snapshot.rs b/src/config/snapshot.rs index b955796..f212807 100644 --- a/src/config/snapshot.rs +++ b/src/config/snapshot.rs @@ -1,24 +1,46 @@ impl Snapshot { - // Exact host: no allocation. Wildcards: lazy and most-specific-first. - fn host_patterns(host: &str) -> impl Iterator> { - std::iter::once(std::borrow::Cow::Borrowed(host)).chain( - host.match_indices('.').filter_map(move |(index, _)| { - let suffix = &host[index + 1..]; - suffix.contains('.').then(|| std::borrow::Cow::Owned(format!("*.{suffix}"))) - }), - ) - } - pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool { - for pattern in Self::host_patterns(host) { - if let Some(acl) = self.host_acls.get(pattern.as_ref()) { - return acl.allows(peer); + // Exact-first lookups with stack-buffered wildcard keys for normal hosts. + fn find_host_pattern( + host: &str, + mut lookup: impl FnMut(&str) -> Option, + ) -> Option { + 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"*."); + for (index, _) in host.match_indices('.') { + if index == last_dot { + break; + } + let suffix = &host[index + 1..]; + let len = suffix.len() + 2; + if len <= wildcard.len() { + wildcard[2..len].copy_from_slice(suffix.as_bytes()); + let key = std::str::from_utf8(&wildcard[..len]) + .expect("a wildcard plus a UTF-8 host is valid UTF-8"); + if let Some(value) = lookup(key) { + return Some(value); + } + } else if let Some(value) = lookup(&format!("*.{suffix}")) { + // Preserve behavior for unusual overlong internal host inputs. + return Some(value); } } - true + None } - /// RFC 8441 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is - /// advertised only when at least one explicitly opted-in vhost is bound to - /// this HTTP/2 listener. Request routing still enforces the per-vhost opt-in. + pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool { + if self.host_acls.is_empty() { + return true; + } + Self::find_host_pattern(host, |pattern| { + self.host_acls.get(pattern).map(|acl| acl.allows(peer)) + }) + .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 @@ -29,10 +51,7 @@ impl Snapshot { }) }) } - - /// RFC 9220 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is - /// advertised only when at least one explicitly opted-in vhost is bound to - /// this QUIC listener. Request routing still enforces the per-vhost opt-in. + /// 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 @@ -54,7 +73,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, @@ -63,28 +81,22 @@ impl Snapshot { local: SocketAddr, tls: bool, ) -> Option<&Arc> { - for pattern in Self::host_patterns(host) { - let fallback = self.fallbacks.get(pattern.as_ref()).and_then(|routes| { + Self::find_host_pattern(host, |pattern| { + let fallback = self.fallbacks.get(pattern).and_then(|routes| { routes .iter() .find(|route| route.bindings.iter().any(|l| l.matches(local, tls))) - }); - // A claimed exact/wildcard vhost owns the request as a unit. First try - // its Custom locations, then its main backend fallback; never continue - // into a broader wildcard merely because no Custom location matched. - if let Some(fallback) = fallback { - return self - .routes - .get(pattern.as_ref()) - .and_then(|routes| { - select_route(routes, path, |r| { - r.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| { + select_route(routes, path, |r| { + r.bindings.iter().any(|l| l.matches(local, tls)) }) - .or(Some(fallback)); - } - } - None + }) + .or(Some(fallback)) + }) } pub fn frontend( &self, @@ -92,21 +104,15 @@ impl Snapshot { local: SocketAddr, tls: bool, ) -> Option<&Arc> { - for pattern in Self::host_patterns(host) { - if let Some(frontend) = self.frontends.get(pattern.as_ref()).and_then(|frontends| { + Self::find_host_pattern(host, |pattern| { + self.frontends.get(pattern).and_then(|frontends| { frontends .iter() .find(|f| f.bindings.iter().any(|l| l.matches(local, tls))) - }) { - return Some(frontend); - } - } - None + }) + }) } - - /// Resolve a persistent protocol-error record to a configured vhost using - /// the same exact/wildcard precedence and listener binding rules as normal routing. - /// Client-provided Host/SNI values are never used as file names directly. + /// Resolve protocol-error attribution with the routing precedence and bindings. pub fn resolve_logging_vhost( &self, local: SocketAddr, @@ -130,7 +136,6 @@ impl Snapshot { }); } } - for (candidate, matched_by) in [ (authority, "authority"), (host, "host"), @@ -151,8 +156,7 @@ impl Snapshot { } None } - /// Select a redirect host/rule using the accepted socket, TLS state and normalized path. - /// Exact hostnames win over the longest matching wildcard, matching vhost behavior. + /// Select redirects using accepted socket, TLS state and normalized path. pub fn redirect_on( &self, host: &str, @@ -163,8 +167,11 @@ impl Snapshot { &Arc, &crate::redirect::CompiledRule, )> { - for pattern in Self::host_patterns(host) { - if let Some((redirect, rule)) = self.redirects.get(pattern.as_ref()).and_then(|redirects| { + if self.redirects.is_empty() { + return None; + } + Self::find_host_pattern(host, |pattern| { + self.redirects.get(pattern).and_then(|redirects| { redirects.iter().find_map(|redirect| { if !redirect .bindings @@ -179,11 +186,8 @@ impl Snapshot { .find(|rule| rule.matches(path, tls)) .map(|rule| (redirect, rule)) }) - }) { - return Some((redirect, rule)); - } - } - None + }) + }) } /// Select a host-owned local 404/static response using the accepted socket. pub fn not_found_host_on( @@ -192,40 +196,31 @@ impl Snapshot { local: SocketAddr, tls: bool, ) -> Option<&Arc> { - for pattern in Self::host_patterns(host) { - if let Some(item) = self.not_found_hosts.get(pattern.as_ref()).and_then(|items| { + if self.not_found_hosts.is_empty() { + return None; + } + Self::find_host_pattern(host, |pattern| { + self.not_found_hosts.get(pattern).and_then(|items| { items.iter().find(|item| { item.bindings .iter() .any(|binding| binding.matches(local, tls)) }) - }) { - return Some(item); - } - } - None + }) + }) } - /// Legacy inspection helper only; network handlers must call select_on. pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { - for pattern in Self::host_patterns(host) { - if let Some(fallback) = self - .fallbacks - .get(pattern.as_ref()) - .and_then(|routes| routes.first()) - { - return self - .routes - .get(pattern.as_ref()) - .and_then(|routes| select_route(routes, path, |_| true)) - .or(Some(fallback)) - .map(Arc::as_ref); - } - } - None + Self::find_host_pattern(host, |pattern| { + let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?; + self.routes + .get(pattern) + .and_then(|routes| select_route(routes, path, |_| true)) + .or(Some(fallback)) + .map(Arc::as_ref) + }) } } - fn select_route<'a, F>( routes: &'a [Arc], path: &str, @@ -273,7 +268,6 @@ where } prefix } - impl CompiledRoute { pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result> { let Some(spec) = &self.redirect else { @@ -297,7 +291,6 @@ impl CompiledRoute { } 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> { diff --git a/src/config/tests.rs b/src/config/tests.rs index e2c4057..f2aeea8 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -953,4 +953,53 @@ fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() { let err = compile(config, 1, &policy()).unwrap_err().to_string(); assert!(err.contains("duplicate PHP-FPM profile"), "{err}"); } + +#[test] +fn stack_host_lookup_keeps_exact_and_most_specific_wildcard_order() { + let mut checked = Vec::new(); + let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| { + checked.push(pattern.to_owned()); + (pattern == "*.example.test").then_some("parent") + }); + assert_eq!(found, Some("parent")); + assert_eq!(checked, vec![ + "api.eu.example.test", "*.eu.example.test", "*.example.test", + ]); + + checked.clear(); + let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| { + checked.push(pattern.to_owned()); + (pattern == "*.eu.example.test").then_some("specific") + }); + assert_eq!(found, Some("specific")); + assert_eq!(checked.len(), 2); + + checked.clear(); + let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| { + checked.push(pattern.to_owned()); + (pattern == "api.eu.example.test").then_some("exact") + }); + assert_eq!(found, Some("exact")); + assert_eq!(checked, vec!["api.eu.example.test"]); + + let no_wildcard_for_tld = Snapshot::find_host_pattern("example.test", |pattern| { + (pattern == "*.test").then_some("incorrect") + }); + assert_eq!(no_wildcard_for_tld, None); +} + +#[test] +fn stack_host_lookup_preserves_long_internal_host_fallback() { + // Normalized runtime hosts are <=253 bytes. Direct internal callers with + // longer strings still follow the previous pattern order without panics. + let long = format!("a.{}.sub.example.test", "x".repeat(260)); + let mut patterns = Vec::new(); + let found = Snapshot::find_host_pattern(&long, |pattern| { + patterns.push(pattern.to_owned()); + (pattern == "*.sub.example.test").then_some(true) + }); + assert_eq!(found, Some(true)); + assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260))); + assert_eq!(patterns[2], "*.sub.example.test"); +} } diff --git a/src/forwarding.rs b/src/forwarding.rs index f9c44d0..4cdafae 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -100,7 +100,7 @@ fn cloudflare_peer(peer: SocketAddr) -> bool { /// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's /// published edge ranges. The header is otherwise untrusted user input. pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option { - if !headers.contains_key("cf-connecting-ip") || !cloudflare_peer(peer) { + if !cloudflare_peer(peer) { return None; } let mut values = headers.get_all("cf-connecting-ip").iter(); @@ -120,7 +120,7 @@ pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option Option { - if !headers.contains_key("cf-ipcountry") || !cloudflare_peer(peer) { + if !cloudflare_peer(peer) { return None; } let mut values = headers.get_all("cf-ipcountry").iter(); @@ -319,18 +319,11 @@ impl Compiled { values, }); } - let mut proxy_chain = chain(incoming).unwrap_or_default(); - // No forwarded hops: borrow the actual peer from the stack instead of - // allocating a one-element Vec on every trusted-proxy request. - let direct_peer = [peer_ip]; - let ips: &[IpAddr] = if proxy_chain.is_empty() { - &direct_peer - } else { - proxy_chain.push(peer_ip); - // Discard untrusted upstream prefixes without shifting the Vec. - let first = proxy_chain.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0); - &proxy_chain[first..] - }; + let mut ips = chain(incoming).unwrap_or_default(); + ips.push(peer_ip); + // Discard everything left of the first untrusted hop walking from us. + let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0); + ips.drain(..first); let client_ip = ips[0]; let mut scheme = if tls { "https" } else { "http" }; if trusted_peer diff --git a/src/metrics/global.rs b/src/metrics/global.rs index c1b48dd..b855e32 100644 --- a/src/metrics/global.rs +++ b/src/metrics/global.rs @@ -1,15 +1,5 @@ use serde::Serialize; use std::sync::atomic::{AtomicU64, Ordering}; -/// Isolate a high-contention counter without spreading every request-level -/// atomic across a separate cache line. Existing read/write API is unchanged. -#[repr(align(64))] -#[derive(Default, Debug)] -pub struct CacheAlignedAtomicU64(AtomicU64); -impl std::ops::Deref for CacheAlignedAtomicU64 { - type Target = AtomicU64; - fn deref(&self) -> &Self::Target { &self.0 } -} - #[derive(Default)] pub struct Metrics { pub admin_requests: AtomicU64, @@ -45,7 +35,7 @@ pub struct Metrics { pub websocket_bytes_in: AtomicU64, pub websocket_bytes_out: AtomicU64, pub websocket_duration_us: AtomicU64, - pub requests: CacheAlignedAtomicU64, + pub requests: AtomicU64, pub completed: AtomicU64, pub active: AtomicU64, pub errors: AtomicU64, diff --git a/src/metrics/vhost.rs b/src/metrics/vhost.rs index 7bfb9af..27cde63 100644 --- a/src/metrics/vhost.rs +++ b/src/metrics/vhost.rs @@ -8,7 +8,7 @@ pub struct VhostMetrics { pub bytes_in: AtomicU64, pub bytes_out: AtomicU64, pub duration_us: AtomicU64, - pub ewma_duration_us: CacheAlignedAtomicU64, + pub ewma_duration_us: AtomicU64, pub status: [AtomicU64; 5], pub buckets: [AtomicU64; 11], } diff --git a/src/metrics/vhost/tests.rs b/src/metrics/vhost/tests.rs index 026d7fb..cd059f4 100644 --- a/src/metrics/vhost/tests.rs +++ b/src/metrics/vhost/tests.rs @@ -63,18 +63,4 @@ mod compression_counter_tests { assert_eq!(view.compression_zstd, 1); assert_eq!(view.compression_deflate, 1); } - #[test] - fn cache_aligned_counters_keep_exact_values_and_separate_hot_lines() { - let m = VhostMetrics::default(); - let ewma = &m.ewma_duration_us as *const _ as usize; - assert_eq!(ewma % 64, 0); - m.active.fetch_add(1, Ordering::Relaxed); - m.requests.fetch_add(1, Ordering::Relaxed); - m.complete(200, false, std::time::Duration::from_micros(100), 20, 1040); - assert_eq!(n(&m.completed), 1); - assert_eq!(n(&m.active), 0); - assert_eq!(n(&m.requests), 1); - assert_eq!(n(&m.bytes_out), 1040); - assert_eq!(n(&m.ewma_duration_us), 100); - } } diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index 3943a40..bec01c1 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -282,22 +282,6 @@ fn finish_headers( crate::http3::advertise(response.headers_mut(), local, tls, active); } } -fn attach_origin_diagnostics( - response: &mut Response, - frontend: Option<&crate::config::CompiledFrontend>, - elapsed: Duration, - backend_id: &str, -) { - if let Some(frontend) = frontend { - if frontend.timing_headers || frontend.backend_timing_header { - response.extensions_mut().insert(OriginTiming(elapsed)); - } - if frontend.backend_header { - response.extensions_mut().insert(OriginBackend(backend_id.to_owned())); - } - } -} - fn apply_timing_headers( response: &mut Response, frontend: Option<&crate::config::CompiledFrontend>, diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index 01b72ed..ca3a3bc 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -179,7 +179,10 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str hit.resident_age_seconds, ); hit.response.extensions_mut().insert(ResponseRulesApplied); - attach_origin_diagnostics(&mut hit.response, route.frontend.as_deref(), elapsed, origin_backend); + hit.response.extensions_mut().insert(OriginTiming(elapsed)); + hit.response + .extensions_mut() + .insert(OriginBackend(origin_backend.to_owned())); state.cache.record(scope, &host, "HIT", reason); if let Some(exchange) = &exchange { exchange.cache_status.store(1, Ordering::Relaxed); diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs index db67b88..880d8fd 100644 --- a/src/proxy/forward/success_finish.rs +++ b/src/proxy/forward/success_finish.rs @@ -73,8 +73,12 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { revalidated = crate::cache::make_not_modified(revalidated); } revalidated.extensions_mut().insert(ResponseRulesApplied); - attach_origin_diagnostics(&mut revalidated, route.frontend.as_deref(), - upstream_started.elapsed(), &active_backend.id); + revalidated + .extensions_mut() + .insert(OriginTiming(upstream_started.elapsed())); + revalidated + .extensions_mut() + .insert(OriginBackend(active_backend.id.clone())); if let Some(exchange) = &exchange { exchange.cache_status.store(1, Ordering::Relaxed); *exchange diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs index 717e379..0fceaab 100644 --- a/src/proxy/forward/success_prepare.rs +++ b/src/proxy/forward/success_prepare.rs @@ -366,8 +366,12 @@ if intercept_origin_error { ); } response.extensions_mut().insert(ResponseRulesApplied); -attach_origin_diagnostics(&mut response, route.frontend.as_deref(), - upstream_started.elapsed(), &active_backend.id); +response + .extensions_mut() + .insert(OriginTiming(upstream_started.elapsed())); +response + .extensions_mut() + .insert(OriginBackend(active_backend.id.clone())); if !range_requested && handshake.is_none() { if let Some(settings) = transform_settings.filter(|settings| settings.enabled) { let transform_started = Instant::now(); diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/tests/authority_tests.rs index f7ab671..7bf531f 100644 --- a/src/proxy/tests/authority_tests.rs +++ b/src/proxy/tests/authority_tests.rs @@ -409,34 +409,6 @@ mod authority_tests { assert!(std::str::from_utf8(detail.as_bytes()).is_ok()); } - #[test] - fn origin_diagnostics_are_created_only_for_enabled_frontend_features() { - let mut response = Response::new(Body::empty()); - attach_origin_diagnostics(&mut response, None, Duration::from_millis(5), "origin-a"); - assert!(response.extensions().get::().is_none()); - assert!(response.extensions().get::().is_none()); - for (timing, backend, backend_timing) in [ - (false, false, false), - (true, false, false), - (false, true, false), - (false, false, true), - (true, true, true), - ] { - let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].timing_headers = timing; - config.vhosts[0].backend_header = backend; - config.vhosts[0].backend_timing_header = backend_timing; - let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); - let route = snapshot.select("localhost", "/").unwrap(); - let mut response = Response::new(Body::empty()); - attach_origin_diagnostics(&mut response, route.frontend.as_deref(), - Duration::from_millis(5), "origin-a"); - assert_eq!(response.extensions().get::().is_some(), - timing || backend_timing); - assert_eq!(response.extensions().get::().is_some(), backend); - } - } - #[test] fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() { let mut response = Response::new(Body::empty()); diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 0949824..7c155ca 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -211,18 +211,13 @@ impl Settings { self.country_access.allows(country) } fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option)> { - // With no UA overrides the common request never allocates a lowercase - // copy (and oversized headers still skip matching entirely). - if !self.user_agent_limits.is_empty() { - if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) { - if let Some((index, rule)) = self.user_agent_limits.iter().enumerate() - .find(|(_, rule)| crate::user_agent::wildcard_match_case_insensitive(agent, &rule.pattern)) - { - return Some((Limit { - requests: rule.requests, window_seconds: rule.window_seconds, - burst: rule.burst, - }, Some(index))); - } + // Matching only needs a bounded copy. Oversized headers cannot force + // unbounded work in the hot request path. + if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) { + let candidate = agent.to_ascii_lowercase(); + if let Some((index, rule)) = self.user_agent_limits.iter().enumerate() + .find(|(_, rule)| crate::user_agent::wildcard_match(&candidate, &rule.pattern)) { + return Some((Limit { requests: rule.requests, window_seconds: rule.window_seconds, burst: rule.burst }, Some(index))); } } if let Some(country) = country { diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs index 29e0ab6..0cf8dfc 100644 --- a/src/rate_limit/tests.rs +++ b/src/rate_limit/tests.rs @@ -157,32 +157,4 @@ mod tests { assert!(settings.validate().is_ok()); } - #[test] - fn no_ua_rules_do_not_create_buckets_when_base_limiter_is_off() { - let settings = Settings::default(); - let limiter = Limiter::new(); - let ip = "192.0.2.34".parse().unwrap(); - for _ in 0..32 { - assert!(limiter.check_with_user_agent(1, "site", ip, None, - Some("Mozilla/5.0 (compatible; Requests)"), &settings).is_ok()); - } - assert!(lock(&limiter.state).buckets.is_empty()); - } - - #[test] - fn rate_limit_ua_wildcards_preserve_case_insensitive_quotas() { - let mut settings = Settings { user_agent_limits: vec![UserAgentLimit { - pattern: "*Scanner*Bot".into(), requests: 1, window_seconds: 60, - burst: 0, - }], ..Default::default() }; - settings.validate().unwrap(); - let limiter = Limiter::new(); - let ip = "192.0.2.53".parse().unwrap(); - assert!(limiter.check_with_user_agent(1, "site", ip, None, - Some("ACME-SCANNER-XXbot"), &settings).is_ok()); - assert!(limiter.check_with_user_agent(1, "site", ip, None, - Some("acme-scanner-XXBOT"), &settings).is_err()); - assert!(limiter.check_with_user_agent(1, "site", ip, None, - Some("ordinary-browser"), &settings).is_ok()); - } } diff --git a/src/user_agent.rs b/src/user_agent.rs index f3ee6ab..0c305e1 100644 --- a/src/user_agent.rs +++ b/src/user_agent.rs @@ -76,47 +76,24 @@ pub(crate) fn wildcard_match(candidate: &str, pattern: &str) -> bool { } let starts_wild = pattern.starts_with('*'); let ends_wild = pattern.ends_with('*'); + let parts = pattern + .split('*') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.is_empty() { + return true; + } let mut cursor = 0usize; - let mut found_any = false; - for part in pattern.split('*').filter(|part| !part.is_empty()) { + for (index, part) in parts.iter().enumerate() { let Some(found) = candidate[cursor..].find(part) else { return false; }; - if !found_any && !starts_wild && found != 0 { + if index == 0 && !starts_wild && found != 0 { return false; } - found_any = true; cursor += found + part.len(); } - !found_any || ends_wild || cursor == candidate.len() -} - -/// Match normalized policy patterns against ASCII agents without allocating -/// a lowercased request value. Non-ASCII inputs retain the original matcher. -pub(crate) fn wildcard_match_case_insensitive(candidate: &str, pattern: &str) -> bool { - if !candidate.is_ascii() || !pattern.is_ascii() { - return wildcard_match(&candidate.to_ascii_lowercase(), pattern); - } - let text = candidate.as_bytes(); - let pattern_bytes = pattern.as_bytes(); - if pattern_bytes.is_empty() { return true; } - let find_part = |from: usize, part: &[u8]| -> Option { - text[from..].windows(part.len()).position(|window| window.eq_ignore_ascii_case(part)) - }; - if !pattern_bytes.contains(&b'*') { - return find_part(0, pattern_bytes).is_some(); - } - let starts_wild = pattern_bytes.first() == Some(&b'*'); - let ends_wild = pattern_bytes.last() == Some(&b'*'); - let mut cursor = 0usize; - let mut found_any = false; - for part in pattern_bytes.split(|byte| *byte == b'*').filter(|part| !part.is_empty()) { - let Some(found) = find_part(cursor, part) else { return false; }; - if !found_any && !starts_wild && found != 0 { return false; } - found_any = true; - cursor += found + part.len(); - } - !found_any || ends_wild || cursor == text.len() + ends_wild || cursor == candidate.len() } impl CompiledPolicy { @@ -127,10 +104,10 @@ impl CompiledPolicy { let Some(value) = headers.get(USER_AGENT) else { return false; }; - let candidate = String::from_utf8_lossy(value.as_bytes()); + let candidate = String::from_utf8_lossy(value.as_bytes()).to_ascii_lowercase(); self.patterns .iter() - .any(|pattern| wildcard_match_case_insensitive(&candidate, pattern)) + .any(|pattern| wildcard_match(&candidate, pattern)) } } diff --git a/src/user_agent/tests.rs b/src/user_agent/tests.rs index 9b61460..89840ed 100644 --- a/src/user_agent/tests.rs +++ b/src/user_agent/tests.rs @@ -51,24 +51,3 @@ .unwrap(); assert!(!enabled.blocks(&headers(None))); } - - #[test] - fn allocation_free_ascii_matcher_matches_original_policy() { - for candidate in ["", "Mozilla/5.0", "CURL/8.4.0", "AcmeScanner/1", - "abc-DEF-ghi", "aBcXDeFz", "Content:Token", "żółw TOKEN"] { - for pattern in ["*", "curl/*", "*scanner*", "abc*ghi", "abc*def*", "*def", - "token", "missing", "mozilla", "*x*d*f*", "żółw*"] { - let normalized = pattern.to_ascii_lowercase(); - assert_eq!( - wildcard_match_case_insensitive(candidate, &normalized), - wildcard_match(&candidate.to_ascii_lowercase(), &normalized), - "{candidate:?} vs {pattern:?}", - ); - } - } - let policy = Policy { enabled: true, - patterns: vec!["*bot*".into(), "curl/*".into()] }.compile().unwrap(); - assert!(policy.blocks(&headers(Some("GOOD-BOT/1.0")))); - assert!(policy.blocks(&headers(Some("CURL/9.0")))); - assert!(!policy.blocks(&headers(Some("Mozilla/5.0")))); - }