diff --git a/changelog.md b/changelog.md index 26fcf56..e74df6a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-19 - 25.16.2 - fix(rustproxy-http) +cache backend Alt-Svc only from original upstream responses during protocol auto-detection + +- Moves Alt-Svc discovery into streaming response construction so it reads backend headers before response filters inject client-facing Alt-Svc values +- Stores the protocol cache key in connection activity during auto-detect mode and clears it after HTTP/3 connection failure to avoid re-caching failed H3 routes +- Prevents fallback requests from reintroducing stale or self-injected Alt-Svc entries that could cause repeated H3 retry loops + ## 2026-03-19 - 25.16.1 - fix(http-proxy) avoid repeated HTTP/3 recaching after QUIC fallback and document backend protocol selection diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 0554644..c3e5135 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -43,6 +43,10 @@ struct ConnActivity { /// increments on creation and decrements on Drop, keeping the watchdog aware that /// a response body is still streaming after the request handler has returned. active_requests: Option>, + /// Protocol cache key for Alt-Svc discovery. When set, `build_streaming_response` + /// checks the backend's original response headers for Alt-Svc before our + /// ResponseFilter injects its own. None when not in auto-detect mode or after H3 failure. + alt_svc_cache_key: Option, } /// Default upstream connect timeout (30 seconds). @@ -341,7 +345,7 @@ impl HttpProxyService { let cn = cancel_inner.clone(); let la = Arc::clone(&la_inner); let st = start; - let ca = ConnActivity { last_activity: Arc::clone(&la_inner), start, active_requests: Some(Arc::clone(&ar_inner)) }; + let ca = ConnActivity { last_activity: Arc::clone(&la_inner), start, active_requests: Some(Arc::clone(&ar_inner)), alt_svc_cache_key: None }; async move { let result = svc.handle_request(req, peer, port, cn, ca).await; // Mark request end — update activity timestamp before guard drops @@ -418,7 +422,7 @@ impl HttpProxyService { peer_addr: std::net::SocketAddr, port: u16, cancel: CancellationToken, - conn_activity: ConnActivity, + mut conn_activity: ConnActivity, ) -> Result>, hyper::Error> { let host = req.headers() .get("host") @@ -703,9 +707,11 @@ impl HttpProxyService { ProtocolDecision::AlpnProbe => (false, true), }; - // Track whether H3 connect failed — suppresses Alt-Svc re-caching to prevent - // the loop: H3 cached → QUIC timeout → H2/H1 fallback → Alt-Svc re-caches H3 → repeat - let mut h3_connect_failed = false; + // Set Alt-Svc cache key on conn_activity so build_streaming_response can check + // the backend's original Alt-Svc header before ResponseFilter injects our own. + if is_auto_detect_mode { + conn_activity.alt_svc_cache_key = Some(protocol_cache_key.clone()); + } // --- H3 path: try QUIC connection before TCP --- if let ProtocolDecision::H3 { port: h3_port } = protocol_decision { @@ -742,7 +748,9 @@ impl HttpProxyService { Err(e) => { warn!(backend = %upstream_key, error = %e, "H3 backend connect failed, falling back to H2/H1"); - h3_connect_failed = true; + // Suppress Alt-Svc caching for the fallback to prevent re-caching H3 + // from our own injected Alt-Svc header or a stale backend Alt-Svc + conn_activity.alt_svc_cache_key = None; // Force ALPN probe on TCP fallback so we correctly detect H2 vs H1 // (don't cache anything yet — let the ALPN probe decide) if is_auto_detect_mode && upstream.use_tls { @@ -948,22 +956,6 @@ impl HttpProxyService { self.upstream_selector.connection_ended(&upstream_key); self.metrics.backend_connection_closed(&upstream_key); - // --- Alt-Svc discovery: check if backend advertises H3 --- - // Suppress Alt-Svc caching when we just failed an H3 attempt to prevent the loop: - // H3 cached → QUIC timeout → fallback → Alt-Svc re-caches H3 → repeat. - // The ALPN probe already cached H1 or H2; it will expire after 5min TTL, - // at which point we'll re-probe and see Alt-Svc again, retrying QUIC then. - if is_auto_detect_mode && !h3_connect_failed { - if let Ok(ref resp) = result { - if let Some(alt_svc) = resp.headers().get("alt-svc").and_then(|v| v.to_str().ok()) { - if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) { - debug!(backend = %upstream_key, h3_port, "Backend advertises H3 via Alt-Svc"); - self.protocol_cache.insert_h3(protocol_cache_key, h3_port); - } - } - } - } - result } @@ -1762,6 +1754,19 @@ impl HttpProxyService { ) -> Result>, hyper::Error> { let (resp_parts, resp_body) = upstream_response.into_parts(); + // Check for Alt-Svc in the backend's ORIGINAL response headers BEFORE + // ResponseFilter::apply_headers runs — the filter may inject our own Alt-Svc + // for client-facing HTTP/3 advertisement, which must not be confused with + // backend-originated Alt-Svc. + if let Some(ref cache_key) = conn_activity.alt_svc_cache_key { + if let Some(alt_svc) = resp_parts.headers.get("alt-svc").and_then(|v| v.to_str().ok()) { + if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) { + debug!(h3_port, "Backend advertises H3 via Alt-Svc"); + self.protocol_cache.insert_h3(cache_key.clone(), h3_port); + } + } + } + let mut response = Response::builder() .status(resp_parts.status); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 767409a..d3e005d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.16.1', + version: '25.16.2', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }