This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 11:19:55 +02:00
1 parent 8c0de520d7
commit 8f41b39c26
32 files changed
+934 -128

No files matched your search

+84 -4
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.2",
"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. Version 0.34.2 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
"version": "0.34.3",
"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. Version 0.34.3 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
{
@@ -691,8 +691,34 @@
},
"listeners": {
"type": "object",
"properties": {},
"additionalProperties": true
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"binding": {
"type": "object",
"properties": {
"http3": {
"description": "HTTP/3/QUIC listener policy. null means disabled.",
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/Http3Settings"
}
]
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}
}
},
"additionalProperties": true,
"description": "Named listener endpoints (binding.http3 uses Http3Settings); settings that change UDP binds/socket buffers need service restart."
},
"http2": {
"description": "Legacy HTTP/2 lifecycle fallback for listeners that do not define binding.http2_settings. Prefer listener-scoped settings in config.listeners.",
@@ -1161,6 +1187,7 @@
"format": "int64"
},
"listeners": {
"description": "Bound and configured HTTP listener endpoints. restart_required excludes hot HTTP/3 Alt-Svc and per-new-connection QUIC settings, but includes socket/port and startup capacity changes.",
"type": "object",
"properties": {},
"additionalProperties": true
@@ -7352,6 +7379,59 @@
"capacity"
],
"additionalProperties": false
},
"Http3Settings": {
"type": "object",
"description": "Per-TLS-listener binding.http3 settings. Alt-Svc lifetime updates immediately; stream/idle limits apply to new QUIC handshakes. UDP buffers are applied to new listener sockets after service restart and can be OS-clamped. 0 disables an explicit socket buffer request.",
"properties": {
"alt_svc_max_age": {
"type": "integer",
"format": "int64",
"minimum": 0,
"maximum": 604800,
"default": 3600
},
"max_concurrent_connections": {
"type": "integer",
"minimum": 1,
"maximum": 4096,
"default": 512
},
"max_concurrent_bidistream": {
"type": "integer",
"minimum": 1,
"maximum": 1024,
"default": 100
},
"max_concurrent_unistream": {
"type": "integer",
"minimum": 3,
"maximum": 1024,
"default": 100
},
"max_idle_timeout": {
"type": "integer",
"format": "int64",
"minimum": 0,
"maximum": 86400,
"default": 10
},
"udp_receive_buffer_bytes": {
"type": "integer",
"minimum": 0,
"maximum": 16777216,
"default": 4194304,
"description": "0 or 65536..16777216 bytes. Applies after restart."
},
"udp_send_buffer_bytes": {
"type": "integer",
"minimum": 0,
"maximum": 16777216,
"default": 4194304,
"description": "0 or 65536..16777216 bytes. Applies after restart."
}
},
"additionalProperties": false
}
}
},
+5
View File
@@ -31,6 +31,7 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
"extra_process_cpu",
"stream_traffic",
"wide_cache_activity",
"cache_outcomes",
"www_status",
"www_requests",
"www_top_services",
@@ -189,6 +190,9 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
assert!(prefs
.dashboard_hidden_widgets
.contains(&"www_status".to_owned()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"cache_outcomes".to_owned()));
assert!(validate_admin_preferences(&prefs).is_ok());
}
@@ -199,6 +203,7 @@ fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() {
prefs.dashboard_hidden_widgets.clear();
reconcile_dashboard_widgets(&mut prefs);
assert!(prefs.dashboard_hidden_widgets.is_empty());
assert!(prefs.dashboard_widgets.contains(&"cache_outcomes".into()));
assert_eq!(prefs.dashboard_widgets, default_admin_dashboard_widgets());
assert!(validate_admin_preferences(&prefs).is_ok());
}
+1 -1
View File
@@ -10,7 +10,7 @@ fn status_payload(state: &App) -> Value {
"active_endpoints":state.active_listeners,
"desired_endpoints":snapshot.listeners,
"named_endpoints":snapshot.envelope.config.listeners.endpoints,
"restart_required":snapshot.listeners != state.active_listeners
"restart_required":crate::http3::listeners_require_restart(&state.active_listeners, &snapshot.listeners)
});
let mut features = serde_json::Map::new();
for name in [
+1 -1
View File
@@ -54,7 +54,7 @@ fn live_status_payload(state: &App) -> Value {
"listeners": {
"active_endpoints": state.active_listeners,
"desired_endpoints": snapshot.listeners,
"restart_required": snapshot.listeners != state.active_listeners
"restart_required": crate::http3::listeners_require_restart(&state.active_listeners, &snapshot.listeners)
},
"metrics": state.metrics.view(),
"web_server_metrics": web_server_metrics_payload(&snapshot),
+37
View File
@@ -174,6 +174,7 @@ $("reload-certificates").addEventListener("click", async () => {
$("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
syncCertificateFileSource();
let cacheMixLastCounters = null, cacheMixLastShares = null, cacheMixLastTotal = 0;
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
const hiddenNetworkInterfaces = (() => {
@@ -330,6 +331,34 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
target.replaceChildren(...children);
}
}
function cacheOutcomeMix(deltas) {
if (!Array.isArray(deltas) || deltas.length !== 4 ||
deltas.some(value => !Number.isFinite(value) || value < 0)) return null;
const [requests, hits, misses, bypasses] = deltas;
const cacheTotal = hits + misses + bypasses;
if (requests <= 0 || cacheTotal > requests) return null;
const counts = [hits, misses, bypasses, requests - cacheTotal];
return { counts, percentages: counts.map(value => value / requests * 100), requests };
}
function renderCacheMix(mix) {
const names = ["hit", "miss", "bypass", "other"];
names.forEach((name, i) => {
const pct = mix ? mix.percentages[i] : 0;
$(`cache-mix-${name}`).style.width = `${pct}%`;
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-";
});
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
$("cache-mix-caption").textContent = mix
? t("cacheMixSample").replace("{count}", fmt(mix.requests))
: t("cacheMixWaiting");
$("cache-mix-bar").setAttribute("aria-label", mix
? t("cacheMixAria").replace("{hit}", fmt(mix.counts[0]))
.replace("{miss}", fmt(mix.counts[1]))
.replace("{bypass}", fmt(mix.counts[2]))
.replace("{other}", fmt(mix.counts[3]))
: t("cacheMixWaiting"));
}
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
$("metric-h3").textContent = fmt(m.http3_requests);
@@ -337,6 +366,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
window.rustProxyExtraOverview?.donut?.("cache-ratio", cacheHits, cacheMisses);
if (appendSample) {
const counters = [m.requests, c.hits, c.misses, c.bypasses].map(value => value == null ? NaN : Number(value));
const delta = rustProxyRateSampling.difference(cacheMixLastCounters, counters,
performance.now(), rustProxyRateSampling.cadence("overview_seconds"));
cacheMixLastCounters = counters.every(Number.isFinite) ? delta.next : null;
cacheMixLastShares = delta.deltas ? cacheOutcomeMix(delta.deltas) : null;
}
renderCacheMix(cacheMixLastShares);
if (systemSample) {
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
$("process-summary").hidden = d.process_threads == null;
+132 -36
View File
@@ -1,43 +1,139 @@
{
"dashboard_widgets": [
"requests", "active", "bandwidth", "errors", "memory", "websocket",
"backend_traffic", "extra_response_latency", "http_protocol", "tls",
"websocket_protocol", "backend_latency", "stream_overview", "stream_traffic",
"stream_sessions", "stream_backend_health", "backend_health", "backend_activity",
"backend_errors", "backend_retries", "http2", "backend_protocols",
"backend_requests", "backend_capacity", "backend_http5xx", "backend_timeouts",
"backend_network", "http3", "backend_queue", "extra_open_connections",
"extra_process_cpu", "extra_process_rss", "extra_process_fds", "backend_rotations",
"extra_websocket_active", "extra_tls_error_rate", "extra_request_error_rate",
"extra_cache_hit_rate", "extra_backend_active", "extra_h2_share",
"extra_cache_memory", "extra_backend_unhealthy", "extra_total_rps",
"extra_active_exchanges", "extra_h1_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_cold_storage", "wide_protocol_rates",
"wide_cpu", "wide_memory", "wide_cache_activity", "wide_connections",
"wide_backend_health", "wide_latency", "cache", "traffic_chart",
"system_chart", "connection_chart", "wide_transform_activity",
"wide_compression_activity", "cache_diagnostics", "extra_cold_queue_memory",
"process_diagnostics", "network_diagnostics", "backend_table",
"www_status", "www_kind", "www_capacity", "www_methods", "www_error_rate",
"www_php_requests", "www_requests", "www_latency", "www_failures",
"www_traffic", "www_top_services", "www_status_trend"
"requests",
"active",
"bandwidth",
"errors",
"memory",
"websocket",
"backend_traffic",
"extra_response_latency",
"http_protocol",
"tls",
"websocket_protocol",
"backend_latency",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_retries",
"http2",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"http3",
"backend_queue",
"extra_open_connections",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"backend_rotations",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_backend_active",
"extra_h2_share",
"extra_cache_memory",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_active_exchanges",
"extra_h1_share",
"extra_h3_share",
"extra_cache_miss_rate",
"extra_cold_storage",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
"wide_cache_activity",
"wide_connections",
"wide_backend_health",
"wide_latency",
"cache",
"cache_outcomes",
"traffic_chart",
"system_chart",
"connection_chart",
"wide_transform_activity",
"wide_compression_activity",
"cache_diagnostics",
"extra_cold_queue_memory",
"process_diagnostics",
"network_diagnostics",
"backend_table",
"www_status",
"www_kind",
"www_capacity",
"www_methods",
"www_error_rate",
"www_php_requests",
"www_requests",
"www_latency",
"www_failures",
"www_traffic",
"www_top_services",
"www_status_trend"
],
"dashboard_hidden_widgets": [
"stream_overview", "stream_backend_health", "tls", "stream_sessions",
"backend_health", "backend_activity", "backend_errors", "backend_protocols",
"backend_requests", "backend_timeouts", "backend_network", "extra_process_cpu",
"extra_process_rss", "extra_process_fds", "extra_websocket_active",
"extra_tls_error_rate", "extra_request_error_rate", "extra_h2_share",
"extra_backend_unhealthy", "extra_total_rps", "extra_h1_share",
"extra_h3_share", "wide_memory", "wide_connections", "wide_backend_health",
"wide_protocol_rates", "extra_active_exchanges", "extra_cache_miss_rate",
"extra_cache_hit_rate", "backend_capacity", "stream_traffic", "backend_http5xx",
"bandwidth", "backend_queue", "wide_cpu", "wide_latency", "www_status",
"www_kind", "www_capacity", "www_methods", "www_error_rate",
"www_php_requests", "www_requests", "www_latency", "www_failures",
"www_traffic", "www_top_services", "www_status_trend",
"extra_cold_queue_memory", "wide_cache_activity", "backend_rotations"
"cache_outcomes",
"stream_overview",
"stream_backend_health",
"tls",
"stream_sessions",
"backend_health",
"backend_activity",
"backend_errors",
"backend_protocols",
"backend_requests",
"backend_timeouts",
"backend_network",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_h2_share",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_h1_share",
"extra_h3_share",
"wide_memory",
"wide_connections",
"wide_backend_health",
"wide_protocol_rates",
"extra_active_exchanges",
"extra_cache_miss_rate",
"extra_cache_hit_rate",
"backend_capacity",
"stream_traffic",
"backend_http5xx",
"bandwidth",
"backend_queue",
"wide_cpu",
"wide_latency",
"www_status",
"www_kind",
"www_capacity",
"www_methods",
"www_error_rate",
"www_php_requests",
"www_requests",
"www_latency",
"www_failures",
"www_traffic",
"www_top_services",
"www_status_trend",
"extra_cold_queue_memory",
"wide_cache_activity",
"backend_rotations"
],
"dashboard_density": "compact",
"overview_samples": 600
}
}
+12 -1
View File
@@ -54,6 +54,17 @@
"default_hidden": false,
"display_type": "value"
},
{
"id": "cache_outcomes",
"label_key": "cacheMixTitle",
"description_key": "cacheMixHelp",
"element_selector": "[data-dashboard-widget=\"cache_outcomes\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "memory",
"label_key": "processMemory",
@@ -857,4 +868,4 @@
"default_hidden": true,
"display_type": "chart"
}
]
]
+7 -4
View File
@@ -2028,10 +2028,11 @@
<form class="edit-form embedded-settings-form" id="http3-form">
<div class="card-heading">
<h3 data-i18n="http3Settings">HTTP/3 / QUIC</h3><span class="pill"
data-i18n="restartLabel">RESTART</span>
data-i18n="http3ApplyPolicy">LIVE / RESTART</span>
</div>
<p class="fine" data-i18n="http3SettingsHelp">HTTP/3 enablement is controlled in
Listeners. Tune advertisement, connection and stream limits here.</p>
Listeners. Tune live advertisement/stream limits and startup UDP socket
buffers here.</p>
<p class="pending-note" hidden="" id="http3-listeners-dirty"
data-i18n="http3ListenersDirty">Save pending listener changes before editing
HTTP/3 settings.</p>
@@ -3053,7 +3054,8 @@
role="tabpanel">
<article class="card edit-form">
<div class="card-heading">
<div><span class="small-label" data-i18n="configStorageLabel">CONFIGURATION SOURCE</span>
<div><span class="small-label" data-i18n="configStorageLabel">CONFIGURATION
SOURCE</span>
<h2 data-i18n="manualConfig">Manual config</h2>
</div>
<div class="manual-config-tools"><button class="quiet" data-i18n="configHistory"
@@ -3062,7 +3064,8 @@
JSON</button></div>
</div>
<div class="manual-config-source" role="status" aria-live="polite">
<strong id="config-storage-mode" data-i18n="configStorageLoading">Checking storage...</strong>
<strong id="config-storage-mode" data-i18n="configStorageLoading">Checking
storage...</strong>
<p id="config-storage-location" class="manual-config-path"></p>
<p id="config-storage-description" class="fine"></p>
<p id="config-storage-history" class="fine"></p>
+21 -5
View File
@@ -1,6 +1,4 @@
"use strict";
// Shared controls for named listeners, HTTP/2/HTTP/3 and the certificate inventory.
// All labels are resolved from discovered language packs.
(() => {
let uploadBusy = false;
const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
@@ -10,7 +8,7 @@
if (type === "select") for (const [value, key] of options.values || []) { const option = label("option", key); option.value = value; input.append(option); }
else if (type !== "textarea") input.type = type;
if (type === "checkbox") input.checked = !!value; else input.value = value ?? "";
for (const key of ["min", "max", "maxLength", "required", "pattern", "rows"]) if (Object.hasOwn(options, key)) input[key] = options[key];
for (const key of ["min", "max", "maxLength", "required", "pattern", "rows", "step"]) if (Object.hasOwn(options, key)) input[key] = options[key];
if (type === "checkbox") wrapper.append(input, label("span", key)); else wrapper.append(label("span", key), input);
parent.append(wrapper); return input;
}
@@ -87,15 +85,33 @@
};
return { read, sync };
}
const h3Defaults = { alt_svc_max_age: 3600, max_concurrent_connections: 512, max_concurrent_bidistream: 100, max_concurrent_unistream: 100, max_idle_timeout: 10 };
const h3Defaults = { alt_svc_max_age: 3600, max_concurrent_connections: 512, max_concurrent_bidistream: 100, max_concurrent_unistream: 100, max_idle_timeout: 10, udp_receive_buffer_bytes: 4194304, udp_send_buffer_bytes: 4194304 };
function http3SettingsControls(parent, binding) {
const box = el("div", undefined, "h3-editor vh-wide"); parent.append(box);
const grid = el("div", undefined, "vh-grid"); box.append(grid); const inputs = {};
for (const [name, min, max] of [["alt_svc_max_age", 0, 604800], ["max_concurrent_connections", 1, 4096], ["max_concurrent_bidistream", 1, 1024], ["max_concurrent_unistream", 3, 1024], ["max_idle_timeout", 0, 86400]]) inputs[name] = field(grid, name, `h3_${name}`, binding.http3?.[name] ?? h3Defaults[name], "number", { min, max, required: true });
for (const direction of ["receive", "send"]) {
const name = `udp_${direction}_buffer_bytes`;
const mib = (binding.http3?.[name] ?? h3Defaults[name]) / 1048576;
inputs[name] = field(grid, name, `h3_udp_${direction}_buffer_mib`, mib, "number", { min: 0, max: 16, step: "any", required: true });
}
box.append(label("p", "h3TuningHelp", "fine"));
const sync = (blocked = false) => { for (const input of Object.values(inputs)) input.disabled = blocked; };
sync(false);
const read = () => { const value = {}; for (const [name, input] of Object.entries(inputs)) { value[name] = Number(input.value); if (!Number.isSafeInteger(value[name])) throw fieldError(input, t("vhJsonInvalid")); } return value; };
const read = () => {
const value = {};
for (const [name, input] of Object.entries(inputs)) {
let number = Number(input.value);
if (!input.value.trim() || !Number.isFinite(number)) throw fieldError(input, t("vhJsonInvalid"));
if (name.startsWith("udp_") && name.endsWith("_buffer_bytes")) {
number = Math.round(number * 1048576);
if (number !== 0 && (number < 65536 || number > 16777216)) throw fieldError(input, t("vhJsonInvalid"));
}
if (!Number.isSafeInteger(number)) throw fieldError(input, t("vhJsonInvalid"));
value[name] = number;
}
return value;
};
return { read, sync };
}
function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) {
@@ -31,6 +31,25 @@
</div>
</div>
</article>
<article class="stat stat-cache-outcomes" data-dashboard-widget="cache_outcomes">
<span data-i18n="cacheMixTitle">Cache outcomes (recent)</span>
<strong id="cache-mix-headline">-</strong>
<div class="cache-mix-bar" id="cache-mix-bar" role="img" aria-label="Recent request cache outcomes">
<span class="cache-mix-hit" id="cache-mix-hit" style="width:0%"></span>
<span class="cache-mix-miss" id="cache-mix-miss" style="width:0%"></span>
<span class="cache-mix-bypass" id="cache-mix-bypass" style="width:0%"></span>
<span class="cache-mix-other" id="cache-mix-other" style="width:0%"></span>
</div>
<small class="cache-mix-legend">
<span class="cache-mix-hit-key"><i></i>HIT <b id="cache-mix-hit-percent">-</b></span>
<span class="cache-mix-miss-key"><i></i>MISS <b id="cache-mix-miss-percent">-</b></span>
<span class="cache-mix-bypass-key"><i></i>BYPASS <b id="cache-mix-bypass-percent">-</b></span>
<span class="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b
id="cache-mix-other-percent">-</b></span>
</small>
<small class="cache-mix-caption" id="cache-mix-caption" data-i18n="cacheMixWaiting">Waiting for request
samples</small>
</article>
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
</article>
+83
View File
@@ -12193,4 +12193,87 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
.certificate-bulk-list .check {
overflow-wrap: anywhere;
}
#panel-overview .stat-cache-outcomes .cache-mix-bar {
display: flex;
width: 100%;
height: 12px;
margin: 7px 0 9px;
border-radius: var(--rp-progress-radius);
background: var(--rp-progress-track);
overflow: hidden;
}
#panel-overview .cache-mix-bar>span {
display: block;
height: 100%;
min-width: 0;
}
#panel-overview .cache-mix-hit {
background: var(--chart-accent);
}
#panel-overview .cache-mix-miss {
background: var(--chart-secondary);
}
#panel-overview .cache-mix-bypass {
background: var(--chart-tertiary);
}
#panel-overview .cache-mix-other {
background: var(--muted);
opacity: 0.65;
}
#panel-overview .cache-mix-legend {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px 8px;
line-height: 1.25;
}
#panel-overview .cache-mix-legend>span {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
#panel-overview .cache-mix-legend i {
display: inline-block;
flex: 0 0 7px;
width: 7px;
height: 7px;
border-radius: 2px;
}
#panel-overview .cache-mix-hit-key i {
background: var(--chart-accent);
}
#panel-overview .cache-mix-miss-key i {
background: var(--chart-secondary);
}
#panel-overview .cache-mix-bypass-key i {
background: var(--chart-tertiary);
}
#panel-overview .cache-mix-other-key i {
background: var(--muted);
}
#panel-overview .cache-mix-legend b {
margin-left: auto;
color: var(--text);
font: 500 10px var(--mono);
}
#panel-overview .stat-cache-outcomes .cache-mix-caption {
display: block;
margin-top: 8px;
color: var(--muted);
}
+209 -12
View File
@@ -17,7 +17,7 @@ use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use std::{
io,
net::SocketAddr,
net::{SocketAddr, UdpSocket},
pin::Pin,
sync::{atomic::Ordering, Arc},
task::{Context, Poll},
@@ -63,6 +63,66 @@ pub struct Bound {
address: SocketAddr,
settings: Settings,
}
// Set the actual QUIC UDP socket buffers, not only sysctl maxima. Linux
// doubles SO_RCVBUF/SO_SNDBUF values returned by getsockopt(2), which `ss`
// reports as rb/tb. Target the effective buffer size shown by `ss`.
fn requested_socket_buffer_size(effective_bytes: u32) -> usize {
#[cfg(target_os = "linux")]
{
(effective_bytes as usize / 2).max(1)
}
#[cfg(not(target_os = "linux"))]
{
effective_bytes as usize
}
}
fn configure_udp_socket(socket: &UdpSocket, address: SocketAddr, settings: Settings) {
let socket = socket2::SockRef::from(socket);
for (direction, requested, result) in [
(
"receive",
settings.udp_receive_buffer_bytes,
if settings.udp_receive_buffer_bytes == 0 {
Ok(())
} else {
socket.set_recv_buffer_size(requested_socket_buffer_size(
settings.udp_receive_buffer_bytes,
))
},
),
(
"send",
settings.udp_send_buffer_bytes,
if settings.udp_send_buffer_bytes == 0 {
Ok(())
} else {
socket.set_send_buffer_size(requested_socket_buffer_size(
settings.udp_send_buffer_bytes,
))
},
),
] {
if let Err(error) = result {
tracing::warn!(%address, direction, requested, %error,
"HTTP/3 UDP buffer request failed; continuing with OS socket default");
}
}
let receive = socket.recv_buffer_size().ok();
let send = socket.send_buffer_size().ok();
tracing::info!(%address, ?receive, ?send,
"HTTP/3 UDP effective socket buffers (bytes)");
for (direction, requested, actual) in [
("receive", settings.udp_receive_buffer_bytes, receive),
("send", settings.udp_send_buffer_bytes, send),
] {
if requested != 0 && actual.is_some_and(|size| size < requested as usize) {
tracing::warn!(%address, direction, requested, ?actual,
"HTTP/3 UDP socket buffer below configured target; check net.core.rmem_max and wmem_max on Linux");
}
}
}
pub fn bind(listener: Listener, state: &App) -> Result<Bound> {
let settings = listener
.http3
@@ -82,8 +142,16 @@ pub fn bind(listener: Listener, state: &App) -> Result<Bound> {
.max_incoming(settings.max_concurrent_connections as usize)
.incoming_buffer_size(64 * 1024)
.incoming_buffer_size_total(1024 * 1024);
let endpoint = quinn::Endpoint::server(server_config.clone(), listener.address)
let socket = UdpSocket::bind(listener.address)
.with_context(|| format!("cannot bind HTTP/3 UDP {}", listener.address))?;
configure_udp_socket(&socket, listener.address, settings);
let endpoint = quinn::Endpoint::new(
quinn::EndpointConfig::default(),
Some(server_config.clone()),
socket,
Arc::new(quinn::TokioRuntime),
)
.with_context(|| format!("cannot initialize HTTP/3 UDP {}", listener.address))?;
Ok(Bound {
endpoint,
server_config,
@@ -91,13 +159,77 @@ pub fn bind(listener: Listener, state: &App) -> Result<Bound> {
settings,
})
}
/// Derive the advertised endpoint from ACTUALLY bound sockets, never saved intent
/// or a backend Alt-Svc header. A pending H3 enable is not advertised before restart.
/// An established UDP socket is immutable until restart. Use the committed
/// *desired* transport policy for new QUIC connections on that same socket;
/// never apply settings from a listener planned on a different IP/port.
fn desired_for_bound(address: SocketAddr, tls: bool, desired: &[Listener]) -> Option<Settings> {
desired
.iter()
.find(|listener| listener.address == address && listener.tls == tls)
.and_then(|listener| listener.http3)
}
/// Compare immutable listener properties. H3 limits other than an increase in
/// the startup connection capacity are hot-applied for *new* connections.
/// UDP socket creation/removal (and H1/H2 TLS/ALPN settings) still need restart.
pub fn listeners_require_restart(active: &[Listener], desired: &[Listener]) -> bool {
if active.len() != desired.len() {
return true;
}
active.iter().any(|bound| {
let Some(next) = desired.iter().find(|next| next.address == bound.address) else {
return true;
};
if bound.tls != next.tls
|| bound.http2 != next.http2
|| bound.http2_settings != next.http2_settings
|| bound.h2c != next.h2c
{
return true;
}
match (bound.http3, next.http3) {
(None, None) => false,
(Some(initial), Some(updated)) => {
updated.max_concurrent_connections > initial.max_concurrent_connections
|| updated.udp_receive_buffer_bytes != initial.udp_receive_buffer_bytes
|| updated.udp_send_buffer_bytes != initial.udp_send_buffer_bytes
}
_ => true,
}
})
}
/// Origin-supplied Alt-Svc is never trusted. An HTTP/3 opt-out emits `clear`
/// on TLS responses while the existing QUIC socket is still bound, so clients
/// are told to discard their cached alternative service immediately.
pub fn withdraw_advertisement(
headers: &mut HeaderMap,
local: Option<SocketAddr>,
tls: bool,
active: &[Listener],
) {
headers.remove("alt-svc");
if tls
&& local.is_some_and(|local| {
active
.iter()
.any(|binding| binding.matches(local, true) && binding.http3.is_some())
})
{
headers.insert("alt-svc", HeaderValue::from_static("clear"));
}
}
/// Advertise only an actually active H3 UDP listener, using the newest
/// committed Alt-Svc lifetime. Disabling H3 for the listener or vhost publishes
/// `Alt-Svc: clear` without waiting for a restart.
pub fn advertise(
headers: &mut HeaderMap,
local: Option<SocketAddr>,
tls: bool,
active: &[Listener],
desired: &[Listener],
host_http3_enabled: bool,
) {
headers.remove("alt-svc");
if !tls {
@@ -106,11 +238,19 @@ pub fn advertise(
let Some(local) = local else {
return;
};
if let Some(settings) = active
.iter()
.find(|binding| binding.matches(local, true))
.and_then(|binding| binding.http3)
{
let Some(bound) = active.iter().find(|binding| binding.matches(local, true)) else {
return;
};
// A desired H3 enable on a socket with no UDP listener is pending restart.
if bound.http3.is_none() {
return;
}
let settings = desired_for_bound(bound.address, bound.tls, desired);
if !host_http3_enabled || settings.is_none() {
withdraw_advertisement(headers, Some(local), true, active);
return;
}
if let Some(settings) = settings {
let value = format!("h3=\":{}\"; ma={}", local.port(), settings.alt_svc_max_age);
if let Ok(value) = HeaderValue::from_str(&value) {
headers.insert("alt-svc", value);
@@ -131,6 +271,29 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
let Some(incoming)=incoming else {break;};
let peer=incoming.remote_address();
if !incoming.remote_address_validated() {let _=incoming.retry();continue;}
// Listener setting saves are already published through ArcSwap.
// Apply new limits to fresh QUIC handshakes on this bound UDP
// address, without mutating established connections.
let desired = {
let snapshot = state.config.load();
desired_for_bound(bound.address, true, &snapshot.listeners)
};
let Some(settings) = desired else { incoming.refuse(); continue; };
// Startup permits and Quinn's incoming capacity cannot be grown
// without restarting the UDP endpoint. A lower limit can be
// enforced dynamically without releasing live connection leases.
let open = bound.settings.max_concurrent_connections as usize - slots.available_permits();
let capacity = (settings.max_concurrent_connections as usize)
.min(bound.settings.max_concurrent_connections as usize);
if open >= capacity {
state.metrics.http3_rejected_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.rejected_connections.fetch_add(1,Ordering::Relaxed);
state.logs.push_protocol_error(
"http3_connection_limit_rejected", "HTTP/3 configured connection cap reached",
peer, bound.address, "quic", "3", 0, None,
);
incoming.refuse();continue;
}
let Ok(permit)=slots.clone().try_acquire_owned() else {
state.metrics.http3_rejected_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.rejected_connections.fetch_add(1,Ordering::Relaxed);
@@ -141,9 +304,21 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
incoming.refuse();continue;
};
let mut config=bound.server_config.clone();
let transport = match settings.transport() {
Ok(transport) => transport,
Err(error) => {
state.logs.push_protocol_error(
"http3_transport_config_error", error,
peer, bound.address, "quic", "3", 0, None,
);
incoming.refuse();continue;
}
};
config.transport = Arc::new(transport);
let Some(crypto)=state.config.load().quic_tls.clone() else {incoming.refuse();continue;};
// Certificate changes apply to new handshakes without changing
// active transport limits or regenerating the Retry token key.
// Existing QUIC sessions keep their negotiated transport limits.
// New sessions get current limits and certificate without
// regenerating the Retry token key.
config.crypto=crypto;
let connecting=match incoming.accept_with(Arc::new(config)) {
Ok(c)=>c,
@@ -155,7 +330,7 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
}
};
let router=router.clone();let state=state.clone();let address=bound.address;
let limit=bound.settings.max_concurrent_bidistream as usize;
let limit=settings.max_concurrent_bidistream as usize;
tasks.spawn(async move {
let connection=tokio::select! {
_=state.shutdown.cancelled()=>return,
@@ -251,6 +426,7 @@ async fn connection_loop(
drop(resolver);continue;
}
let router=router.clone();let state=state.clone();let peer=connection.remote_address();let tls_sni=tls_sni.clone();
let quic_diagnostics = connection.clone();
requests.spawn(async move {
let resolved=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await;
let (mut request,stream)=match resolved {
@@ -403,6 +579,27 @@ async fn connection_loop(
} else {
log_h3_response_complete(peer, &stream_id, response_status, sent_body_bytes);
}
// Connection-level cumulative counters (not per-stream
// deltas). Useful for distinguishing QUIC loss/cwnd/MTU
// limitations from application CPU when benchmarking.
if sent_body_bytes >= 8 * 1024 * 1024 || expected.is_some_and(|n| n >= 8 * 1024 * 1024) {
let stats = quic_diagnostics.stats();
tracing::debug!(
%peer, local=%local, stream_id=%stream_id,
response_bytes=sent_body_bytes,
request_content_length=?expected,
quic_udp_tx_datagrams=stats.udp_tx.datagrams,
quic_udp_tx_ios=stats.udp_tx.ios,
quic_udp_rx_datagrams=stats.udp_rx.datagrams,
quic_sent_packets=stats.path.sent_packets,
quic_lost_packets=stats.path.lost_packets,
quic_congestion_events=stats.path.congestion_events,
quic_rtt_ms=stats.path.rtt.as_secs_f64() * 1000.0,
quic_cwnd_bytes=stats.path.cwnd,
quic_path_mtu=stats.path.current_mtu,
"HTTP/3 bulk transfer transport snapshot"
);
}
});
},
Ok(None)=>break,
+15
View File
@@ -7,6 +7,10 @@ pub struct Settings {
pub max_concurrent_unistream: u32,
/// QUIC idle timeout in seconds; zero disables the transport idle timeout.
pub max_idle_timeout: u64,
/// Desired effective UDP socket receive buffer (bytes). 0 = OS default.
pub udp_receive_buffer_bytes: u32,
/// Desired effective UDP socket send buffer (bytes). 0 = OS default.
pub udp_send_buffer_bytes: u32,
}
impl Default for Settings {
fn default() -> Self {
@@ -16,6 +20,8 @@ impl Default for Settings {
max_concurrent_bidistream: 100,
max_concurrent_unistream: 100,
max_idle_timeout: 10,
udp_receive_buffer_bytes: 4 * 1024 * 1024,
udp_send_buffer_bytes: 4 * 1024 * 1024,
}
}
}
@@ -38,6 +44,15 @@ impl Settings {
self.max_idle_timeout <= 86400,
"HTTP/3 idle timeout must be 0..86400 seconds"
);
for (name, value) in [
("receive", self.udp_receive_buffer_bytes),
("send", self.udp_send_buffer_bytes),
] {
ensure!(
value == 0 || (64 * 1024..=16 * 1024 * 1024).contains(&value),
"HTTP/3 UDP {name} buffer must be 0 or 65536..16777216 bytes"
);
}
Ok(())
}
pub fn transport(&self) -> Result<quinn::TransportConfig> {
+107 -9
View File
@@ -41,9 +41,15 @@ mod tests {
}
.validate()
.is_err());
assert!(Settings { udp_receive_buffer_bytes: 1, ..Default::default() }
.validate().is_err());
assert!(Settings { udp_send_buffer_bytes: 16 * 1024 * 1024 + 1, ..Default::default() }
.validate().is_err());
assert!(Settings { udp_send_buffer_bytes: 0, ..Default::default() }
.validate().is_ok());
}
#[test]
fn alt_svc_only_for_actual_tls_udp_listener() {
fn alt_svc_uses_latest_committed_lifetime_and_only_bound_udp() {
let binding = Listener {
address: "127.0.0.1:8443".parse().unwrap(),
tls: true,
@@ -52,19 +58,111 @@ mod tests {
h2c: false,
http3: Some(Settings::default()),
};
let active = [binding];
let mut h = HeaderMap::new();
h.insert("alt-svc", "h3=\":9999\"".parse().unwrap());
advertise(&mut h, Some(binding.address), true, &[binding]);
advertise(&mut h, Some(binding.address), true, &active, &active, true);
assert_eq!(h["alt-svc"], "h3=\":8443\"; ma=3600");
advertise(&mut h, Some(binding.address), false, &[binding]);
let mut updated = binding;
updated.http3.as_mut().unwrap().alt_svc_max_age = 19;
advertise(&mut h, Some(binding.address), true, &active, &[updated], true);
assert_eq!(h["alt-svc"], "h3=\":8443\"; ma=19");
advertise(&mut h, Some(binding.address), false, &active, &[updated], true);
assert!(!h.contains_key("alt-svc"));
advertise(
&mut h,
Some("127.0.0.2:8443".parse().unwrap()),
true,
&[binding],
);
advertise(&mut h, Some("127.0.0.2:8443".parse().unwrap()), true, &active, &[updated], true);
assert!(!h.contains_key("alt-svc"));
// An intended but not bound H3 socket MUST NOT be advertised.
let mut previously_h2_only = binding;
previously_h2_only.http3 = None;
advertise(&mut h, Some(binding.address), true, &[previously_h2_only], &[updated], true);
assert!(!h.contains_key("alt-svc"));
}
#[test]
fn disabling_h3_withdraws_cached_advertisement() {
let binding = Listener {
address: "0.0.0.0:443".parse().unwrap(),
tls: true,
http2: true,
http2_settings: None,
h2c: false,
http3: Some(Settings::default()),
};
let local = Some("192.0.2.2:443".parse().unwrap());
let mut h = HeaderMap::new();
// Vhost opt-out: immediate Alt-Svc: clear without affecting another host.
advertise(&mut h, local, true, &[binding], &[binding], false);
assert_eq!(h["alt-svc"], "clear");
// Listener opt-out: listener removal requires restart but the header
// is withdrawn immediately, while existing H3 sessions may survive.
let mut disabled = binding;
disabled.http3 = None;
advertise(&mut h, local, true, &[binding], &[disabled], true);
assert_eq!(h["alt-svc"], "clear");
advertise(&mut h, local, true, &[binding], &[], true);
assert_eq!(h["alt-svc"], "clear");
withdraw_advertisement(&mut h, local, true, &[binding]);
assert_eq!(h["alt-svc"], "clear");
withdraw_advertisement(&mut h, local, false, &[binding]);
assert!(!h.contains_key("alt-svc"));
}
#[test]
fn h3_hot_changes_do_not_misreport_restart() {
let active = Listener {
address: "127.0.0.1:8443".parse().unwrap(),
tls: true,
http2: true,
http2_settings: Some(crate::settings::Http2Connections::default()),
h2c: false,
http3: Some(Settings::default()),
};
assert!(!listeners_require_restart(&[active], &[active]));
let mut next = active;
let h3 = next.http3.as_mut().unwrap();
h3.alt_svc_max_age = 0;
h3.max_concurrent_connections = 40;
h3.max_concurrent_bidistream = 20;
h3.max_concurrent_unistream = 30;
h3.max_idle_timeout = 90;
assert!(!listeners_require_restart(&[active], &[next]));
// A capacity increase above the startup socket/permit cap needs restart.
next.http3.as_mut().unwrap().max_concurrent_connections = 513;
assert!(listeners_require_restart(&[active], &[next]));
next = active;
next.http3.as_mut().unwrap().udp_receive_buffer_bytes = 8 * 1024 * 1024;
assert!(listeners_require_restart(&[active], &[next]));
next = active;
next.http3.as_mut().unwrap().udp_send_buffer_bytes = 8 * 1024 * 1024;
assert!(listeners_require_restart(&[active], &[next]));
next = active;
next.http3 = None;
assert!(listeners_require_restart(&[active], &[next]));
next = active;
next.address = "127.0.0.1:9443".parse().unwrap();
assert!(listeners_require_restart(&[active], &[next]));
next = active;
next.http2 = false;
assert!(listeners_require_restart(&[active], &[next]));
assert!(listeners_require_restart(&[active], &[]));
}
#[test]
fn udp_buffers_keep_legacy_config_compatible() {
let old = r#"{"alt_svc_max_age":3600,"max_concurrent_connections":512,"max_concurrent_bidistream":100,"max_concurrent_unistream":100,"max_idle_timeout":10}"#;
let settings: Settings = serde_json::from_str(old).unwrap();
assert_eq!(settings.udp_receive_buffer_bytes, 4 * 1024 * 1024);
assert_eq!(settings.udp_send_buffer_bytes, 4 * 1024 * 1024);
assert_eq!(serde_json::from_str::<Settings>(&serde_json::to_string(&settings).unwrap()).unwrap(), settings);
}
#[test]
fn udp_socket_tuning_produces_readable_socket_options() {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let address = socket.local_addr().unwrap();
configure_udp_socket(&socket, address, Settings::default());
let socket = socket2::SockRef::from(&socket);
assert!(socket.recv_buffer_size().unwrap() > 0);
assert!(socket.send_buffer_size().unwrap() > 0);
}
#[test]
fn client_cancellation_is_a_stream_event_not_an_http_400() {
+2 -3
View File
@@ -172,10 +172,10 @@ fn render_core(app: &App, snapshot: &crate::config::Snapshot, out: &mut String,
f.add(
"rust_proxy_restart_required",
"gauge",
"Listener/exporter intent differs from startup state.",
"Listener/exporter changes requiring a restart.",
"",
u8::from(
snapshot.listeners != app.active_listeners
crate::http3::listeners_require_restart(&app.active_listeners, &snapshot.listeners)
|| settings.restart_differs(&app.active_prometheus),
),
);
@@ -428,7 +428,6 @@ fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) {
counter.load(Ordering::Relaxed),
);
}
}
}
}
+8 -5
View File
@@ -291,11 +291,14 @@ fn finish_headers(
HeaderValue::from_static("private, no-store"),
);
}
if frontend.is_some_and(|frontend| !frontend.http3_enabled) {
response.headers_mut().remove("alt-svc");
} else {
crate::http3::advertise(response.headers_mut(), local, tls, active);
}
crate::http3::advertise(
response.headers_mut(),
local,
tls,
active,
&snapshot.listeners,
!frontend.is_some_and(|frontend| !frontend.http3_enabled),
);
}
fn apply_timing_headers(
response: &mut Response,
+12 -2
View File
@@ -316,14 +316,24 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.get::<RedirectResponse>()
.is_some_and(|redirect| !redirect.http3_enabled)
{
response.headers_mut().remove("alt-svc");
crate::http3::withdraw_advertisement(
response.headers_mut(),
local,
transport_tls,
&state.active_listeners,
);
}
if response
.extensions()
.get::<LocalHostResponse>()
.is_some_and(|item| !item.http3_enabled)
{
response.headers_mut().remove("alt-svc");
crate::http3::withdraw_advertisement(
response.headers_mut(),
local,
transport_tls,
&state.active_listeners,
);
}
let global_compression = &snapshot.envelope.config.compression;
// All scoped policies have the global codec ceiling. With no accepted