v0.34.0_7
This commit is contained in:
1 parent
47ce1f18c2
commit
b6a3e12225
21 files changed
+471
-84
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user