v0.34.0_7

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-18 07:48:59 +02:00
1 parent 47ce1f18c2
commit b6a3e12225
21 files changed
+471 -84

No files matched your search

+4 -7
View File
@@ -40,24 +40,21 @@ impl Lease {
"ok"
};
let before = self.member.state.healthy();
let failure_detail = if is_5xx {
format!("status={status}")
} else {
reason.into()
};
let failure_detail = is_5xx.then(|| format!("status={status}"));
let detail = failure_detail.as_deref().unwrap_or(reason);
let ejected = self.member.state.observe_passive_with_detail(
&self.health,
&self.passive_health,
reason,
reason,
&failure_detail,
detail,
elapsed,
);
if let Some((first, failures)) = ejected {
tracing::warn!(backend = %self.member.definition.id, origin = %self.member.backend.origin, source = "passive",
reason, first_error_class = first.class,
first_error_at_ms = first.at_ms, first_error_detail = %first.detail,
trigger_error_detail = %failure_detail, failures,
trigger_error_detail = %detail, failures,
"backend excluded from traffic");
}
let after = self.member.state.healthy();
+15
View File
@@ -807,4 +807,19 @@ mod tests {
state.observe_passive(&active, &passive, "ok", Duration::from_millis(2));
assert!(state.healthy());
}
#[test]
fn successful_lease_response_keeps_passive_observability_and_failure_detail() {
let pool = capacity_pool(0, 0);
let selection = select(&pool);
selection.lease.response(200, Duration::from_millis(5), true);
let state = &selection.lease.member.state;
assert_eq!(state.last_status.load(Ordering::Relaxed), 200);
assert_eq!(lock(&state.health).passive_result, "ok");
assert!(lock(&state.health).latency_us > 0);
selection.lease.response(503, Duration::from_millis(5), true);
assert_eq!(state.last_status.load(Ordering::Relaxed), 503);
assert_eq!(lock(&state.last_failure).detail, "status=503");
}
} // mod tests
+13
View File
@@ -56,6 +56,19 @@ mod tests {
assert!(request_headers(&HeaderMap::new()).is_empty());
}
#[test]
fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() {
let global = Settings::default();
let force_on = Overrides { enabled: Some(true), ..Default::default() };
for offer in ["identity", "gzip;q=0, br;q=0, zstd;q=0", "deflate", "*;q=0"] {
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", offer.parse().unwrap());
assert!(preferred_encoding(&headers, &global).is_none());
let effective = force_on.resolve(&global, &global);
assert!(preferred_encoding(&headers, &effective).is_none());
}
}
#[test]
fn scoped_codec_overrides_keep_global_codecs_authoritative() {
let global = Settings::default();
+26 -2
View File
@@ -214,6 +214,22 @@ impl Compiled {
tls: bool,
authority: &HeaderValue,
uri: &Uri,
) -> Result<Context> {
self.context_with_original_url(incoming, peer, local, tls, authority, uri, true)
}
/// The absolute original URL is only consumed by the optional Tinyauth
/// verifier. Ordinary proxy requests need the exact forwarding metadata,
/// but do not need to allocate this extra diagnostic URL.
pub fn context_with_original_url(
&self,
incoming: &HeaderMap,
peer: SocketAddr,
local: SocketAddr,
tls: bool,
authority: &HeaderValue,
uri: &Uri,
include_original_url: bool,
) -> Result<Context> {
let peer_ip = canonical(peer.ip());
let trusted_peer = self.trusted(peer_ip);
@@ -222,7 +238,11 @@ impl Compiled {
let authority_text = authority.to_str()?;
crate::config::request_host(authority_text)?;
let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = format!("{scheme}://{authority_text}{target}");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{target}")
} else {
String::new()
};
let peer_text = peer_ip.to_string();
let peer_header = HeaderValue::from_str(&peer_text)?;
let peer_node = if peer_ip.is_ipv4() { None } else { Some(node(peer_ip)) };
@@ -269,7 +289,11 @@ impl Compiled {
// Validate before inserting authority into Forwarded's quoted host field.
crate::config::request_host(authority_text)?;
let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = format!("{scheme}://{authority_text}{original_target}");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{original_target}")
} else {
String::new()
};
let mut forwarded: Vec<_> = ips.iter().map(|ip| format!("for={}", node(*ip))).collect();
let last = forwarded.last_mut().expect("actual peer is always present");
last.push_str(&format!(
+35
View File
@@ -229,4 +229,39 @@ mod tests {
assert_eq!(context.values["x-forwarded-port"], "8080");
assert_eq!(context.values["x-forwarded-uri"], "/s?q=1");
}
#[test]
fn original_url_is_omitted_only_when_unneeded_and_forwarding_is_identical() {
for (trusted, peer, local) in [
(false, "192.0.2.9:5000", "10.0.0.1:8080"),
(true, "10.0.0.4:5000", "10.0.0.1:8080"),
(false, "[2001:db8::a]:5010", "[2001:db8::b]:8080"),
] {
let settings = Settings {
trusted_proxies: if trusted { vec!["10.0.0.0/8".into()] } else { vec![] },
trust_forwarded_proto: true,
}.compile().unwrap();
let mut incoming = HeaderMap::new();
incoming.insert("x-forwarded-for", "192.0.2.15".parse().unwrap());
incoming.insert("x-forwarded-proto", "http".parse().unwrap());
let peer: SocketAddr = peer.parse().unwrap();
let local: SocketAddr = local.parse().unwrap();
let host: HeaderValue = "app.example:8443".parse().unwrap();
let uri: Uri = "/route?q=hello".parse().unwrap();
let full = settings.context(&incoming, peer, local, true, &host, &uri).unwrap();
let compact = settings.context_with_original_url(
&incoming, peer, local, true, &host, &uri, false,
).unwrap();
assert!(!full.original_url.is_empty());
assert!(compact.original_url.is_empty());
assert_eq!(full.client_ip, compact.client_ip);
assert_eq!(full.scheme, compact.scheme);
assert_eq!(full.values, compact.values);
let mut full_headers = incoming.clone();
let mut compact_headers = incoming.clone();
full.apply(&mut full_headers);
compact.apply(&mut compact_headers);
assert_eq!(full_headers, compact_headers);
}
}
}
+2 -1
View File
@@ -193,13 +193,14 @@ async fn forward_request(
}
}
}
let mut context = match snapshot.forwarding.context(
let mut context = match snapshot.forwarding.context_with_original_url(
request.headers(),
peer.0,
local.0,
tls,
&original_host,
request.uri(),
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
) {
Ok(context) => context,
Err(_) => {
+9 -10
View File
@@ -1,14 +1,13 @@
fn request_id_hash(sequence: u64) -> String {
use sha2::{Digest, Sha256};
// Thread-local CSPRNG is seeded (and periodically reseeded) from the OS.
// Do not make a getrandom syscall on the hot path for every HTTP request.
// Keep hashing the per-process sequence as before for the same ID format.
let nonce: [u8; 32] = rand::random();
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
request_id_hex(&hash.finalize())
// Same thread-local CSPRNG as before, now with no per-request SHA-256.
// XORing an increasing sequence into independent uniformly random bytes
// preserves the full 256-bit unpredictability and 64-hex-character shape.
// The sequence is still mixed in; this is not a predictable counter ID.
let mut token: [u8; 32] = rand::random();
for (byte, sequence_byte) in token[..8].iter_mut().zip(sequence.to_be_bytes()) {
*byte ^= sequence_byte;
}
request_id_hex(&token)
}
fn request_id_hex(digest: &[u8]) -> String {
+9
View File
@@ -30,6 +30,15 @@ mod request_id_tests {
}
}
#[test]
fn independent_sequential_ids_keep_full_length_and_unique_values() {
let ids: std::collections::HashSet<_> = (0..4096u64)
.map(request_id_hash)
.collect();
assert_eq!(ids.len(), 4096);
assert!(ids.iter().all(|id| id.len() == 64));
}
#[test]
#[ignore = "explicit release-mode microbenchmark; not an acceptance threshold"]
fn benchmark_request_id_formatting() {
+19 -11
View File
@@ -312,21 +312,29 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
response.headers_mut().remove("alt-svc");
}
let global_compression = &snapshot.envelope.config.compression;
let mut compression = global_compression.clone();
if let Some(frontend) = frontend_context.as_ref() {
compression = frontend
.compression
.resolve(global_compression, &compression);
}
if let Some(route) = effective_route_context {
if let crate::config::CompiledSource::WebServer(service) = &route.source {
compression = service
// All scoped policies have the global codec ceiling. With no accepted
// global encoding (e.g. Accept-Encoding: identity), encode is a no-op:
// avoid even constructing/resolving the per-request settings.
if method != Method::HEAD
&& crate::compression::preferred_encoding(&original_headers, global_compression).is_some()
{
let mut compression = global_compression.clone();
if let Some(frontend) = frontend_context.as_ref() {
compression = frontend
.compression
.resolve(global_compression, &compression);
}
if let Some(route) = effective_route_context {
if let crate::config::CompiledSource::WebServer(service) = &route.source {
compression = service
.compression
.resolve(global_compression, &compression);
}
}
response = crate::compression::encode(
response, &original_headers, &method, &compression,
).await;
}
response =
crate::compression::encode(response, &original_headers, &method, &compression).await;
if let Some(encoding) = response
.extensions()
.get::<crate::compression::AppliedEncoding>()
+45 -20
View File
@@ -122,7 +122,16 @@ impl Counters {
self.methods.add(&sample.method, 1);
self.method_families.add(method_family(&sample.method), 1);
self.sources.add(sample.source, 1);
self.statuses.add(&format!("{}xx", sample.status / 100), 1);
// Standard HTTP status families are static; the unusual 6xx-9xx
// extension codes still keep their exact original dashboard label.
match sample.status / 100 {
1 => self.statuses.add("1xx", 1),
2 => self.statuses.add("2xx", 1),
3 => self.statuses.add("3xx", 1),
4 => self.statuses.add("4xx", 1),
5 => self.statuses.add("5xx", 1),
family => self.statuses.add(&format!("{family}xx"), 1),
}
self.routes.add(sample.route.as_deref().unwrap_or("(main)"), 1);
self.paths.add(&sample.path, 1);
self.cache.add(sample.cache, 1);
@@ -225,16 +234,27 @@ impl Analytics {
let (tx, mut rx) = mpsc::channel::<Sample>(QUEUE_CAPACITY);
let worker_state = state.clone();
tokio::spawn(async move {
// One bounded receiver and one ordered aggregator as before.
// Batch up to 64 ready messages under one dashboard-state lock;
// never hold the lock while waiting for new messages.
let mut batch = Vec::with_capacity(64);
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
sample = rx.recv() => {
let Some(sample) = sample else { break; };
count = rx.recv_many(&mut batch, 64) => {
if count == 0 { break; }
let mut state = worker_state.lock().unwrap_or_else(|e| e.into_inner());
if !state.vhosts.contains_key(&sample.vhost) && state.vhosts.len() >= MAX_VHOSTS {
continue;
for sample in batch.drain(..) {
if let Some(counters) = state.vhosts.get_mut(sample.vhost.as_str()) {
counters.record(sample);
} else if state.vhosts.len() < MAX_VHOSTS {
// Allocate the map key only for a previously unseen vhost.
let name = sample.vhost.clone();
let mut counters = Counters::default();
counters.record(sample);
state.vhosts.insert(name, counters);
}
}
state.vhosts.entry(sample.vhost.clone()).or_default().record(sample);
}
}
}
@@ -268,7 +288,9 @@ impl Analytics {
}
total.view()
} else {
state.vhosts.get(selected).cloned().unwrap_or_default().view()
// No need to copy a dozen bounded top lists just to render one
// vhost's read-only dashboard view while holding the state lock.
state.vhosts.get(selected).map(Counters::view).unwrap_or_else(|| Counters::default().view())
};
View {
scope: selected.to_owned(),
@@ -320,30 +342,33 @@ fn method_family(method: &str) -> &'static str {
}
fn user_agent_family(value: &str) -> &'static str {
let lower = value.to_ascii_lowercase();
if lower.contains("bot") || lower.contains("spider") || lower.contains("crawler") || lower.contains("slurp") {
// ASCII-insensitive classification without copying and lowercasing a
// potentially 512-character User-Agent for every sampled request.
let has = |needle: &str| value.as_bytes().windows(needle.len())
.any(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes()));
if has("bot") || has("spider") || has("crawler") || has("slurp") {
"bot"
} else if lower.contains("curl/") {
} else if has("curl/") {
"curl"
} else if lower.contains("wget/") {
} else if has("wget/") {
"wget"
} else if lower.contains("python-requests") || lower.contains("python/") || lower.contains("aiohttp/") {
} else if has("python-requests") || has("python/") || has("aiohttp/") {
"python"
} else if lower.contains("go-http-client") {
} else if has("go-http-client") {
"go"
} else if lower.contains("java/") || lower.contains("okhttp/") {
} else if has("java/") || has("okhttp/") {
"java"
} else if lower.contains("postmanruntime/") {
} else if has("postmanruntime/") {
"postman"
} else if lower.contains("edg/") || lower.contains("edge/") {
} else if has("edg/") || has("edge/") {
"edge"
} else if lower.contains("opr/") || lower.contains("opera") {
} else if has("opr/") || has("opera") {
"opera"
} else if lower.contains("chrome/") || lower.contains("crios/") {
} else if has("chrome/") || has("crios/") {
"chrome"
} else if lower.contains("firefox/") || lower.contains("fxios/") {
} else if has("firefox/") || has("fxios/") {
"firefox"
} else if lower.contains("safari/") {
} else if has("safari/") {
"safari"
} else {
"other"
+64
View File
@@ -75,4 +75,68 @@ mod tests {
assert_eq!(user_agent_family("curl/8.12.1"), "curl");
assert_eq!(user_agent_family("ExampleBot/1.0"), "bot");
}
#[test]
fn status_top_list_preserves_common_and_extension_family_names() {
let mut counters = Counters::default();
for status in [100, 200, 302, 404, 503, 701] {
counters.record(Sample {
vhost: "test".into(), route: None, method: "GET".into(),
path: "/".into(), client_ip: None, status,
duration_ms: 1, request_bytes: 0, response_bytes: 1,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
});
}
let labels: std::collections::HashSet<_> = counters.view().statuses.into_iter()
.map(|entry| entry.value).collect();
for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] {
assert!(labels.contains(label));
}
}
#[test]
fn user_agent_matching_is_ascii_case_insensitive_even_with_unicode() {
for (agent, expected) in [
("MOZILLA/5.0 ChRoMe/120.0", "chrome"),
("żółw / uSER-BOT", "bot"),
("CURL/8.0", "curl"),
("wGeT/1.24", "wget"),
("Ąęć PYTHON-Requests", "python"),
("Mozilla/5.0 EdG/110", "edge"),
("Random ∂x/42", "other"),
] {
assert_eq!(user_agent_family(agent), expected);
}
}
#[tokio::test]
async fn batched_worker_keeps_exact_counts_and_bounded_vhosts() {
let shutdown = CancellationToken::new();
let analytics = Analytics::start(shutdown.clone());
for id in 0..130 {
analytics.push(Sample {
vhost: if id % 2 == 0 { "left".into() } else { "right".into() },
route: None, method: "GET".into(), path: "/small".into(),
client_ip: None, status: 200, duration_ms: 1,
request_bytes: 0, response_bytes: 1040,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
});
}
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while analytics.view(Some("left")).data.requests != 65
|| analytics.view(Some("right")).data.requests != 65
{
tokio::task::yield_now().await;
}
}).await.expect("analytics should drain two batches");
let all = analytics.view(None).data;
assert_eq!(all.requests, 130);
assert_eq!(all.response_bytes, 130 * 1040);
assert_eq!(analytics.dropped.load(Ordering::Relaxed), 0);
assert_eq!(analytics.reset(Some("left")), 1);
assert_eq!(analytics.view(None).data.requests, 65);
shutdown.cancel();
}
}