v0.31.5
This commit is contained in:
1 parent
75104f87ba
commit
4cc994b3bc
86 files changed
+942
-223
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user