This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 22:12:27 +02:00
1 parent 75104f87ba
commit 4cc994b3bc
86 files changed
+942 -223

No files matched your search

+1
View File
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
),
reader_doc!("docs/IMPLEMENTATION_0.31.6.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.5.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.5",
"version": "0.31.6",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+9
View File
@@ -463,6 +463,15 @@ function setMobileMenu(open) {
}
mobileMenuToggle?.addEventListener("click", event => { event.stopPropagation(); setMobileMenu(!dashboardSidebar.classList.contains("mobile-nav-open")); });
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => { setPanel(button.dataset.panel); setMobileMenu(false); }));
// The brand is a Home navigation action, not a hard reload of the HTTP/3
// document. Preserve href="/" as the no-JS and modified-click fallback.
document.querySelector('.brand[href="/"]')?.addEventListener("click", event => {
if (!authenticated || event.defaultPrevented || event.button !== 0 ||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
setPanel("overview");
setMobileMenu(false);
});
document.addEventListener("click", event => { if (dashboardSidebar?.classList.contains("mobile-nav-open") && !dashboardSidebar.contains(event.target)) setMobileMenu(false); });
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
+8 -1
View File
@@ -238,8 +238,15 @@
entries.forEach((entry, index) => {
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
row.setAttribute("aria-label", entrySummary(entry));
for (const id of ids) {
const value = columnValue(entry, id), cell = el("td", value); cell.title = value; row.append(cell);
const value = columnValue(entry, id), cell = el("td", value); cell.title = value;
// Mobile uses compact, expandable cards instead of a width-growing
// table. All parsed fields remain accessible in the detail row.
cell.dataset.mobileColumn = id;
const label = columnLabel(id);
cell.dataset.mobileLabel = label;
row.append(cell);
}
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
row.addEventListener("click", select);
+1 -1
View File
@@ -2758,7 +2758,7 @@
<label><span data-i18n="administrationPublicOrigin">Public panel HTTPS origin (optional)</span><input
id="admin-public-origin" maxlength="255" placeholder="https://panel.example.com"
type="url" spellcheck="false" autocomplete="off" /></label>
<p class="fine" data-i18n="administrationPublicOriginHelp">When behind a trusted reverse proxy, enter the panel's external HTTPS address once. Its DNS name is automatically allowed. A restart is required.</p>
<p class="fine" data-i18n="administrationPublicOriginHelp">Pins the browser-facing HTTPS scheme, hostname and optional port for Origin/CSRF checks behind a trusted reverse proxy; it is not only a DNS allowlist. The hostname is automatically allowed. Restart required.</p>
<details id="admin-extra-hosts-details">
<summary data-i18n="administrationHostsAdvanced">Advanced: additional allowed DNS hostnames</summary>
<label><span data-i18n="administrationHosts">Additional allowed DNS hostnames</span><textarea
+198
View File
@@ -10265,3 +10265,201 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
@media (max-width: 800px) {
.file-log-aggregate-form { grid-template-columns: 1fr; }
}
/* Stored Logs on narrow screens: bounded container and a compact record list.
The complete parsed JSON is available by opening any row, while the desktop
table and the persisted column picker keep their original behavior. */
@media (max-width: 700px) {
#panel-filelogs,
#panel-filelogs .file-logs-workspace,
#file-log-view-entries,
#file-log-view-counts,
#panel-filelogs .file-logs-table,
#panel-filelogs .file-logs-table-toolbar,
#panel-filelogs .file-log-field-grid {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
}
#panel-filelogs .file-logs-workspace {
gap: 10px;
padding: 12px;
overflow: visible;
}
#panel-filelogs .card-heading {
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
#panel-filelogs .file-logs-heading-actions {
width: 100%;
justify-content: flex-start;
gap: 4px;
}
#panel-filelogs .file-logs-heading-actions .file-log-consolidate {
margin-inline-end: auto;
white-space: normal;
}
#panel-filelogs .file-log-search-help-popover {
width: min(320px, calc(100vw - 54px));
}
#panel-filelogs .file-logs-filter,
#panel-filelogs .file-log-aggregate-form {
grid-template-columns: minmax(0, 1fr);
}
#panel-filelogs .file-log-limit { max-width: none; }
#panel-filelogs .file-logs-filter label,
#panel-filelogs .file-logs-filter input,
#panel-filelogs .file-logs-filter select,
#panel-filelogs .file-log-aggregate-form label,
#panel-filelogs .file-log-aggregate-form input,
#panel-filelogs .file-log-aggregate-form select {
min-width: 0;
max-width: 100%;
}
#panel-filelogs .file-log-tabs { min-width: 0; }
#panel-filelogs .file-log-tab {
flex: 1 1 0;
min-width: 0;
white-space: normal;
}
#panel-filelogs .file-logs-table-toolbar {
align-items: flex-start;
gap: 5px;
}
#panel-filelogs .file-log-column-picker { align-self: flex-end; }
#panel-filelogs .file-logs-table {
max-height: none;
overflow: visible;
}
#panel-filelogs .file-logs-table table,
#panel-filelogs .file-logs-table tbody {
display: block;
width: 100%;
min-width: 0;
white-space: normal;
}
#panel-filelogs .file-logs-table thead { display: none; }
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 5px 10px;
position: relative;
min-width: 0;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--line);
background: var(--panel);
}
#panel-filelogs .file-logs-table tbody tr:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
#panel-filelogs .file-logs-table tbody tr.selected { border-color: var(--accent); }
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td {
display: block;
min-width: 0;
max-width: none;
padding: 0;
border: 0;
overflow: hidden;
text-overflow: unset;
white-space: normal;
overflow-wrap: anywhere;
}
/* Every user-selected column is available even when the administrator
replaces all default columns with custom parsed fields. */
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
grid-column: 1 / -1;
font-weight: 600;
line-height: 1.45;
padding-inline-end: 16px;
}
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
#panel-filelogs .file-logs-table td[data-mobile-column="event"],
#panel-filelogs .file-logs-table td[data-mobile-column="status"],
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] {
color: var(--muted);
font-size: 11px;
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td::before {
content: attr(data-mobile-label) ": ";
color: var(--text-subtle);
font-weight: 400;
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td[data-mobile-column="record"]::before {
content: "";
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after {
content: "+";
position: absolute;
inset-block-end: 4px;
inset-inline-end: 8px;
color: var(--muted);
font-size: 15px;
pointer-events: none;
}
#panel-filelogs .file-logs-table tbody tr.selected::after { content: "-"; }
#panel-filelogs .file-logs-table .file-log-detail-row {
display: block;
margin: -8px 0 8px;
border: 1px solid var(--accent);
border-top: 0;
background: var(--panel2);
}
#panel-filelogs .file-logs-table .file-log-detail-row td {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
white-space: normal;
}
#panel-filelogs .file-log-field { grid-template-columns: minmax(0, 1fr); }
#panel-filelogs .file-log-inline-detail { min-width: 0; padding: 8px; }
#panel-filelogs .file-log-inline-detail pre { max-width: 100%; overflow-x: auto; }
#panel-filelogs .file-log-aggregate-table {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
#panel-filelogs .file-log-aggregate-table table { table-layout: fixed; }
#panel-filelogs .file-log-aggregate-table td:first-child {
white-space: normal;
overflow-wrap: anywhere;
}
#panel-filelogs .file-logs-pager { justify-content: space-between; }
}
/* Tablet/narrow-desktop log tables scroll locally instead of expanding <main>. */
#panel-filelogs,
#panel-filelogs .file-logs-workspace,
#file-log-view-entries,
#file-log-view-counts {
min-width: 0;
max-width: 100%;
}
#panel-filelogs .file-logs-workspace { grid-template-columns: minmax(0, 1fr); }
#panel-filelogs .file-logs-table,
#panel-filelogs .file-log-aggregate-table {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
@media (max-width: 700px) {
#panel-filelogs .file-logs-table { overflow: visible; }
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { grid-column: 1 / -1; }
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
max-height: 4.4em;
overflow: hidden;
}
#panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] {
max-height: none;
overflow-wrap: anywhere;
}
}
+80 -137
View File
@@ -28,66 +28,7 @@ use tokio::{
task::JoinSet,
};
use tower::ServiceExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub alt_svc_max_age: u64,
pub max_concurrent_connections: u32,
pub max_concurrent_bidistream: u32,
pub max_concurrent_unistream: u32,
/// QUIC idle timeout in seconds; zero disables the transport idle timeout.
pub max_idle_timeout: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
alt_svc_max_age: 3600,
max_concurrent_connections: 512,
max_concurrent_bidistream: 100,
max_concurrent_unistream: 100,
max_idle_timeout: 10,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.alt_svc_max_age <= 604800,
"HTTP/3 Alt-Svc lifetime must be at most seven days"
);
ensure!(
(1..=4096).contains(&self.max_concurrent_connections),
"HTTP/3 connections per listener must be 1..4096"
);
ensure!(
(1..=1024).contains(&self.max_concurrent_bidistream),
"HTTP/3 bidirectional streams must be 1..1024"
);
ensure!((3..=1024).contains(&self.max_concurrent_unistream),"HTTP/3 requires at least three peer unidirectional streams (control and QPACK), maximum 1024");
ensure!(
self.max_idle_timeout <= 86400,
"HTTP/3 idle timeout must be 0..86400 seconds"
);
Ok(())
}
pub fn transport(&self) -> Result<quinn::TransportConfig> {
self.validate()?;
let mut transport = quinn::TransportConfig::default();
transport
.max_concurrent_bidi_streams(self.max_concurrent_bidistream.into())
.max_concurrent_uni_streams(self.max_concurrent_unistream.into())
.max_idle_timeout(if self.max_idle_timeout == 0 {
None
} else {
Some(Duration::from_secs(self.max_idle_timeout).try_into()?)
})
.stream_receive_window((256 * 1024u32).into())
.receive_window((8 * 1024 * 1024u32).into())
.send_window(8 * 1024 * 1024)
.datagram_receive_buffer_size(None);
Ok(transport)
}
}
include!("http3/settings.rs");
pub fn crypto(
tls: &Arc<rustls::ServerConfig>,
policy: &crate::tls::ProtocolPolicy,
@@ -176,77 +117,6 @@ pub fn advertise(
}
}
}
struct Lease {
_permit: OwnedSemaphorePermit,
metrics: Arc<Metrics>,
}
impl Drop for Lease {
fn drop(&mut self) {
self.metrics
.http3_active_connections
.fetch_sub(1, Ordering::Relaxed);
self.metrics
.active_connections
.fetch_sub(1, Ordering::Relaxed);
}
}
#[allow(clippy::too_many_arguments)]
fn log_protocol_error(
state: &App,
kind: &'static str,
detail: impl std::fmt::Display,
peer: SocketAddr,
local: SocketAddr,
status: u16,
authority: Option<String>,
host: Option<String>,
sni: Option<String>,
) {
let resolved = state.config.load().resolve_logging_vhost(
local,
true,
None,
authority.as_deref(),
host.as_deref(),
sni.as_deref(),
);
let (vhost, matched_by) = resolved
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
.unwrap_or((None, "none"));
state.logs.push_protocol_error_attributed(
kind,
detail,
peer,
local,
"quic",
"3",
status,
sni,
authority.or(host),
vhost,
matched_by,
);
}
fn request_identity<B>(request: &http::Request<B>) -> (Option<String>, Option<String>) {
let authority = request
.uri()
.authority()
.map(|value| value.as_str().to_owned());
let host = request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
(authority, host)
}
fn connection_sni(connection: &quinn::Connection) -> Option<String> {
connection
.handshake_data()?
.downcast::<quinn::crypto::rustls::HandshakeData>()
.ok()?
.server_name
.and_then(|name| crate::config::request_host(&name).ok())
}
pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duration) -> Result<()> {
let slots = Arc::new(Semaphore::new(
bound.settings.max_concurrent_connections as usize,
@@ -386,9 +256,19 @@ async fn connection_loop(
let (mut request,stream)=match resolved {
Ok(Ok(request))=>request,
Ok(Err(error))=>{
log_protocol_error(
&state, "http3_request_error", error, peer, local, 400, None, None, tls_sni.clone(),
);
if client_cancelled_request(&error) {
// Record the cancellation with status=0 (no HTTP response)
// for iOS navigation investigations, never synthetic 400.
log_protocol_error(
&state, "http3_peer_cancelled_before_headers",
format!("phase=request_headers stream_status=cancelled error={error}"),
peer, local, 0, None, None, tls_sni.clone(),
);
} else {
log_protocol_error(
&state, "http3_request_error", error, peer, local, 400, None, None, tls_sni.clone(),
);
}
return;
},
Err(_)=>{
@@ -404,6 +284,22 @@ async fn connection_loop(
let extended_connect=request.method()==Method::CONNECT && protocol.is_some();
let websocket=extended_connect && protocol.as_deref()==Some("websocket");
let (mut send,receive)=stream.split();
let stream_id=format!("{:?}",send.send_id());
let diagnostic_method=request.method().as_str().to_owned();
// Do not record query strings or cookies in transport diagnostics.
let diagnostic_path=request.uri().path().chars().take(160).collect::<String>();
let (diagnostic_authority, diagnostic_host)=request_identity(&request);
// GET / is a full document navigation (the original logo).
// Emit only query-free, bounded navigation metadata.
let root_navigation=diagnostic_method=="GET" && diagnostic_path=="/";
if root_navigation { log_root_navigation(
"started", peer, local, &tls_sni, &stream_id, "request_headers", 0, 0, false,
); }
tracing::debug!(
peer=%peer, stream_id=%stream_id,
method=%diagnostic_method, path=%diagnostic_path,
"HTTP/3 request headers accepted"
);
if invalid_headers(request.headers()) {
let (authority, host) = request_identity(&request);
log_protocol_error(
@@ -437,8 +333,12 @@ async fn connection_loop(
crate::proxy::headers::strip_hop_by_hop(&mut parts.headers);
parts.version=Version::HTTP_3;
let response=http::Response::from_parts(parts,());
let response_status=response.status().as_u16();
let mut response_phase="response_headers";
let mut sent_body_bytes=0u64;
let sent=async {
tokio::time::timeout(state.body_idle_timeout,send.send_response(response)).await??;
response_phase="response_body";
if let Some(tunnel)=tunnel {
let Some(mut receive)=tunnel_receive.take() else {
send.stop_stream(Code::H3_INTERNAL_ERROR);
@@ -462,21 +362,62 @@ async fn connection_loop(
while let Some(frame)=body.frame().await {
let frame=frame?;
match frame.into_data() {
Ok(data)=>{if !data.is_empty() {tokio::time::timeout(state.body_idle_timeout,send.send_data(data)).await??;}},
Ok(data)=>{if !data.is_empty() {
let data_len=data.len() as u64;
tokio::time::timeout(state.body_idle_timeout,send.send_data(data)).await??;
sent_body_bytes=sent_body_bytes.saturating_add(data_len);
}},
Err(frame)=>{if let Ok(mut trailers)=frame.into_trailers() {
crate::proxy::headers::strip_hop_by_hop(&mut trailers);
tokio::time::timeout(state.body_idle_timeout,send.send_trailers(trailers)).await??;
}},
}
}
response_phase="response_finish";
tokio::time::timeout(state.body_idle_timeout,send.finish()).await??;
Ok::<(),anyhow::Error>(())
}.await;
if let Err(error)=sent {tracing::debug!(%error,"HTTP/3 response interrupted");send.stop_stream(Code::H3_INTERNAL_ERROR);}
if let Err(error)=sent {
let cancelled=error.downcast_ref::<h3::error::StreamError>()
.is_some_and(client_cancelled_request);
let kind=if cancelled { "http3_peer_cancelled_after_headers" }
else { "http3_response_write_error" };
log_protocol_error(
&state, kind,
format!("phase={response_phase} stream_id={stream_id} method={diagnostic_method} path={diagnostic_path} response_status={response_status} body_bytes_sent={sent_body_bytes} error={error}"),
peer, local, 0,
diagnostic_authority, diagnostic_host, tls_sni.clone(),
);
if root_navigation { log_root_navigation(
"interrupted", peer, local, &tls_sni, &stream_id, response_phase,
response_status, sent_body_bytes, cancelled,
); }
if !cancelled {
// Never mark a truncated upstream response as success.
// Reset only this response stream, not sibling requests.
send.stop_stream(Code::H3_INTERNAL_ERROR);
}
} else if root_navigation {
log_root_navigation("complete", peer, local, &tls_sni, &stream_id,
"response_finish", response_status, sent_body_bytes, false);
} else {
log_h3_response_complete(peer, &stream_id, response_status, sent_body_bytes);
}
});
},
Ok(None)=>break,
Err(error)=>{requests.abort_all();return Err(error.into());},
Err(error)=>{
// The parser/QUIC transport can stop accepting after a peer
// refresh while unrelated response streams are still alive.
// Abort_all here reset those streams (including a new GET /).
// Stop accepting and allow the bounded drain below instead.
log_protocol_error(
&state, "http3_accept_error", &error,
connection.remote_address(), local, 0, None, None, tls_sni.clone(),
);
tracing::debug!(%error, "HTTP/3 accept stopped; draining in-flight streams");
break;
},
}
}
}
@@ -492,6 +433,8 @@ async fn connection_loop(
}
Ok(())
}
include!("http3/identity.rs");
include!("http3/diagnostics.rs");
include!("http3/body.rs");
#[cfg(test)]
+3 -3
View File
@@ -110,8 +110,8 @@ impl HttpBody for ReceiveBody {
}
impl Drop for ReceiveBody {
fn drop(&mut self) {
// Dropping the request stream cancels the receive side at QUIC level.
// Explicit STOP_SENDING is used only from poll_frame paths where the
// stream handle is available and a protocol error was detected.
// Do not call stop_sending here: h3/quinn can still have a pending
// receive read and has previously panicked on that cancellation path.
// Protocol framing violations explicitly stop only from poll_frame.
}
}
+91
View File
@@ -0,0 +1,91 @@
struct Lease {
_permit: OwnedSemaphorePermit,
metrics: Arc<Metrics>,
}
impl Drop for Lease {
fn drop(&mut self) {
self.metrics
.http3_active_connections
.fetch_sub(1, Ordering::Relaxed);
self.metrics
.active_connections
.fetch_sub(1, Ordering::Relaxed);
}
}
#[allow(clippy::too_many_arguments)]
fn log_protocol_error(
state: &App,
kind: &'static str,
detail: impl std::fmt::Display,
peer: SocketAddr,
local: SocketAddr,
status: u16,
authority: Option<String>,
host: Option<String>,
sni: Option<String>,
) {
let resolved = state.config.load().resolve_logging_vhost(
local,
true,
None,
authority.as_deref(),
host.as_deref(),
sni.as_deref(),
);
let (vhost, matched_by) = resolved
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
.unwrap_or((None, "none"));
state.logs.push_protocol_error_attributed(
kind,
detail,
peer,
local,
"quic",
"3",
status,
sni,
authority.or(host),
vhost,
matched_by,
);
}
/// Service-level breadcrumbs for a full HTTP/3 GET / navigation. User-provided
/// query strings, headers and error details must not reach this diagnostic.
#[allow(clippy::too_many_arguments)]
fn log_root_navigation(
stage: &'static str,
peer: SocketAddr,
local: SocketAddr,
sni: &Option<String>,
stream_id: &str,
phase: &'static str,
response_status: u16,
body_bytes_sent: u64,
cancelled: bool,
) {
match stage {
"started" => tracing::info!(
peer=%peer, local=%local, sni=?sni, stream_id=%stream_id,
method="GET", path="/", "http3_navigation_started"
),
"complete" => tracing::info!(
peer=%peer, local=%local, sni=?sni, stream_id=%stream_id,
method="GET", path="/", response_status, body_bytes_sent,
"http3_navigation_complete"
),
"interrupted" => tracing::warn!(
peer=%peer, local=%local, sni=?sni, stream_id=%stream_id,
method="GET", path="/", phase, response_status, body_bytes_sent,
cancelled, "http3_navigation_interrupted"
),
_ => {}
}
}
fn log_h3_response_complete(peer: SocketAddr, stream_id: &str, response_status: u16, body_bytes_sent: u64) {
tracing::debug!(
peer=%peer, stream_id=%stream_id, response_status, body_bytes_sent,
"HTTP/3 response completed"
);
}
+33
View File
@@ -0,0 +1,33 @@
/// A client can cancel an obsolete in-flight request during navigation/F5.
/// RFC 9114 section 4.1.1 gives this a stream reset code, not an HTTP 400.
/// Treat only peer-originated cancellation as benign; local protocol errors
/// and other remote reset codes remain visible to operators.
fn client_cancelled_code(code: Code) -> bool {
code == Code::H3_REQUEST_CANCELLED || code == Code::H3_NO_ERROR
}
fn client_cancelled_request(error: &h3::error::StreamError) -> bool {
matches!(
error,
h3::error::StreamError::RemoteTerminate { code, .. } if client_cancelled_code(*code)
)
}
fn request_identity<B>(request: &http::Request<B>) -> (Option<String>, Option<String>) {
let authority = request
.uri()
.authority()
.map(|value| value.as_str().to_owned());
let host = request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
(authority, host)
}
fn connection_sni(connection: &quinn::Connection) -> Option<String> {
connection
.handshake_data()?
.downcast::<quinn::crypto::rustls::HandshakeData>()
.ok()?
.server_name
.and_then(|name| crate::config::request_host(&name).ok())
}
+60
View File
@@ -0,0 +1,60 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub alt_svc_max_age: u64,
pub max_concurrent_connections: u32,
pub max_concurrent_bidistream: u32,
pub max_concurrent_unistream: u32,
/// QUIC idle timeout in seconds; zero disables the transport idle timeout.
pub max_idle_timeout: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
alt_svc_max_age: 3600,
max_concurrent_connections: 512,
max_concurrent_bidistream: 100,
max_concurrent_unistream: 100,
max_idle_timeout: 10,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.alt_svc_max_age <= 604800,
"HTTP/3 Alt-Svc lifetime must be at most seven days"
);
ensure!(
(1..=4096).contains(&self.max_concurrent_connections),
"HTTP/3 connections per listener must be 1..4096"
);
ensure!(
(1..=1024).contains(&self.max_concurrent_bidistream),
"HTTP/3 bidirectional streams must be 1..1024"
);
ensure!((3..=1024).contains(&self.max_concurrent_unistream),"HTTP/3 requires at least three peer unidirectional streams (control and QPACK), maximum 1024");
ensure!(
self.max_idle_timeout <= 86400,
"HTTP/3 idle timeout must be 0..86400 seconds"
);
Ok(())
}
pub fn transport(&self) -> Result<quinn::TransportConfig> {
self.validate()?;
let mut transport = quinn::TransportConfig::default();
transport
.max_concurrent_bidi_streams(self.max_concurrent_bidistream.into())
.max_concurrent_uni_streams(self.max_concurrent_unistream.into())
.max_idle_timeout(if self.max_idle_timeout == 0 {
None
} else {
Some(Duration::from_secs(self.max_idle_timeout).try_into()?)
})
.stream_receive_window((256 * 1024u32).into())
.receive_window((8 * 1024 * 1024u32).into())
.send_window(8 * 1024 * 1024)
.datagram_receive_buffer_size(None);
Ok(transport)
}
}
+9
View File
@@ -67,6 +67,15 @@ mod tests {
assert!(!h.contains_key("alt-svc"));
}
#[test]
fn client_cancellation_is_a_stream_event_not_an_http_400() {
// Exercise the production classifier; RemoteTerminate is
// non-exhaustive in hpx-h3 and cannot be constructed by this crate.
assert!(client_cancelled_code(Code::H3_REQUEST_CANCELLED));
assert!(client_cancelled_code(Code::H3_NO_ERROR));
assert!(!client_cancelled_code(Code::H3_MESSAGE_ERROR));
assert!(!client_cancelled_code(Code::H3_INTERNAL_ERROR));
}
#[test]
fn h3_rejects_http1_connection_specific_headers() {
for name in ["connection", "transfer-encoding", "upgrade"] {
let mut h = HeaderMap::new();
+37 -4
View File
@@ -342,8 +342,24 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
return Ok(false);
}
// Multiplexed transports may end a body with an empty DATA frame.
// On H3 document GET/HEAD, waiting without a bound can stall the entire
// cache/404-routing stage before proxy forwarding even starts. A timed
// probe that finds no FIN conservatively bypasses caching; it never drops
// or consumes a nonempty request body.
let bounded_h3_navigation = request.version() == Version::HTTP_3
&& (request.method() == Method::GET || request.method() == Method::HEAD);
for _ in 0..8 {
match request.body_mut().frame().await {
let next = if bounded_h3_navigation {
match tokio::time::timeout(
std::time::Duration::from_millis(35), request.body_mut().frame(),
).await {
Ok(frame) => frame,
Err(_) => return Ok(false),
}
} else {
request.body_mut().frame().await
};
match next {
None => {
*request.body_mut() = Body::empty();
return Ok(true);
@@ -381,8 +397,6 @@ async fn normalize_h3_h1_unknown_body(
let method = request.method();
if request.version() != Version::HTTP_3
|| upstream_version != Version::HTTP_11
|| method == Method::GET
|| method == Method::HEAD
|| method == Method::CONNECT
|| request.headers().contains_key(http::header::CONTENT_LENGTH)
|| request.body().size_hint().exact().is_some()
@@ -393,8 +407,27 @@ async fn normalize_h3_h1_unknown_body(
// Ignore a small number of empty DATA frames while looking for either the
// stream end or the first frame that carries request semantics. The body
// idle timer in MeteredBody still bounds a stalled peer.
// A full page navigation (e.g. clicking the RustProxy logo) is a GET.
// H3 HEADERS can precede FIN on a document GET. Resolve the receive half
// when FIN arrives promptly, before handing the request to an H1 origin.
// Hyper already special-cases GET/HEAD H1 encoding; this probe targets
// receive-stream completion, not a presumed chunked GET on the wire.
// When the peer is slow or sends a real request body, retain streaming and
// do not delay navigation by the general upload/body-idle deadline.
let navigation_method = method == Method::GET || method == Method::HEAD;
for _ in 0..8 {
match request.body_mut().frame().await {
let next = if navigation_method {
match tokio::time::timeout(
std::time::Duration::from_millis(35),
request.body_mut().frame(),
).await {
Ok(frame) => frame,
Err(_) => return Ok(()),
}
} else {
request.body_mut().frame().await
};
match next {
None => {
*request.body_mut() = Body::empty();
return Ok(());
+2
View File
@@ -8,6 +8,8 @@ include!("forward_entry.rs");
#[cfg(test)]
include!("tests/authority_tests.rs");
#[cfg(test)]
include!("tests/h3_navigation_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
+75
View File
@@ -0,0 +1,75 @@
mod h3_navigation_tests {
use super::*;
#[tokio::test]
async fn h3_bodyless_get_and_head_are_normalized_for_http1_navigation() {
use bytes::Bytes;
for method in [Method::GET, Method::HEAD] {
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(method.clone())
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap();
assert!(request.body().is_end_stream(), "{method}");
assert_eq!(request.body().size_hint().exact(), Some(0), "{method}");
}
}
#[tokio::test]
async fn h3_get_with_payload_retains_payload_for_http1_upstream() {
use bytes::Bytes;
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"unexpected-but-valid"))]);
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap();
let body = request.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"unexpected-but-valid"));
}
#[tokio::test]
async fn h3_stalled_cache_probe_bypasses_without_blocking_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
let empty = tokio::time::timeout(
std::time::Duration::from_millis(750), empty_for_cache(&mut request),
).await.expect("bounded cache preflight").unwrap();
assert!(!empty, "unknown body is not cacheable");
assert_eq!(request.body().size_hint().exact(), None);
}
#[tokio::test]
async fn h3_cache_probe_replays_nonempty_get_without_dropping_bytes() {
use bytes::Bytes;
let stream = futures_util::stream::iter([
Ok::<_, std::io::Error>(Bytes::from_static(b"intact")),
]);
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
assert!(!empty_for_cache(&mut request).await.unwrap());
assert_eq!(request.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"intact"));
}
#[tokio::test]
async fn h3_stalled_empty_get_does_not_block_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
tokio::time::timeout(
std::time::Duration::from_millis(750),
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11),
).await.expect("bounded GET probe").unwrap();
assert_eq!(request.body().size_hint().exact(), None);
}
}
+24
View File
@@ -69,6 +69,30 @@ mod cache_revision_tests {
CacheInvalidation::None
));
for label in ["http2", "http3"] {
let mut protocol_only = base.clone();
if label == "http2" {
protocol_only.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled;
} else {
protocol_only.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
}
// The effective configuration really changes, but the cache key
// does not. This must hold for both toggling directions.
assert_ne!(
(protocol_only.vhosts[0].http2_enabled, protocol_only.vhosts[0].http3_enabled),
(base.vhosts[0].http2_enabled, base.vhosts[0].http3_enabled),
"{label} toggle did not change a transport switch"
);
assert_eq!(cache_revision(&protocol_only), identity, "{label} rotated cache");
assert!(matches!(cache_invalidation(&base, &protocol_only), CacheInvalidation::None));
assert!(matches!(cache_invalidation(&protocol_only, &base), CacheInvalidation::None));
}
let mut both_protocols = base.clone();
both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled;
both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
assert_eq!(cache_revision(&both_protocols), identity);
assert!(matches!(cache_invalidation(&base, &both_protocols), CacheInvalidation::None));
let mut vhost_diagnostics = base.clone();
vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true);
assert_eq!(cache_revision(&vhost_diagnostics), identity);
+5
View File
@@ -117,6 +117,11 @@ fn cache_revision(config: &config::Config) -> u64 {
let mut vhosts = config.vhosts.clone();
for vhost in &mut vhosts {
vhost.cache.expose_only_hits = None;
// Frontend ALPN/QUIC switches decide how a client reaches a vhost,
// not which representation the origin returns. Hot and cold entries
// must survive HTTP/2 or HTTP/3 opt-in/out saves.
vhost.http2_enabled = true;
vhost.http3_enabled = true;
}
let encoded = serde_json::to_vec(&(
&cache,