v0.35.1: performance, AGPL licensing and documentation overhaul

- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts
This commit is contained in:
gru committed 2026-11-05 18:29:56 +01:00
1 parent d3b492874b
commit eba9f89283
135 files changed
+1425 -1064

No files matched your search

+12 -9
View File
@@ -35,17 +35,20 @@ async fn execute(
.map_err(|_| "acme.validation_worker_failed".to_owned())?
}
pub async fn challenge(
axum::extract::State(state): axum::extract::State<Arc<App>>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
const CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
pub fn is_challenge_path(path: &str) -> bool {
path.starts_with(CHALLENGE_PREFIX)
}
pub async fn challenge(state: Arc<App>, request: axum::extract::Request) -> axum::response::Response {
use axum::{body::Body, response::IntoResponse};
use tokio::io::AsyncReadExt;
const PREFIX: &str = "/.well-known/acme-challenge/";
let Some(token) = request.uri().path().strip_prefix(PREFIX) else {
return next.run(request).await;
};
let token = request
.uri()
.path()
.strip_prefix(CHALLENGE_PREFIX)
.unwrap_or_default();
let denied = || {
(
StatusCode::NOT_FOUND,
+5 -6
View File
@@ -11,10 +11,11 @@ macro_rules! reader_doc {
pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("README.md", "project"),
reader_doc!("CHANGELOG.md", "project"),
reader_doc!("ROADMAP.md", "project"),
reader_doc!("LICENSE", "project"),
reader_doc!("docs/README.md", "guides"),
reader_doc!("docs/FEATURES.md", "guides"),
reader_doc!("docs/SCREENSHOTS.md", "guides"),
reader_doc!("docs/INSTALL.md", "guides"),
reader_doc!("docs/CONTAINER.md", "guides"),
reader_doc!("docs/CONFIGURATION.md", "guides"),
reader_doc!("docs/VHOSTS.md", "guides"),
reader_doc!("docs/CUSTOM_LOCATIONS.md", "guides"),
@@ -24,11 +25,9 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/CACHE.md", "guides"),
reader_doc!("docs/RESPONSE_DELIVERY.md", "guides"),
reader_doc!("docs/TRANSPORT.md", "guides"),
reader_doc!("docs/TLS_POST_QUANTUM.md", "guides"),
reader_doc!("docs/STREAMS.md", "guides"),
reader_doc!("docs/ACME_PROVIDERS.md", "guides"),
reader_doc!("docs/CERTIFICATES.md", "guides"),
reader_doc!("docs/DOCKER_DISCOVERY.md", "guides"),
reader_doc!("docs/INTEGRATIONS.md", "guides"),
reader_doc!("docs/DASHBOARD.md", "guides"),
reader_doc!("docs/MONITORING.md", "guides"),
reader_doc!("docs/LOGS.md", "guides"),
@@ -36,6 +35,6 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/MIGRATION.md", "guides"),
reader_doc!("docs/SECURITY.md", "guides"),
reader_doc!("docs/API.md", "guides"),
reader_doc!("docs/API_ACCESS.md", "guides"),
reader_doc!("docs/PERFORMANCE.md", "guides"),
reader_doc!("docs/TROUBLESHOOTING.md", "guides"),
];
+1 -1
View File
@@ -12,7 +12,7 @@ mod tests {
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
assert!(documents
.iter()
.any(|doc| doc["path"] == "docs/TLS_POST_QUANTUM.md"));
.any(|doc| doc["path"] == "docs/FEATURES.md"));
assert!(documents.iter().all(|doc| {
doc["path"].as_str().is_some_and(|value| !value.is_empty())
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.35.0",
"version": "0.35.1",
"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. The API 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": [
@@ -9521,7 +9521,7 @@
}
}
},
"description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/ACME_PROVIDERS.md. Poll GET /api/acme.",
"description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/CERTIFICATES.md. Poll GET /api/acme.",
"parameters": [
{
"name": "Origin",
+1 -2
View File
@@ -1043,8 +1043,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// GET returns the same standalone section shape accepted by PUT, at the
// matching revision and without unrequested configuration fields.
// GET returns the same section shape that PUT accepts.
let read = router
.clone()
.oneshot(
+1 -2
View File
@@ -21,8 +21,7 @@ function syncConfigHistoryRestoreButton() {
button.hidden = configHistorySelectedRevision === null;
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
}
// Compare leaf fields, not serialized object/array blobs. Object IDs are used
// for stable array paths so reordering vhosts does not become a huge fake diff.
// Leaf-level diff keyed by object IDs, so reordering is not a change.
function configDiff(before, after, path = "$", output = [], depth = 0) {
if (output.length >= 200 || Object.is(before, after)) return output;
if (depth >= 24) {
+1 -3
View File
@@ -144,9 +144,7 @@ window.rustProxyDashboardLayout = (() => {
widen.addEventListener("click", () => resize(id, 1));
const widthValue = el("span", `${width}/12`, "overview-layout-width-value");
widthValue.title = t("overviewLayoutWidth");
// The visual editor intentionally keeps uniformly sized previews.
// Group sizing separately from ordering/hiding so both sets wrap
// inside a narrow dialog rather than spilling into adjacent tiles.
// Size and order controls are grouped separately so they wrap in narrow dialogs.
const sizeActions = el("span", undefined, "overview-layout-size-actions");
sizeActions.append(narrow, widthValue, widen);
const orderActions = el("span", undefined, "overview-layout-order-actions");
+5 -3
View File
@@ -1,6 +1,4 @@
// One continuity policy for all browser-side counter-derived rates.
// Background tabs cannot supply a valid instantaneous rate for an interval
// spanning the pause. Keep the plotted history and rebase on the next sample.
// Shared rate sampling: rebase after background-tab pauses instead of plotting a false spike.
const rustProxyRateSampling = (() => {
let generation = 0;
const pause = () => { generation += 1; };
@@ -19,6 +17,10 @@ const rustProxyRateSampling = (() => {
at - previous.at <= Math.max(10000, seconds * 3000));
}
function difference(previous, values, at, intervalSeconds = 3) {
if (previous && previous.generation === generation && Number.isFinite(at) &&
Number.isFinite(previous.at) && at >= previous.at && at - previous.at < 250) {
return { next: previous, deltas: null, seconds: null, rapid: true };
}
const next = { ...stamp(at), values: [...values] };
if (!continuous(previous, at, intervalSeconds) ||
values.some((value, i) => !Number.isFinite(value) || value < 0 ||
+1 -3
View File
@@ -1,6 +1,4 @@
/* Theme palettes and appearance tokens. Runtime preference variables remain writable by JavaScript.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Theme palettes and appearance tokens */
:root {
color-scheme: dark;
+39 -8
View File
@@ -1,6 +1,4 @@
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives */
* {
box-sizing: border-box;
@@ -1304,8 +1302,9 @@ body[data-density=compact] .page-footer {
}
.about-dialog {
width: calc(min(560px, 100vw - 24px));
overflow: hidden;
width: calc(min(640px, 100vw - 24px));
max-height: calc(100vh - 24px);
overflow: auto;
}
.about-dialog .window-heading button {
@@ -1370,6 +1369,38 @@ body[data-density=compact] .page-footer {
line-height: 1.65;
}
.about-license {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--line);
background: var(--panel);
}
.about-license h3 {
margin: 0;
}
.about-license p {
margin: 0;
line-height: 1.6;
}
.about-license ul {
display: grid;
gap: 4px;
margin: 0;
padding-inline-start: 18px;
color: var(--muted);
line-height: 1.5;
}
.about-links {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.about-repository {
justify-content: space-between;
gap: 12px;
@@ -1381,7 +1412,9 @@ body[data-density=compact] .page-footer {
.about-repository span:last-child {
color: var(--muted);
font: 10px var(--mono);
overflow-wrap: anywhere;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
html[data-table-density=compact] :is(table th, table td) {
@@ -1479,7 +1512,6 @@ html[data-table-density=ultra] :is(table th, table td) {
align-items: center;
}
/* Keep generated ACL instructions in the original metrics-example code style. */
.path-permissions {
min-width: 0;
}
@@ -1506,7 +1538,6 @@ html[data-table-density=ultra] :is(table th, table td) {
max-width: 260px;
}
/* Permission guidance belongs to its generated command block, not the path inputs. */
#certificate-external-acl-example.path-permissions {
margin-top: 20px;
border-top: 1px solid var(--line);
+1 -3
View File
@@ -1,6 +1,4 @@
/* Application startup, authentication, navigation, page chrome and draft dock.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Application startup, authentication, navigation, page chrome and draft dock */
html.app-loading .shell {
visibility: hidden;
+2 -5
View File
@@ -1,6 +1,4 @@
/* Overview metrics, charts, dashboard density, inline layout controls and presets.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Overview metrics, charts, dashboard density, inline layout controls and presets */
html[data-theme=ultra_dark] :is(.card, .stat, dialog) {
box-shadow: 0 6px 24px rgba(0, 0, 0, .18);
@@ -76,8 +74,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5);
}
/* Optional X/Y scale readouts do not change SVG coordinates or chart ranges.
* Labels are HTML, not distorted SVG text under preserveAspectRatio="none". */
/* Axis labels are HTML overlays, not stretched SVG text. */
.rp-chart-axis-host {
position: relative;
display: flow-root;
+1 -3
View File
@@ -1,6 +1,4 @@
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers */
.host-chip-editor.field-invalid,
.listener-choices.field-invalid {
+2 -4
View File
@@ -1,6 +1,4 @@
/* Settings, certificates, integrations and Docker agent/service management.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Settings, certificates, integrations and Docker agent/service management */
.settings-tabs button.has-unsaved:after {
content: "";
@@ -1205,7 +1203,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
width: 100%;
min-width: 0;
}
/* 0.34.13: global security policy forms, grouped with clear control boundaries. */
/* Global security policy forms */
.global-policy-form .global-policy-section {
display: grid;
gap: 10px;
+1 -3
View File
@@ -1,6 +1,4 @@
/* Traffic, cache, transformations, live/stored logs and configuration history.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Traffic, cache, transformations, live/stored logs and configuration history */
.certificate-card .pill,
.cache-miss-key,
+3 -5
View File
@@ -1,6 +1,4 @@
/* Responsive rules. Each viewport, container and reduced-motion condition is declared once.
* Historical overrides were resolved before consolidation; preserve the documented order.
*/
/* Responsive rules */
@media (min-width:1201px) {
#panel-overview .stats-grid {
@@ -1052,7 +1050,8 @@
}
.named-listener-row .vh-grid,
.about-meta {
.about-meta,
.about-links {
grid-template-columns: 1fr;
}
@@ -1388,7 +1387,6 @@
letter-spacing: -2px;
}
/* The standalone auth heading keeps its later, unconditional font size. */
.login-section h1:where(:not(.auth-main h1)) {
font-size: 40px;
}
-6
View File
@@ -91,9 +91,3 @@ When replacing a previously built checkout with a source ZIP, include the root
the [generated-CSS recovery steps](../../../../scripts/README.md#missing-generated-css-during-rust-compilation).
Source preflight does not generate Cargo outputs. The build tracks the generated
bundle as well as its inputs, and rejects a missing or empty output.
The optional [dashboard browser regression runner](../../../../tools/dev/README-dashboard-css.md)
assembles the actual HTML and JavaScript against synthetic API fixtures and
compares a supplied baseline stylesheet with the built candidate. It includes
breakpoint/container boundaries and dynamic editor states. Its dependencies are
development-only; they are not part of the application build or deployment.
+34 -3
View File
@@ -1,4 +1,4 @@
/* A deliberately self-contained documentation layout. */
/* Documentation Reader layout. */
* {
box-sizing: border-box
}
@@ -325,6 +325,38 @@ main {
color: #52606d
}
.reader-table {
overflow-x: auto;
margin: 14px 0
}
.reader-table table {
border-collapse: collapse;
width: 100%;
font-size: 14px
}
.reader-table :is(th, td) {
text-align: left;
vertical-align: top;
padding: 7px 10px;
border: 1px solid #e0e5ea;
line-height: 1.5
}
.reader-table th {
background: #f4f6f8
}
.reader-image {
display: block;
margin: 12px 0;
padding: 10px 12px;
border: 1px dashed #c9d1d9;
color: #52606d;
font-size: 13px
}
.reader-empty {
color: #687581;
padding: 10px 8px
@@ -344,8 +376,7 @@ main {
padding: 18px
}
}
/* Authenticated documentation uses the same stored corner preference as the
main panel, while retaining its original flat shape for "Original". */
/* Corner style follows the panel preference. */
html[data-corner-style=square] {
--rp-docs-radius-surface: 0px;
--rp-docs-radius-control: 0px;
+23 -2
View File
@@ -10,6 +10,7 @@
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
const t = key => translations[key] ?? fallback[key] ?? key;
const REPOSITORY_RAW = "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master";
function docsConfirm(message) {
return new Promise(resolve => {
@@ -78,12 +79,22 @@
}
function appendInline(parent, text, basePath) {
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|!?\[[^\]]*\]\([^)]+\))/g; let offset = 0;
for (const match of text.matchAll(pattern)) {
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
const token = match[0];
if (token.startsWith("`")) {
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
} else if (token.startsWith("**")) {
const strong = document.createElement("strong"); appendInline(strong, token.slice(2, -2), basePath); parent.append(strong);
} else if (token.startsWith("!")) {
const parsed = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(token);
const caption = document.createElement("a"); caption.className = "reader-image";
caption.textContent = `${t("docsImage")}: ${parsed[1] || parsed[2]}`;
const target = parsed[2].trim();
caption.href = /^https?:\/\//i.test(target) ? target : `${REPOSITORY_RAW}/${normalizeDocPath(basePath, target)}`;
caption.target = "_blank"; caption.rel = "noreferrer";
parent.append(caption);
} else {
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
anchor.textContent = parsed[1]; const target = parsed[2].trim();
@@ -106,7 +117,7 @@
function renderMarkdown(documentInfo) {
const target = element("docs-reader-content"); target.replaceChildren();
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
let code = null, list = null, paragraph = [];
let code = null, list = null, table = null, paragraph = [];
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
const closeList = () => { list = null; };
for (const line of lines) {
@@ -117,6 +128,16 @@
continue;
}
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
if (/^\s*\|.*\|\s*$/.test(line)) {
flushParagraph(); closeList();
const cells = line.trim().slice(1, -1).split("|").map(cell => cell.trim());
if (cells.every(cell => /^:?-{2,}:?$/.test(cell))) continue;
if (!table) { table = document.createElement("table"); const wrap = document.createElement("div"); wrap.className = "reader-table"; wrap.append(table); target.append(wrap); }
const row = document.createElement("tr");
for (const cell of cells) { const node = document.createElement(table.rows.length ? "td" : "th"); appendInline(node, cell, documentInfo.path); row.append(node); }
table.append(row); continue;
}
table = null;
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
+39 -11
View File
@@ -3143,7 +3143,7 @@
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
group, not a cipher suite. Disabled by default.</p>
<a href="/docs#doc=docs%2FTLS_POST_QUANTUM.md" target="_blank"
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank"
rel="noopener noreferrer" data-i18n="tlsPqcDocs">Learn about PQC, compatibility
and testing (documentation)</a>
</div>
@@ -4300,24 +4300,52 @@ log_errors = On</code></pre>
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>__RUSTPROXY_VERSION__</strong></div>
</div>
<p data-i18n="aboutTagline">Reverse proxy, web server, certificate manager and traffic monitor in a single
service with a built-in administration panel.</p>
<dl class="about-meta">
<div>
<dt data-i18n="aboutAuthor">Author</dt>
<dd>Mateusz Gruszczynski @linuxiarz.pl</dd>
<dd>Mateusz Gruszczyński · linuxiarz.pl</dd>
</div>
<div>
<dt data-i18n="aboutLicense">License</dt>
<dd data-i18n="aboutLicenseValue">GNU AGPL v3</dd>
<dd data-i18n="aboutLicenseValue">GNU AGPL v3.0 or later</dd>
</div>
<div>
<dt data-i18n="aboutCopyright">Copyright</dt>
<dd>© 2026 Mateusz Gruszczyński</dd>
</div>
<div>
<dt data-i18n="aboutSpdx">SPDX identifier</dt>
<dd><code>AGPL-3.0-or-later</code></dd>
</div>
</dl>
<p data-i18n="aboutOpenSourceText">RustProxy is an open-source project. Its source code is publicly
available for inspection, development and community contribution.</p>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutRepository">Official source
repository</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Official package
repository</span><span>rustproxy.linuxiarz.pl</span></a>
<section class="about-license" aria-labelledby="about-license-title">
<h3 class="small-label" data-i18n="aboutLicenseTitle" id="about-license-title">License summary</h3>
<p data-i18n="aboutOpenSourceText">RustProxy is free and open-source software. You may use, study,
modify and redistribute it under the terms of the GNU Affero General Public License, version 3
or any later version.</p>
<ul>
<li data-i18n="aboutLicensePointUse">Use it for any purpose, including commercial deployments.</li>
<li data-i18n="aboutLicensePointShare">Distributed copies and modified versions must stay under the
same license and include their source code.</li>
<li data-i18n="aboutLicensePointNetwork">If you run a modified version as a network service, its
users must be able to obtain the modified source code.</li>
<li data-i18n="aboutLicensePointWarranty">The software is provided without any warranty.</li>
</ul>
</section>
<div class="about-links">
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Website and packages</span><span>rustproxy.linuxiarz.pl</span></a>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy"
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="/docs#doc=docs%2FREADME.md" rel="noopener"
target="_blank"><span data-i18n="aboutDocumentation">Documentation and API
reference</span><span>/docs</span></a>
<a class="outline about-repository" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
data-i18n="aboutLicenseText">Full license text</span><span>LICENSE</span></a>
</div>
</article>
</dialog>
<div aria-live="polite" class="toast" hidden="" id="toast" popover="manual" role="status"></div>
+4 -11
View File
@@ -24,7 +24,6 @@ const group = (key, advanced = false) => {
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
// Presentation-only subgroup: all existing inputs remain inside their original form.
const subsection = (parent, key) => {
const box = el("section", undefined, "vh-wide vh-setting-section");
box.append(text("h3", key));
@@ -215,8 +214,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
// Preserve Host is a request-authority choice, not an HTTP/1.1-only option.
// The Rust forwarder keeps HTTP/2 :authority consistent with this value.
// Preserve Host also applies to HTTP/2 :authority.
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
@@ -230,8 +228,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
// The per-member settings live in the Health tab. Keep one persistent form
// per backend, so switching the selection never discards unsaved edits.
// One persistent form per backend keeps unsaved edits when switching.
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
const health = value.health_check || {};
@@ -305,9 +302,7 @@ function debugOriginIssues(members) {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
// The troubleshooting preset is OPTIONAL, not a protocol compatibility rule.
// Warn only when IP-host TLS verification may need an explicit identity;
// HTTP/1.1, Auto and H2 plus preserve_host are all valid configurations.
// Warn only when TLS verification of an IP host needs an explicit server name.
return member.tls_verify === true && !String(member.tls_server_name || "").trim();
});
}
@@ -498,9 +493,7 @@ function locationsEditor(parent, values) {
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
rows.append(row); syncUi();
}
// The main vhost backend is an internal fallback, not a Custom location.
// An empty location list must stay empty so the editor cannot accidentally
// delete or recreate the main route as user configuration.
// The main backend is an internal fallback, never an editable location.
for (const value of values) add(value);
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
+3
View File
@@ -516,6 +516,7 @@ function renderStatus(status) {
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
measured: Boolean(metrics) || Boolean(existing.measured),
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
@@ -533,8 +534,10 @@ function renderStatus(status) {
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
for (const [id, data] of aggregate) {
if (!data.measured) continue;
const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
const sample = rustProxyRateSampling.difference(history.last, [data.requests], now, rustProxyRateSampling.cadence("overview_seconds"));
if (sample.rapid) continue;
const pausedRate = history.last !== null && sample.deltas === null;
history.last = sample.next;
const rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0;
+7 -7
View File
@@ -112,7 +112,7 @@ impl Pool {
}
fn affinity<'a>(&self, headers: &'a HeaderMap) -> Option<&'a str> {
let mut values = headers
.get_all("cookie")
.get_all(::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
@@ -131,7 +131,7 @@ impl Pool {
return;
}
let retained: Vec<_> = headers
.get_all("set-cookie")
.get_all(::http::header::SET_COOKIE)
.iter()
.filter(|value| {
value
@@ -142,9 +142,9 @@ impl Pool {
})
.cloned()
.collect();
headers.remove("set-cookie");
headers.remove(::http::header::SET_COOKIE);
for value in retained {
headers.append("set-cookie", value);
headers.append(::http::header::SET_COOKIE, value);
}
}
pub fn strip_affinity(&self, headers: &mut HeaderMap) {
@@ -152,7 +152,7 @@ impl Pool {
return;
}
let values: Vec<HeaderValue> = headers
.get_all("cookie")
.get_all(::http::header::COOKIE)
.iter()
.filter_map(|value| {
let Ok(text) = value.to_str() else {
@@ -175,9 +175,9 @@ impl Pool {
}
})
.collect();
headers.remove("cookie");
headers.remove(::http::header::COOKIE);
for value in values {
headers.append("cookie", value);
headers.append(::http::header::COOKIE, value);
}
}
pub fn select_member(&self, id: &str) -> Option<Selection> {
+1 -2
View File
@@ -6,8 +6,7 @@ mod tests {
h.insert("cache-control", value.parse().unwrap());
h
}
// Legacy-policy regression tests explicitly opt out of the new 0.34.7
// default; varnish-compatible behavior has its own test module.
// Legacy policy; varnish-compatible behavior is tested separately.
fn settings() -> Settings {
Settings {
enabled: true,
+44 -1
View File
@@ -1,3 +1,6 @@
const UNCACHEABLE_MEMO_TTL: Duration = Duration::from_secs(30);
const UNCACHEABLE_MEMO_CAPACITY: usize = 8192;
struct FillFlight {
id: u64,
scope: String,
@@ -50,7 +53,7 @@ impl Drop for FillLeaderGuard {
impl Cache {
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
if !settings.collapse_enabled {
if !settings.collapse_enabled || self.recently_uncacheable(&ticket.key) {
return FillClaim::Disabled;
}
let existing = {
@@ -116,7 +119,46 @@ impl Cache {
}
}
/// Marks a key as uncacheable so requests skip coalescing.
pub fn remember_uncacheable(&self, ticket: &Ticket) {
let now = std::time::Instant::now();
let mut memo = self
.uncacheable_until
.lock()
.unwrap_or_else(|e| e.into_inner());
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
memo.retain(|_, until| *until > now);
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
return;
}
}
memo.insert(ticket.key, now + UNCACHEABLE_MEMO_TTL);
}
fn recently_uncacheable(&self, key: &Key) -> bool {
let mut memo = self
.uncacheable_until
.lock()
.unwrap_or_else(|e| e.into_inner());
match memo.get(key) {
Some(until) if *until > std::time::Instant::now() => true,
Some(_) => {
memo.remove(key);
false
}
None => false,
}
}
fn forget_uncacheable(&self) {
self.uncacheable_until
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
fn cancel_all_fill_flights(&self) {
self.forget_uncacheable();
let flights = {
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
@@ -130,6 +172,7 @@ impl Cache {
}
fn cancel_scope_fill_flights(&self, scope: &str) {
self.forget_uncacheable();
let removed = {
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let keys = flights
+2 -2
View File
@@ -92,9 +92,9 @@ impl Candidate {
.fetch_add(1, Ordering::Relaxed);
return;
}
if !self.headers.contains_key("date") {
if !self.headers.contains_key(::http::header::DATE) {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
self.headers.insert("date", value);
self.headers.insert(::http::header::DATE, value);
}
}
self.headers.remove("x-request-id");
+2
View File
@@ -23,6 +23,7 @@ impl Cache {
cold_read_bytes_active: AtomicU64::new(0),
revalidating: Mutex::new(HashSet::new()),
fill_flights: Mutex::new(HashMap::new()),
uncacheable_until: Mutex::new(HashMap::new()),
fill_flight_sequence: AtomicU64::new(0),
collapse_waiters: AtomicU64::new(0),
bans: Mutex::new(Vec::new()),
@@ -75,6 +76,7 @@ impl Cache {
cold_read_bytes_active: AtomicU64::new(0),
revalidating: Mutex::new(HashSet::new()),
fill_flights: Mutex::new(HashMap::new()),
uncacheable_until: Mutex::new(HashMap::new()),
fill_flight_sequence: AtomicU64::new(0),
collapse_waiters: AtomicU64::new(0),
bans: Mutex::new(Vec::new()),
+2 -4
View File
@@ -87,8 +87,7 @@ impl Cache {
if skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
response.headers_mut().insert(::http::header::AGE,
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats
@@ -285,8 +284,7 @@ impl Cache {
if entry.skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
}
response.headers_mut().insert(
"age",
response.headers_mut().insert(::http::header::AGE,
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats
+7 -8
View File
@@ -22,9 +22,9 @@ impl Cache {
if cached.body.len() > settings.max_object_bytes {
return None;
}
if !headers.contains_key("date") {
if !headers.contains_key(::http::header::DATE) {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
headers.insert("date", value);
headers.insert(::http::header::DATE, value);
}
}
headers.remove("x-request-id");
@@ -34,11 +34,11 @@ impl Cache {
let vary = plan.vary.clone();
let tags = cache_tags(&headers);
let origin_etag = validation_headers
.get("etag")
.get(::http::header::ETAG)
.cloned()
.or_else(|| cached.origin_etag.clone());
let origin_last_modified = validation_headers
.get("last-modified")
.get(::http::header::LAST_MODIFIED)
.cloned()
.or_else(|| cached.origin_last_modified.clone());
let cost = hot_entry_cost(
@@ -98,8 +98,7 @@ impl Cache {
}
self.counters.hits.fetch_add(1, Ordering::Relaxed);
let mut response = cached.response(headers);
response.headers_mut().insert(
"age",
response.headers_mut().insert(::http::header::AGE,
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
Some(Hit {
@@ -156,11 +155,11 @@ impl Cache {
.cloned();
let origin_etag = match origin_validators.as_ref() {
Some(validators) => validators.etag.clone(),
None => headers.get("etag").cloned(),
None => headers.get(::http::header::ETAG).cloned(),
};
let origin_last_modified = match origin_validators.as_ref() {
Some(validators) => validators.last_modified.clone(),
None => headers.get("last-modified").cloned(),
None => headers.get(::http::header::LAST_MODIFIED).cloned(),
};
let transformed = parts
.extensions
+1 -2
View File
@@ -333,8 +333,7 @@ impl Settings {
if transformed {
value.push_str("; transformed");
}
headers.append(
"cache-status",
headers.append(::http::header::CACHE_STATUS,
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
);
}
+18 -17
View File
@@ -68,6 +68,7 @@ pub struct Cache {
cold_read_bytes_active: AtomicU64,
revalidating: Mutex<HashSet<Key>>,
fill_flights: Mutex<HashMap<Key, Arc<FillFlight>>>,
uncacheable_until: Mutex<HashMap<Key, std::time::Instant>>,
fill_flight_sequence: AtomicU64,
collapse_waiters: AtomicU64,
bans: Mutex<Vec<BanRule>>,
@@ -183,35 +184,35 @@ impl Revalidation {
&self.headers
}
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
if headers.contains_key(::http::header::IF_NONE_MATCH) {
let Some(public_etag) = self.headers.get(::http::header::ETAG).and_then(|v| v.to_str().ok()) else {
return false;
};
if !single_if_none_match_matches(headers, public_etag) {
return false;
}
if let Some(origin) = self.origin_etag.clone() {
headers.insert("if-none-match", origin);
headers.remove("if-modified-since");
headers.insert(::http::header::IF_NONE_MATCH, origin);
headers.remove(::http::header::IF_MODIFIED_SINCE);
return true;
}
if let Some(modified) = self.origin_last_modified.clone() {
headers.remove("if-none-match");
headers.insert("if-modified-since", modified);
headers.remove(::http::header::IF_NONE_MATCH);
headers.insert(::http::header::IF_MODIFIED_SINCE, modified);
return true;
}
return false;
}
if headers.contains_key("if-modified-since") {
if headers.contains_key(::http::header::IF_MODIFIED_SINCE) {
let Some(cached) = self
.headers
.get("last-modified")
.get(::http::header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
else {
return false;
};
let Some(client) = headers
.get("if-modified-since")
.get(::http::header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
else {
return false;
@@ -226,16 +227,16 @@ impl Revalidation {
let Some(origin) = self.origin_last_modified.clone() else {
return false;
};
headers.insert("if-modified-since", origin);
headers.insert(::http::header::IF_MODIFIED_SINCE, origin);
return true;
}
if let Some(etag) = self.origin_etag.clone() {
headers.insert("if-none-match", etag);
headers.remove("if-modified-since");
headers.insert(::http::header::IF_NONE_MATCH, etag);
headers.remove(::http::header::IF_MODIFIED_SINCE);
true
} else if let Some(modified) = self.origin_last_modified.clone() {
headers.insert("if-modified-since", modified);
headers.remove("if-none-match");
headers.insert(::http::header::IF_MODIFIED_SINCE, modified);
headers.remove(::http::header::IF_NONE_MATCH);
true
} else {
false
@@ -281,7 +282,7 @@ impl Revalidation {
}
pub fn merge_validation_headers(&self, validation: &HeaderMap) -> HeaderMap {
let mut merged = merge_revalidated_headers(&self.headers, validation);
let public_etag = self.headers.get("etag").cloned();
let public_etag = self.headers.get(::http::header::ETAG).cloned();
let representation_etag_differs = public_etag.as_ref().is_some_and(|public| {
self.origin_etag
.as_ref()
@@ -289,7 +290,7 @@ impl Revalidation {
});
if representation_etag_differs {
if let Some(public) = public_etag {
merged.insert("etag", public);
merged.insert(::http::header::ETAG, public);
}
}
merged
@@ -316,7 +317,7 @@ impl Revalidation {
}
fn vary_names(headers: &HeaderMap) -> Option<Vec<String>> {
let mut names = Vec::new();
for value in headers.get_all("vary") {
for value in headers.get_all(::http::header::VARY) {
for raw in value.to_str().ok()?.split(',') {
let raw = raw.trim();
if raw.is_empty() || raw == "*" {
+1 -1
View File
@@ -77,7 +77,7 @@ fn retention_deadline(
}
fn has_validator(headers: &HeaderMap) -> bool {
headers.contains_key("etag") || headers.contains_key("last-modified")
headers.contains_key(::http::header::ETAG) || headers.contains_key(::http::header::LAST_MODIFIED)
}
fn stale_forbidden(must_revalidate: bool, proxy_revalidate: bool) -> bool {
+29 -29
View File
@@ -1,6 +1,6 @@
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
for header in headers.get_all(::http::header::CACHE_CONTROL) {
for raw in header.to_str().ok()?.split(',') {
let raw = raw.trim();
if raw.is_empty() {
@@ -49,7 +49,7 @@ fn compatible_freshness_headers<'a>(
headers: &'a HeaderMap,
compatible: bool,
) -> std::borrow::Cow<'a, HeaderMap> {
if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) {
if !compatible || (!headers.contains_key(::http::header::CACHE_CONTROL) && !headers.contains_key(::http::header::PRAGMA)) {
return std::borrow::Cow::Borrowed(headers);
}
@@ -70,8 +70,8 @@ fn compatible_freshness_headers<'a>(
let mut changed = false;
if pragma_no_cache(headers) {
freshness.remove("pragma");
for header in headers.get_all("pragma") {
freshness.remove(::http::header::PRAGMA);
for header in headers.get_all(::http::header::PRAGMA) {
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
@@ -84,14 +84,14 @@ fn compatible_freshness_headers<'a>(
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
return std::borrow::Cow::Borrowed(headers);
};
freshness.append("pragma", value);
freshness.append(::http::header::PRAGMA, value);
}
}
changed = true;
}
if headers.contains_key("cache-control") {
if headers.contains_key(::http::header::CACHE_CONTROL) {
let mut retained_values = Vec::new();
for header in headers.get_all("cache-control") {
for header in headers.get_all(::http::header::CACHE_CONTROL) {
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
@@ -118,9 +118,9 @@ fn compatible_freshness_headers<'a>(
}
}
if changed {
freshness.remove("cache-control");
freshness.remove(::http::header::CACHE_CONTROL);
for value in retained_values {
freshness.append("cache-control", value);
freshness.append(::http::header::CACHE_CONTROL, value);
}
}
}
@@ -140,7 +140,7 @@ pub fn request_freshness_headers<'a>(
pub fn only_if_cached(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.get_all(::http::header::CACHE_CONTROL)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
@@ -170,7 +170,7 @@ impl RequestPolicy {
}
fn pragma_no_cache(headers: &HeaderMap) -> bool {
headers
.get_all("pragma")
.get_all(::http::header::PRAGMA)
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| {
@@ -193,7 +193,7 @@ pub fn forced_request_policy(
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
if headers.contains_key(::http::header::IF_RANGE) && !headers.contains_key(::http::header::RANGE) {
return RequestPolicy::Bypass("request_precondition");
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
@@ -225,7 +225,7 @@ pub fn request_policy(
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
if headers.contains_key(::http::header::IF_RANGE) && !headers.contains_key(::http::header::RANGE) {
return RequestPolicy::Bypass("request_precondition");
}
if settings.bypass_query && has_query {
@@ -285,7 +285,7 @@ pub fn request_policy(
}
}
if !settings.varnish_compatible
&& !headers.contains_key("cache-control")
&& !headers.contains_key(::http::header::CACHE_CONTROL)
&& pragma_no_cache(headers)
{
return RequestPolicy::Revalidate("request_pragma_no_cache");
@@ -327,7 +327,7 @@ fn weak_etag(value: &str) -> Option<&str> {
}
fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> Option<bool> {
let target = weak_etag(etag)?;
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
let values: Vec<_> = headers.get_all(::http::header::IF_NONE_MATCH).iter().collect();
if values.is_empty() {
return None;
}
@@ -362,7 +362,7 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
let Some(target) = weak_etag(etag) else {
return false;
};
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
let values: Vec<_> = headers.get_all(::http::header::IF_NONE_MATCH).iter().collect();
if values.len() != 1 {
return false;
}
@@ -381,22 +381,22 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
if request.contains_key(::http::header::IF_NONE_MATCH) {
return response
.get("etag")
.get(::http::header::ETAG)
.and_then(|etag| etag.to_str().ok())
.and_then(|etag| if_none_match_matches(request, etag))
.unwrap_or(false);
}
let Some(since) = request
.get("if-modified-since")
.get(::http::header::IF_MODIFIED_SINCE)
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
else {
return false;
};
response
.get("last-modified")
.get(::http::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
.is_some_and(|modified| modified <= since)
@@ -411,8 +411,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
}
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
if !validation.contains_key("age") {
merged.remove("age");
if !validation.contains_key(::http::header::AGE) {
merged.remove(::http::header::AGE);
}
for name in validation.keys() {
if matches!(
@@ -426,16 +426,16 @@ pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) ->
merged.append(name.clone(), value.clone());
}
}
if !validation.contains_key("date") {
if !validation.contains_key(::http::header::DATE) {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
merged.insert("date", value);
merged.insert(::http::header::DATE, value);
}
}
merged
}
pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
headers.get_all("vary").iter().any(|value| {
headers.get_all(::http::header::VARY).iter().any(|value| {
value.to_str().ok().is_some_and(|value| {
value
.split(',')
@@ -465,13 +465,13 @@ pub fn response_reason(
if status != StatusCode::OK {
return "response_status";
}
if headers.contains_key("set-cookie") {
if headers.contains_key(::http::header::SET_COOKIE) {
return "response_set_cookie";
}
if headers.contains_key("content-range") {
if headers.contains_key(::http::header::CONTENT_RANGE) {
return "response_range";
}
if headers.contains_key("trailer") {
if headers.contains_key(::http::header::TRAILER) {
return "response_trailers";
}
let Some(control) = directives(headers) else {
@@ -485,7 +485,7 @@ pub fn response_reason(
if settings.varnish_compatible && surrogate_no_store(headers) {
return "response_surrogate_control";
}
for value in headers.get_all("vary") {
for value in headers.get_all(::http::header::VARY) {
if value.to_str().ok().is_none_or(|value| {
value.split(',').any(|name| {
let name = name.trim();
+13 -20
View File
@@ -61,9 +61,9 @@ pub fn plan(
return None;
}
if status != StatusCode::OK
|| headers.contains_key("set-cookie")
|| headers.contains_key("content-range")
|| headers.contains_key("trailer")
|| headers.contains_key(::http::header::SET_COOKIE)
|| headers.contains_key(::http::header::CONTENT_RANGE)
|| headers.contains_key(::http::header::TRAILER)
{
return None;
}
@@ -75,7 +75,7 @@ pub fn plan(
return None;
}
if headers
.get("content-type")
.get(::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
let v = v.trim_start().to_ascii_lowercase();
@@ -92,14 +92,11 @@ pub fn plan(
{
return None;
}
// Surrogate-Control: no-store is a shared-cache veto in compatibility
// mode, regardless of public/fallback TTL or any other directive.
// Surrogate-Control: no-store always vetoes shared caching.
if settings.varnish_compatible && surrogate_no_store(headers) {
return None;
}
// Explicitly configured fallback TTL is an opt-in origin no-cache
// override *only* for public responses. With fallback disabled, keep
// the pre-0.34.7 validator-based revalidation and never grant a HIT.
// Fallback TTL overrides origin no-cache only for public responses.
let override_public_no_cache = settings.varnish_compatible
&& control.contains_key("no-cache")
&& control.contains_key("public")
@@ -117,13 +114,13 @@ pub fn plan(
let mut stale_while_revalidate = parse_window("stale-while-revalidate")?;
let stale_if_error = parse_window("stale-if-error")?;
if requires_revalidation
&& !headers.contains_key("etag")
&& !headers.contains_key("last-modified")
&& !headers.contains_key(::http::header::ETAG)
&& !headers.contains_key(::http::header::LAST_MODIFIED)
{
return None;
}
let now = SystemTime::now();
let date = match headers.get("date") {
let date = match headers.get(::http::header::DATE) {
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
None => now,
};
@@ -131,7 +128,7 @@ pub fn plan(
Some(settings.default_ttl_seconds)
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
} else if let Some(expires) = headers.get(::http::header::EXPIRES) {
Some(
httpdate::parse_http_date(expires.to_str().ok()?)
.ok()?
@@ -144,7 +141,7 @@ pub fn plan(
} else {
None
};
let age = match headers.get("age") {
let age = match headers.get(::http::header::AGE) {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
@@ -161,11 +158,7 @@ pub fn plan(
None if requires_revalidation => 0,
None => return None,
};
// Retain zero-TTL and already-expired representations for conditional
// revalidation, client max-stale or stale-if-error. They must not be
// served as fresh HITs. Varnish-style grace is only implicit when the
// origin did not explicitly set stale-while-revalidate (including =0),
// the object arrived fresh and no origin no-cache rule is in effect.
// Expired entries are kept for revalidation only; implicit grace needs no explicit SWR/no-cache.
if settings.varnish_compatible
&& !control.contains_key("stale-while-revalidate")
&& !control.contains_key("no-cache")
@@ -184,7 +177,7 @@ pub fn plan(
{
return None;
}
let expected_length = match headers.get("content-length") {
let expected_length = match headers.get(::http::header::CONTENT_LENGTH) {
Some(v) => Some(v.to_str().ok()?.parse::<usize>().ok()?),
None => None,
};
+9 -12
View File
@@ -12,11 +12,11 @@ pub enum RangeSelection {
}
pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
let mut values = headers.get_all("range").iter();
let mut values = headers.get_all(::http::header::RANGE).iter();
let Some(value) = values.next() else {
return RangeSelection::None;
};
if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 {
if values.next().is_some() || headers.get_all(::http::header::IF_RANGE).iter().count() > 1 {
return RangeSelection::Unsupported;
}
if method != Method::GET {
@@ -74,7 +74,7 @@ fn if_range_matches(headers: &HeaderMap, if_range: Option<&HeaderValue>) -> bool
return false;
}
return headers
.get("etag")
.get(::http::header::ETAG)
.and_then(|value| value.to_str().ok())
.is_some_and(|etag| !etag.trim().starts_with("W/") && etag.trim() == candidate);
}
@@ -82,7 +82,7 @@ fn if_range_matches(headers: &HeaderMap, if_range: Option<&HeaderValue>) -> bool
return false;
};
headers
.get("last-modified")
.get(::http::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
.is_some_and(|modified| modified <= candidate_date)
@@ -127,32 +127,29 @@ impl Hit {
let full_len = self.body.len();
self.response
.headers_mut()
.insert("accept-ranges", HeaderValue::from_static("bytes"));
.insert(::http::header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
self.response
.extensions_mut()
.insert(crate::compression::Skip);
let Some((start, end)) = resolve_byte_range(range, full_len) else {
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*self.response.body_mut() = Body::empty();
self.response.headers_mut().insert(
"content-range",
self.response.headers_mut().insert(::http::header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{full_len}")).expect("generated range"),
);
self.response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
.insert(::http::header::CONTENT_LENGTH, HeaderValue::from_static("0"));
return self;
};
let slice = self.body.slice(start..=end);
*self.response.status_mut() = StatusCode::PARTIAL_CONTENT;
*self.response.body_mut() = Body::from(slice.clone());
self.response.headers_mut().insert(
"content-range",
self.response.headers_mut().insert(::http::header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {start}-{end}/{full_len}"))
.expect("generated range"),
);
self.response.headers_mut().insert(
"content-length",
self.response.headers_mut().insert(::http::header::CONTENT_LENGTH,
HeaderValue::from_str(&slice.len().to_string()).expect("generated length"),
);
self.body = slice;
+3 -5
View File
@@ -294,9 +294,8 @@ impl Cache {
}
let hits = cached.hits.saturating_add(1);
let mut headers = cached.headers.clone();
headers.insert("age", HeaderValue::from_str(&cached.age.to_string()).ok()?);
headers.append(
"warning",
headers.insert(::http::header::AGE, HeaderValue::from_str(&cached.age.to_string()).ok()?);
headers.append(::http::header::WARNING,
HeaderValue::from_static("110 RustProxy \"Response is stale\""),
);
let response = cached.response(headers);
@@ -396,8 +395,7 @@ impl Cache {
}
impl Ticket {
/// Ignore only browser forced reload for freshness; keep min-fresh,
/// max-stale and other client constraints, plus original Vary headers.
/// Ignores forced-reload directives only; other client constraints apply.
pub fn with_varnish_compatible(mut self, compatible: bool) -> Self {
if let std::borrow::Cow::Owned(freshness) =
compatible_freshness_headers(&self.request_headers, compatible)
+28 -17
View File
@@ -15,7 +15,7 @@ impl Predicate for MinimumSize {
fn should_compress<B: http_body::Body>(&self, response: &http::Response<B>) -> bool {
let size = response
.headers()
.get(http::header::CONTENT_LENGTH)
.get(::http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.or_else(|| response.body().size_hint().exact());
@@ -147,7 +147,7 @@ impl AppliedEncoding {
pub fn no_transform(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.get_all(::http::header::CACHE_CONTROL)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
@@ -165,9 +165,13 @@ pub fn no_transform(headers: &HeaderMap) -> bool {
pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap {
let mut selected = HeaderMap::new();
for name in ["accept-encoding", "range", "cache-control"] {
for value in headers.get_all(name) {
selected.append(name, value.clone());
for name in [
::http::header::ACCEPT_ENCODING,
::http::header::RANGE,
::http::header::CACHE_CONTROL,
] {
for value in headers.get_all(&name) {
selected.append(name.clone(), value.clone());
}
}
selected
@@ -185,10 +189,14 @@ fn eligible(
|| response.status().is_informational()
|| response.status().as_u16() == 204
|| response.status().as_u16() == 304
|| response.headers().contains_key("content-encoding")
|| response.headers().contains_key("content-range")
|| response.headers().contains_key("upgrade")
|| request.contains_key("range")
|| response
.headers()
.contains_key(::http::header::CONTENT_ENCODING)
|| response
.headers()
.contains_key(::http::header::CONTENT_RANGE)
|| response.headers().contains_key(::http::header::UPGRADE)
|| request.contains_key(::http::header::RANGE)
|| no_transform(request)
|| no_transform(response.headers())
{
@@ -196,7 +204,7 @@ fn eligible(
}
!response
.headers()
.get("content-type")
.get(::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(';')
@@ -221,7 +229,7 @@ pub(crate) fn preferred_encoding(
) -> Option<AppliedEncoding> {
let mut explicit = [None; 4];
let mut wildcard = None;
for line in headers.get_all("accept-encoding").iter() {
for line in headers.get_all(::http::header::ACCEPT_ENCODING).iter() {
let Ok(line) = line.to_str() else { continue };
for item in line.split(',') {
let mut parts = item.split(';');
@@ -304,17 +312,20 @@ pub async fn encode(
let mut request = Request::new(Body::empty());
*request.method_mut() = method.clone();
request.headers_mut().insert(
"accept-encoding",
::http::header::ACCEPT_ENCODING,
HeaderValue::from_static(selected.as_str()),
);
let mut response = match compression.oneshot(request).await {
Ok(response) => response.map(Body::new),
Err(never) => match never {},
};
if response.headers().contains_key("content-encoding") {
if response
.headers()
.contains_key(::http::header::CONTENT_ENCODING)
{
if let Some(applied) = response
.headers()
.get("content-encoding")
.get(::http::header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.and_then(AppliedEncoding::from_header)
{
@@ -322,15 +333,15 @@ pub async fn encode(
}
if let Some(value) = response
.headers()
.get("etag")
.get(::http::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
{
if !value.starts_with("W/") {
if let Ok(weak) = HeaderValue::from_str(&format!("W/{value}")) {
response.headers_mut().insert("etag", weak);
response.headers_mut().insert(::http::header::ETAG, weak);
} else {
response.headers_mut().remove("etag");
response.headers_mut().remove(::http::header::ETAG);
}
}
}
+5 -4
View File
@@ -293,7 +293,7 @@ impl Compiled {
"for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\""
);
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
values.insert(::http::header::FORWARDED, forwarded.parse()?);
values.insert("x-forwarded-for", peer_header.clone());
values.insert("x-real-ip", peer_header);
values.insert("x-forwarded-host", authority.clone());
@@ -384,7 +384,7 @@ impl Compiled {
}
write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?;
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
values.insert(::http::header::FORWARDED, forwarded.parse()?);
values.insert("x-forwarded-for", xff.parse()?);
values.insert("x-real-ip", client_ip.to_string().parse()?);
values.insert("x-forwarded-host", authority.clone());
@@ -436,7 +436,7 @@ impl Context {
self.values.insert("x-forwarded-for", xff.parse()?);
let existing = self
.values
.get("forwarded")
.get(::http::header::FORWARDED)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let forwarded = if existing.is_empty() {
@@ -444,7 +444,8 @@ impl Context {
} else {
format!("for={}, {existing}", node(client))
};
self.values.insert("forwarded", forwarded.parse()?);
self.values
.insert(::http::header::FORWARDED, forwarded.parse()?);
}
self.values.insert("x-real-ip", client_text.parse()?);
self.client_ip = client;
+6
View File
@@ -35,6 +35,12 @@ impl PeerFormatting {
untrusted_headers: std::sync::Mutex::new(None),
}
}
/// Text form of the peer address when it needed no canonicalization.
pub(crate) fn raw_peer_text(&self) -> Option<&str> {
(self.peer.ip() == self.peer_ip)
.then(|| self.peer_header.to_str().ok())
.flatten()
}
fn cached_untrusted(&self, authority: &HeaderValue, tls: bool) -> Option<HeaderMap> {
self.untrusted_headers
.lock()
+5 -5
View File
@@ -184,7 +184,7 @@ pub fn withdraw_advertisement(
tls: bool,
active: &[Listener],
) {
headers.remove("alt-svc");
headers.remove(::http::header::ALT_SVC);
if tls
&& local.is_some_and(|local| {
active
@@ -192,7 +192,7 @@ pub fn withdraw_advertisement(
.any(|binding| binding.matches(local, true) && binding.http3.is_some())
})
{
headers.insert("alt-svc", HeaderValue::from_static("clear"));
headers.insert(::http::header::ALT_SVC, HeaderValue::from_static("clear"));
}
}
@@ -204,7 +204,7 @@ pub fn advertise(
desired: &[Listener],
host_http3_enabled: bool,
) {
headers.remove("alt-svc");
headers.remove(::http::header::ALT_SVC);
if !tls {
return;
}
@@ -225,7 +225,7 @@ pub fn advertise(
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);
headers.insert(::http::header::ALT_SVC, value);
}
}
}
@@ -436,7 +436,7 @@ async fn connection_loop(
);
send.stop_stream(Code::H3_MESSAGE_ERROR);return;
}
let expected=request.headers().get("content-length").and_then(|v|v.to_str().ok()).and_then(|v|v.parse::<u64>().ok());
let expected=request.headers().get(::http::header::CONTENT_LENGTH).and_then(|v|v.to_str().ok()).and_then(|v|v.parse::<u64>().ok());
let mut tunnel_receive=None;
let mut request=if websocket {
tunnel_receive=Some(receive);
+3 -3
View File
@@ -9,11 +9,11 @@ fn invalid_headers(headers: &HeaderMap) -> bool {
.iter()
.any(|name| headers.contains_key(*name))
|| headers
.get_all("te")
.get_all(::http::header::TE)
.iter()
.any(|value| value.as_bytes() != b"trailers")
|| headers.get_all("content-length").iter().count() > 1
|| headers.get("content-length").is_some_and(|value| {
|| headers.get_all(::http::header::CONTENT_LENGTH).iter().count() > 1
|| headers.get(::http::header::CONTENT_LENGTH).is_some_and(|value| {
value
.to_str()
.ok()
-2
View File
@@ -1,5 +1,3 @@
// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's
// argument-count threshold is stylistic and does not improve these call sites.
#![allow(clippy::too_many_arguments)]
#![cfg_attr(test, allow(clippy::field_reassign_with_default))]
+3 -2
View File
@@ -1,8 +1,9 @@
// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's
// argument-count threshold is stylistic and does not improve these call sites.
#![allow(clippy::too_many_arguments)]
#![cfg_attr(test, allow(clippy::field_reassign_with_default))]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
include!("main/bootstrap.rs");
include!("main/admin_runtime.rs");
include!("main/proxy_runtime.rs");
+12 -8
View File
@@ -183,7 +183,7 @@ impl Page {
*response.status_mut() = status;
let headers = response.headers_mut();
headers.insert(
"content-type",
::http::header::CONTENT_TYPE,
HeaderValue::from_static(if matches!(self.kind, Kind::Empty) {
"text/plain; charset=utf-8"
} else {
@@ -192,15 +192,18 @@ impl Page {
);
if !bodyless {
if let Ok(value) = HeaderValue::from_str(&length.to_string()) {
headers.insert("content-length", value);
headers.insert(::http::header::CONTENT_LENGTH, value);
}
}
headers.insert("cache-control", HeaderValue::from_static("no-store"));
headers.insert(
::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
if matches!(self.kind, Kind::Welcome) {
response.extensions_mut().insert(MinimalWelcome);
} else {
headers.insert(
"x-content-type-options",
::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
}
@@ -213,10 +216,11 @@ impl Page {
*response.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
response
.headers_mut()
.insert("allow", HeaderValue::from_static("GET, HEAD"));
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::ALLOW, HeaderValue::from_static("GET, HEAD"));
response.headers_mut().insert(
::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
response.extensions_mut().insert(PageServed);
return response;
}
+1 -2
View File
@@ -97,8 +97,7 @@ mod tests {
assert!(gateway_502.contains("RustProxy"));
assert!(gateway_502.contains("#131615"));
assert!(!gateway_502.contains("Tinyauth"));
// The standard HTTP error registry and its stock pages predate the
// Tinyauth-specific diagnostic pages and must remain unchanged.
// Stock error pages must stay unchanged.
assert_eq!(
ERROR_CODES,
[400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
+22 -21
View File
@@ -97,7 +97,7 @@ fn enforce_local_page_no_cache(response: &mut Response) {
}
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
fn error(status: StatusCode, detail: &'static str) -> Response {
@@ -108,12 +108,10 @@ fn error(status: StatusCode, detail: &'static str) -> Response {
);
let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) {
let mut response = (status, html).into_response();
response.headers_mut().insert(
"content-type",
response.headers_mut().insert(::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
"x-content-type-options",
response.headers_mut().insert(::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response
@@ -127,9 +125,8 @@ fn error(status: StatusCode, detail: &'static str) -> Response {
};
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(
"x-content-type-options",
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.extensions_mut().insert(LocalGeneratedResponse);
@@ -145,7 +142,7 @@ fn method_policy_denial(policy: &crate::vhost::MethodPolicy, method: &Method) ->
"RustProxy: HTTP method denied by access policy\n",
);
if let Some(value) = policy.allow_header() {
response.headers_mut().insert(http::header::ALLOW, value);
response.headers_mut().insert(::http::header::ALLOW, value);
}
Some(response)
}
@@ -162,7 +159,7 @@ fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Metho
})
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response());
if let Ok(value) = HeaderValue::from_str(&spec.content_type) {
response.headers_mut().insert("content-type", value);
response.headers_mut().insert(::http::header::CONTENT_TYPE, value);
}
response
}
@@ -283,8 +280,7 @@ fn finish_headers(
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
.apply(response.headers_mut(), request_id);
if frontend.is_some_and(|f| f.auth_provider.is_some() || f.basic_auth.is_some()) {
response.headers_mut().insert(
"cache-control",
response.headers_mut().insert(::http::header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
}
@@ -330,14 +326,21 @@ fn apply_backend_diagnostic_values(
backend_header: bool,
backend_timing_header: bool,
) {
response.headers_mut().remove("x-rustproxy-backend");
response.headers_mut().remove("x-rust-proxy-backend");
response.headers_mut().remove("x-rust-proxy-backend-time");
response.headers_mut().remove("x-rustproxy-backend-time");
const BACKEND: ::http::HeaderName = ::http::HeaderName::from_static("x-rustproxy-backend");
const LEGACY_BACKEND: ::http::HeaderName = ::http::HeaderName::from_static("x-rust-proxy-backend");
const LEGACY_BACKEND_TIME: ::http::HeaderName =
::http::HeaderName::from_static("x-rust-proxy-backend-time");
const BACKEND_TIME: ::http::HeaderName = ::http::HeaderName::from_static("x-rustproxy-backend-time");
let headers = response.headers_mut();
for name in [&BACKEND, &LEGACY_BACKEND, &LEGACY_BACKEND_TIME, &BACKEND_TIME] {
if headers.contains_key(name) {
headers.remove(name);
}
}
if backend_header {
if let Some(origin) = response.extensions().get::<OriginBackend>() {
if let Ok(value) = HeaderValue::from_str(&origin.0) {
response.headers_mut().insert("x-rust-proxy-backend", value);
response.headers_mut().insert(LEGACY_BACKEND, value);
}
}
}
@@ -345,9 +348,7 @@ fn apply_backend_diagnostic_values(
if let Some(origin) = response.extensions().get::<OriginTiming>() {
let value = format!("{:.2}ms", origin.0.as_secs_f64() * 1000.0);
if let Ok(value) = HeaderValue::from_str(&value) {
response
.headers_mut()
.insert("x-rustproxy-backend-time", value);
response.headers_mut().insert(BACKEND_TIME, value);
}
}
}
@@ -416,7 +417,7 @@ async fn normalize_h3_h1_unknown_body(
if request.version() != Version::HTTP_3
|| upstream_version != Version::HTTP_11
|| method == Method::CONNECT
|| request.headers().contains_key(http::header::CONTENT_LENGTH)
|| request.headers().contains_key(::http::header::CONTENT_LENGTH)
|| request.body().size_hint().exact().is_some()
{
return Ok(());
+4 -4
View File
@@ -11,17 +11,17 @@ fn cache_http_invalidation_request(
) -> Result<crate::cache::InvalidationRequest, &'static str> {
let mut lengths = request
.headers()
.get_all(http::header::CONTENT_LENGTH)
.get_all(::http::header::CONTENT_LENGTH)
.iter();
if let Some(length) = lengths.next() {
if lengths.next().is_some() || length.as_bytes() != b"0" {
return Err("BAN/PURGE must not contain a request body");
}
}
if request.headers().contains_key("upgrade")
if request.headers().contains_key(::http::header::UPGRADE)
|| request
.headers()
.contains_key(http::header::TRANSFER_ENCODING)
.contains_key(::http::header::TRANSFER_ENCODING)
|| !request.body().is_end_stream()
{
return Err("BAN/PURGE must not contain a request body or upgrade");
@@ -207,6 +207,6 @@ async fn cache_http_invalidation(
let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response();
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
+1 -1
View File
@@ -90,7 +90,7 @@ pub(super) fn range_selection(
) -> crate::cache::RangeSelection {
if cache_eligible {
crate::cache::request_range(method, headers)
} else if headers.contains_key("range") {
} else if headers.contains_key(::http::header::RANGE) {
crate::cache::RangeSelection::Unsupported
} else {
crate::cache::RangeSelection::None
+1 -1
View File
@@ -207,7 +207,7 @@ if only_cached {
"RustProxy: no reusable cached response\n",
);
cache_settings.annotate(response.headers_mut(), "MISS", 0, 0);
response.headers_mut().remove("cache-status");
response.headers_mut().remove(::http::header::CACHE_STATUS);
return response;
}
+2 -2
View File
@@ -85,11 +85,11 @@
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
if status == StatusCode::SERVICE_UNAVAILABLE {
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
.insert(::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
}
response
}
+11 -11
View File
@@ -22,8 +22,8 @@ if let Some(spec) = &route.redirect {
};
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::FOUND);
let mut response = status.into_response();
response.headers_mut().insert(http::header::LOCATION, location);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(::http::header::LOCATION, location);
response.headers_mut().insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
return response;
}
if route.unavailable {
@@ -33,7 +33,7 @@ if route.unavailable {
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
.insert(::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
if let Some(fallback) = execute_error_fallback(
state.clone(), peer, local, &snapshot, &host, tls, route,
response.status(), &fallback_request, &fallback_body,
@@ -61,7 +61,7 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
.insert(::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
if let Some(fallback) = execute_error_fallback(
state.clone(), peer, local, &snapshot, &host, tls, route,
response.status(), &fallback_request, &fallback_body,
@@ -77,7 +77,7 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
.insert(::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
if let Some(fallback) = execute_error_fallback(
state.clone(), peer, local, &snapshot, &host, tls, route,
response.status(), &fallback_request, &fallback_body,
@@ -253,7 +253,7 @@ if let Some(identity) = identity {
if websocket_legacy_support {
if let Some(upstream_host) = request.headers().get(HOST).cloned() {
if let Some(origin) = legacy_websocket_origin(&backend.request_origin, &upstream_host) {
request.headers_mut().insert("origin", origin);
request.headers_mut().insert(::http::header::ORIGIN, origin);
}
}
}
@@ -263,8 +263,8 @@ let effective_policy = cache_plan::combine_policy(original_policy, || {
request.uri().query().is_some(), cache_settings,
)
});
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
let force_vary_cookie = force_cache && request.headers().contains_key(::http::header::COOKIE);
let force_vary_authorization = force_cache && request.headers().contains_key(::http::header::AUTHORIZATION);
let effective_reason = effective_policy.bypass_reason();
let revalidation_reason = effective_policy.revalidation_reason();
let effective_cacheable = effective_reason.is_none();
@@ -272,15 +272,15 @@ let range_selection = cache_plan::range_selection(
effective_cacheable, request.method(), request.headers(),
);
let if_range = if effective_cacheable {
request.headers().get("if-range").cloned()
request.headers().get(::http::header::IF_RANGE).cloned()
} else { None };
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let cache_route = route.id.as_str();
let mut cache_key_headers = None;
if effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") {
if effective_cacheable && ignore_static_cookie && request.headers().contains_key(::http::header::COOKIE) {
let mut headers = request.headers().clone();
headers.remove("cookie");
headers.remove(::http::header::COOKIE);
cache_key_headers = Some(headers);
}
let key_headers = cache_key_headers
+5
View File
@@ -135,6 +135,11 @@ if let Some(exchange) = &exchange {
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
if fill_leader.is_some() && (origin_plan.is_none() || transformed_plan.is_none()) {
if let Some(ticket) = cache_ticket.as_ref() {
state.cache.remember_uncacheable(ticket);
}
}
if let (Some(ticket), Some(plan), Some(transformed)) =
(cache_ticket, origin_plan, transformed_plan)
{
+13 -14
View File
@@ -137,8 +137,8 @@ let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| {
let origin_no_transform = crate::transform::no_transform(response.headers());
let skip_compression = crate::compression::no_transform(response.headers());
let origin_validators = crate::transform::OriginValidators {
etag: response.headers().get("etag").cloned(),
last_modified: response.headers().get("last-modified").cloned(),
etag: response.headers().get(::http::header::ETAG).cloned(),
last_modified: response.headers().get(::http::header::LAST_MODIFIED).cloned(),
};
let (mut parts, body) = response.into_parts();
parts.extensions.insert(origin_validators);
@@ -147,8 +147,8 @@ if skip_compression || range_requested {
}
headers::strip_hop_by_hop(&mut parts.headers);
if websocket_legacy_support && handshake.is_some() {
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
parts.headers.remove(::http::header::CONTENT_LENGTH);
parts.headers.remove(::http::header::TRANSFER_ENCODING);
}
apply_response_rules(
&mut parts.headers,
@@ -161,7 +161,7 @@ if let Some(pool) = &route.pool {
pool.strip_affinity_response(&mut parts.headers);
}
if let Some(cookie) = active_affinity_cookie {
parts.headers.append("set-cookie", cookie);
parts.headers.append(::http::header::SET_COOKIE, cookie);
}
let h3_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h3())
&& upgraded.is_some();
@@ -208,9 +208,9 @@ let mut response = if h3_tunnel {
};
exchange.websocket_phase("tunnel_ready");
parts.status = StatusCode::OK;
parts.headers.remove("sec-websocket-accept");
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
parts.headers.remove(::http::header::SEC_WEBSOCKET_ACCEPT);
parts.headers.remove(::http::header::CONTENT_LENGTH);
parts.headers.remove(::http::header::TRANSFER_ENCODING);
parts.extensions.insert(crate::compression::Skip);
parts
.extensions
@@ -301,8 +301,8 @@ let mut response = if h3_tunnel {
"RustProxy: HTTP/2 WebSocket tunnel state unavailable\n",
);
};
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
parts.headers.remove(::http::header::CONTENT_LENGTH);
parts.headers.remove(::http::header::TRANSFER_ENCODING);
parts.extensions.insert(crate::compression::Skip);
Response::from_parts(
parts,
@@ -314,10 +314,10 @@ let mut response = if h3_tunnel {
{
parts
.headers
.insert("connection", HeaderValue::from_static("Upgrade"));
.insert(::http::header::CONNECTION, HeaderValue::from_static("Upgrade"));
parts
.headers
.insert("upgrade", HeaderValue::from_static("websocket"));
.insert(::http::header::UPGRADE, HeaderValue::from_static("websocket"));
websocket::spawn(
state.clone(),
downstream,
@@ -342,8 +342,7 @@ if intercept_origin_error {
Some(route),
admin_control_plane,
);
response.headers_mut().insert(
"cache-control",
response.headers_mut().insert(::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
}
+17 -16
View File
@@ -4,27 +4,27 @@ let websocket_upstream_h2 = !websocket_legacy_support
&& matches!(backend.protocol,
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2);
if let Some(handshake) = &handshake {
request.headers_mut().remove("content-length");
request.headers_mut().remove("transfer-encoding");
request.headers_mut().remove(::http::header::CONTENT_LENGTH);
request.headers_mut().remove(::http::header::TRANSFER_ENCODING);
if websocket_upstream_h2 {
request.headers_mut().remove("connection");
request.headers_mut().remove("upgrade");
request.headers_mut().remove("sec-websocket-key");
request.headers_mut().remove("sec-websocket-accept");
request.headers_mut().remove(::http::header::CONNECTION);
request.headers_mut().remove(::http::header::UPGRADE);
request.headers_mut().remove(::http::header::SEC_WEBSOCKET_KEY);
request.headers_mut().remove(::http::header::SEC_WEBSOCKET_ACCEPT);
} else {
request
.headers_mut()
.insert("connection", HeaderValue::from_static("Upgrade"));
.insert(::http::header::CONNECTION, HeaderValue::from_static("Upgrade"));
request
.headers_mut()
.insert("upgrade", HeaderValue::from_static("websocket"));
.insert(::http::header::UPGRADE, HeaderValue::from_static("websocket"));
if !handshake.h1() {
*request.method_mut() = Method::GET;
request.headers_mut().remove("sec-websocket-key");
request.headers_mut().remove("sec-websocket-accept");
request.headers_mut().remove(::http::header::SEC_WEBSOCKET_KEY);
request.headers_mut().remove(::http::header::SEC_WEBSOCKET_ACCEPT);
if let Some(key) = handshake.upstream_key() {
if let Ok(key) = HeaderValue::from_str(key) {
request.headers_mut().insert("sec-websocket-key", key);
request.headers_mut().insert(::http::header::SEC_WEBSOCKET_KEY, key);
} else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -39,7 +39,7 @@ if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
auth.apply_upstream(request.headers_mut(), basic_identity);
}
if range_requested || transform_settings.is_some_and(|settings| settings.enabled) {
request.headers_mut().remove("accept-encoding");
request.headers_mut().remove(::http::header::ACCEPT_ENCODING);
}
let mut revalidation_sent = false;
if pending_revalidation {
@@ -61,10 +61,11 @@ let pool_retry_possible = route.backend_id.is_none()
let h2_replay_possible = !admin_control_plane
&& handshake.is_none()
&& retry_safe_method(&retry_method)
&& matches!(backend.protocol,
crate::config::UpstreamProtocol::Http2
| crate::config::UpstreamProtocol::H2
| crate::config::UpstreamProtocol::Auto);
&& match backend.protocol {
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2 => true,
crate::config::UpstreamProtocol::Auto => !backend.origin.starts_with("http://"),
_ => false,
};
let replay_headers = ((pool_retry_possible || h2_replay_possible)
&& (request.body().size_hint().exact() == Some(0)
|| request.version() == Version::HTTP_3))
+19 -19
View File
@@ -14,9 +14,9 @@
.get::<crate::server::sendfile::SendfileSlots>().cloned();
#[cfg(target_os = "linux")]
let identity_request = request.version() == http::Version::HTTP_11
&& !request.headers().contains_key("accept-encoding")
&& !request.headers().contains_key("range")
&& !request.headers().contains_key("if-range");
&& !request.headers().contains_key(::http::header::ACCEPT_ENCODING)
&& !request.headers().contains_key(::http::header::RANGE)
&& !request.headers().contains_key(::http::header::IF_RANGE);
let cache_settings = route.frontend.as_ref()
.map(|frontend| &frontend.cache)
.unwrap_or(&snapshot.envelope.config.cache);
@@ -54,9 +54,9 @@
request_uri.query().is_some(), cache_settings,
);
let eligible = enabled && policy.bypass_reason().is_none();
let key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") {
let key_headers = if eligible && ignore_cookie && request_headers.contains_key(::http::header::COOKIE) {
let mut headers = request_headers.clone();
headers.remove("cookie");
headers.remove(::http::header::COOKIE);
Some(headers)
} else { None };
let cache_ticket = if eligible {
@@ -67,7 +67,7 @@
.map(|ticket| ticket.with_varnish_compatible(cache_settings.varnish_compatible))
} else { None };
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
let if_range = if eligible { request_headers.get(::http::header::IF_RANGE).cloned() } else { None };
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
@@ -181,18 +181,18 @@
}
let origin_headers = response.headers();
let origin_safe = cache_ticket.is_some() && {
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
let explicit_origin_policy = origin_headers.contains_key(::http::header::CACHE_CONTROL)
|| origin_headers.contains_key(::http::header::EXPIRES)
|| origin_headers.contains_key(::http::header::SET_COOKIE)
|| origin_headers.contains_key(::http::header::VARY);
!explicit_origin_policy || crate::cache::plan(response.status(), origin_headers,
cache_settings, started.elapsed()).is_some()
};
let no_transform = crate::transform::no_transform(origin_headers);
let skip_compression = crate::compression::no_transform(origin_headers);
let origin_validators = crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
etag: origin_headers.get(::http::header::ETAG).cloned(),
last_modified: origin_headers.get(::http::header::LAST_MODIFIED).cloned(),
};
if skip_compression || range_requested {
response.extensions_mut().insert(crate::compression::Skip);
@@ -201,7 +201,7 @@
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
if response.extensions().get::<crate::pages::PageServed>().is_some() {
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
@@ -250,8 +250,8 @@
let mut plan = if canonical { origin.without_vary("accept-encoding") }
else { origin };
if force_cache {
if request_headers.contains_key("cookie") { plan = plan.with_vary("cookie"); }
if request_headers.contains_key("authorization") { plan = plan.with_vary("authorization"); }
if request_headers.contains_key(::http::header::COOKIE) { plan = plan.with_vary("cookie"); }
if request_headers.contains_key(::http::header::AUTHORIZATION) { plan = plan.with_vary("authorization"); }
}
response = state.cache.wrap_with_fill(response, ticket,
plan.restrict(transformed), cache_settings, None);
@@ -278,15 +278,15 @@
if !enabled && identity_request && request_method == Method::GET
&& !transform_settings.is_some_and(|settings| settings.enabled)
&& response.status() == StatusCode::OK
&& !response.headers().contains_key("content-encoding")
&& !response.headers().contains_key("transfer-encoding")
&& !response.headers().contains_key("content-range")
&& !response.headers().contains_key(::http::header::CONTENT_ENCODING)
&& !response.headers().contains_key(::http::header::TRANSFER_ENCODING)
&& !response.headers().contains_key(::http::header::CONTENT_RANGE)
{
if let (Some(slots), Some(candidate)) = (
kernel_sendfile,
response.extensions().get::<crate::server::sendfile::SendfileCandidate>().cloned(),
) {
let correct_length = response.headers().get("content-length")
let correct_length = response.headers().get(::http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok()) == Some(candidate.length);
if correct_length {
+7 -7
View File
@@ -1,7 +1,7 @@
async fn forward(
State(state): State<Arc<App>>,
Extension(peer): Extension<PeerAddr>,
Extension(local): Extension<LocalAddr>,
state: Arc<App>,
peer: PeerAddr,
local: LocalAddr,
mut request: Request,
) -> Response {
let cookies = crate::auth_provider::CookieSink::default();
@@ -279,7 +279,7 @@ async fn forward_request(
country.as_deref(),
request
.headers()
.get(http::header::USER_AGENT)
.get(::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
&snapshot.global_security.rate_limit,
) {
@@ -288,7 +288,7 @@ async fn forward_request(
"RustProxy: global request rate limit exceeded\n",
);
if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) {
response.headers_mut().insert("retry-after", value);
response.headers_mut().insert(::http::header::RETRY_AFTER, value);
}
return response;
}
@@ -335,7 +335,7 @@ async fn forward_request(
country.as_deref(),
request
.headers()
.get(http::header::USER_AGENT)
.get(::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
&frontend.rate_limit,
) {
@@ -344,7 +344,7 @@ async fn forward_request(
"RustProxy: request rate limit exceeded\n",
);
if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) {
response.headers_mut().insert("retry-after", value);
response.headers_mut().insert(::http::header::RETRY_AFTER, value);
}
return response;
}
+23 -21
View File
@@ -27,22 +27,24 @@ pub fn strip_hop_by_hop(headers: &mut HeaderMap) {
headers.remove(name);
}
}
const KEEP_ALIVE: HeaderName = HeaderName::from_static("keep-alive");
const PROXY_CONNECTION: HeaderName = HeaderName::from_static("proxy-connection");
for name in [
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"upgrade",
"trailer",
"te",
"proxy-authenticate",
"proxy-authorization",
CONNECTION,
KEEP_ALIVE,
PROXY_CONNECTION,
http::header::TRANSFER_ENCODING,
http::header::UPGRADE,
http::header::TRAILER,
http::header::TE,
http::header::PROXY_AUTHENTICATE,
http::header::PROXY_AUTHORIZATION,
] {
headers.remove(name);
}
}
pub fn requests_upgrade(headers: &HeaderMap) -> bool {
if headers.contains_key("upgrade") {
if headers.contains_key(::http::header::UPGRADE) {
return true;
}
let mut connection = headers.get_all(CONNECTION).iter();
@@ -67,13 +69,13 @@ pub fn requests_upgrade(headers: &HeaderMap) -> bool {
include!("tests.rs");
pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
if !headers.contains_key(http::header::COOKIE) {
if !headers.contains_key(::http::header::COOKIE) {
return Vec::new();
}
let mut retained = Vec::new();
let mut admin = Vec::new();
for part in headers
.get_all(http::header::COOKIE)
.get_all(::http::header::COOKIE)
.iter()
.filter_map(|h| h.to_str().ok())
.flat_map(|h| h.split(';'))
@@ -88,10 +90,10 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
retained.push(part.to_owned());
}
}
headers.remove(http::header::COOKIE);
headers.remove(::http::header::COOKIE);
if !retained.is_empty() {
if let Ok(value) = retained.join("; ").parse() {
headers.insert(http::header::COOKIE, value);
headers.insert(::http::header::COOKIE, value);
}
}
admin
@@ -102,22 +104,22 @@ pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) {
return;
}
let mut values: Vec<String> = headers
.get_all(http::header::COOKIE)
.get_all(::http::header::COOKIE)
.iter()
.filter_map(|h| h.to_str().ok().map(str::to_owned))
.collect();
values.extend(admin.iter().cloned());
headers.remove(http::header::COOKIE);
headers.remove(::http::header::COOKIE);
if let Ok(value) = values.join("; ").parse() {
headers.insert(http::header::COOKIE, value);
headers.insert(::http::header::COOKIE, value);
}
}
pub fn strip_admin_set_cookie(headers: &mut HeaderMap) {
if !headers.contains_key(http::header::SET_COOKIE) {
if !headers.contains_key(::http::header::SET_COOKIE) {
return;
}
let retained: Vec<_> = headers
.get_all(http::header::SET_COOKIE)
.get_all(::http::header::SET_COOKIE)
.iter()
.filter(|h| {
h.to_str()
@@ -127,8 +129,8 @@ pub fn strip_admin_set_cookie(headers: &mut HeaderMap) {
})
.cloned()
.collect();
headers.remove(http::header::SET_COOKIE);
headers.remove(::http::header::SET_COOKIE);
for value in retained {
headers.append(http::header::SET_COOKIE, value);
headers.append(::http::header::SET_COOKIE, value);
}
}
+3 -3
View File
@@ -46,7 +46,7 @@ fn canonical_host_redirect(
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Some(response)
}
@@ -62,9 +62,9 @@ fn force_ssl_redirect(
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response
.headers_mut()
.insert(http::header::LOCATION, location);
.insert(::http::header::LOCATION, location);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Some(response)
}
+21 -13
View File
@@ -9,9 +9,8 @@ use crate::{
use axum::{
body::Body,
extract::{Request, State},
middleware::{self, Next},
response::{IntoResponse, Response},
Extension, Router,
Router,
};
use body::{Direction, Exchange, ExchangeSource, MeteredBody};
use bytes::Bytes;
@@ -49,14 +48,23 @@ use std::{
};
pub fn router(state: Arc<App>) -> Router {
Router::new()
.fallback(forward)
.layer(middleware::from_fn_with_state(state.clone(), observe))
.layer(middleware::from_fn_with_state(
state.clone(),
crate::acme::challenge,
))
.with_state(state)
Router::new().fallback(entry).with_state(state)
}
async fn entry(State(state): State<Arc<App>>, request: Request) -> Response {
if crate::acme::is_challenge_path(request.uri().path()) {
return crate::acme::challenge(state, request).await;
}
observe(state, request).await
}
async fn dispatch(state: Arc<App>, request: Request) -> Response {
let peer = request.extensions().get::<PeerAddr>().copied();
let local = request.extensions().get::<LocalAddr>().copied();
let (Some(peer), Some(local)) = (peer, local) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Missing request extension").into_response();
};
forward(state, peer, local, request).await
}
const MAX_UPSTREAM_ERROR_DETAIL: usize = 2048;
@@ -320,7 +328,7 @@ async fn refresh_cache_once(
.as_ref()
.is_some_and(|settings| settings.enabled)
{
refresh_headers.remove("accept-encoding");
refresh_headers.remove(::http::header::ACCEPT_ENCODING);
}
let mut refresh = Request::new(Body::empty());
*refresh.method_mut() = Method::GET;
@@ -382,8 +390,8 @@ async fn refresh_cache_once(
let origin_no_transform = crate::transform::no_transform(origin.headers());
let skip_compression = crate::compression::no_transform(origin.headers());
let origin_validators = crate::transform::OriginValidators {
etag: origin.headers().get("etag").cloned(),
last_modified: origin.headers().get("last-modified").cloned(),
etag: origin.headers().get(::http::header::ETAG).cloned(),
last_modified: origin.headers().get(::http::header::LAST_MODIFIED).cloned(),
};
let (mut parts, body) = origin.into_parts();
parts.extensions.insert(origin_validators);
+12 -8
View File
@@ -41,7 +41,7 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool
.is_none()
}
async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
async fn observe(state: Arc<App>, mut request: Request) -> Response {
let snapshot = state.config.load_full();
let selected = response_context(&snapshot, &request);
let frontend_context = selected.frontend.clone();
@@ -65,7 +65,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
let is_head = method == Method::HEAD;
let ws_attempt = request
.headers()
.get("upgrade")
.get(::http::header::UPGRADE)
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket"))
|| (request.version() == Version::HTTP_2
&& request.method() == Method::CONNECT
@@ -148,10 +148,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.unwrap_or_else(|| request.uri().authority().map(|v| v.as_str()).unwrap_or("")),
253,
);
let initial_client_ip = request
.extensions()
.get::<PeerAddr>()
.map(|peer| peer.0.ip().to_string());
let initial_client_ip = request.extensions().get::<PeerAddr>().map(|peer| {
request
.extensions()
.get::<Arc<crate::forwarding::PeerFormatting>>()
.and_then(|formatting| formatting.raw_peer_text())
.map(str::to_owned)
.unwrap_or_else(|| peer.0.ip().to_string())
});
let user_agent = state
.logs
.capture_user_agent()
@@ -221,7 +225,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.and_then(|frontend| frontend.proxy_timeouts.send_timeout())
.unwrap_or(state.body_idle_timeout);
let request = request.map(|body| wrap_observed_request(body, &exchange, request_send_timeout));
let mut response = next.run(request).await;
let mut response = dispatch(state.clone(), request).await;
let final_route_context = response
.extensions()
.get::<FinalRouteContext>()
@@ -277,7 +281,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
}
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
.insert(::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
} else {
if response
.extensions()
+10 -7
View File
@@ -17,8 +17,8 @@ pub(super) fn web_origin_request_headers(
let Some(cached) = cached else {
return (headers, false);
};
headers.remove("if-none-match");
headers.remove("if-modified-since");
headers.remove(::http::header::IF_NONE_MATCH);
headers.remove(::http::header::IF_MODIFIED_SINCE);
let sent = cached.apply_validators(&mut headers);
(headers, sent)
}
@@ -62,17 +62,20 @@ pub(super) fn finish_web_revalidation(
let mut merged = origin_merged;
apply_rules(&mut merged);
if force_cache {
if client.contains_key("cookie") {
merged.append("vary", HeaderValue::from_static("Cookie"));
if client.contains_key(::http::header::COOKIE) {
merged.append(::http::header::VARY, HeaderValue::from_static("Cookie"));
}
if client.contains_key("authorization") {
merged.append("vary", HeaderValue::from_static("Authorization"));
if client.contains_key(::http::header::AUTHORIZATION) {
merged.append(
::http::header::VARY,
HeaderValue::from_static("Authorization"),
);
}
}
let can_store = origin_safe && !(ignore_cookie && crate::cache::varies_on(&merged, "cookie"));
let client_not_modified = crate::cache::conditional_not_modified(client, &merged);
let range = crate::cache::request_range(method, client);
let if_range = client.get("if-range");
let if_range = client.get(::http::header::IF_RANGE);
let published = if can_store {
cache.publish_revalidated(
ticket.clone(),
+13 -13
View File
@@ -81,7 +81,7 @@ impl Handshake {
fn protocols(headers: &HeaderMap) -> Result<Vec<String>> {
let protocols = headers
.get_all("sec-websocket-protocol")
.get_all(::http::header::SEC_WEBSOCKET_PROTOCOL)
.iter()
.map(|v| v.to_str())
.collect::<std::result::Result<Vec<_>, _>>()?
@@ -123,18 +123,18 @@ pub fn validate(request: &Request) -> Result<Handshake> {
"invalid WebSocket key"
);
ensure!(
!request.headers().contains_key("transfer-encoding"),
!request.headers().contains_key(::http::header::TRANSFER_ENCODING),
"WebSocket handshake cannot contain a transfer body"
);
ensure!(
request.headers().get_all("content-length").iter().count() <= 1
request.headers().get_all(::http::header::CONTENT_LENGTH).iter().count() <= 1
&& request
.headers()
.get("content-length")
.get(::http::header::CONTENT_LENGTH)
.is_none_or(|v| v == "0"),
"WebSocket handshake cannot contain a body"
);
for value in request.headers().get_all("connection").iter() {
for value in request.headers().get_all(::http::header::CONNECTION).iter() {
for field in value.to_str()?.split(',') {
ensure!(
!field
@@ -256,8 +256,8 @@ pub fn validate_response(
has_token(headers, "upgrade", "websocket"),
"invalid legacy upstream upgrade"
);
if headers.contains_key("content-length") {
for value in headers.get_all("content-length").iter() {
if headers.contains_key(::http::header::CONTENT_LENGTH) {
for value in headers.get_all(::http::header::CONTENT_LENGTH).iter() {
ensure!(
value.to_str()?.trim() == "0",
"legacy 101 response declared a non-zero body"
@@ -271,17 +271,17 @@ pub fn validate_response(
"invalid upstream upgrade"
);
ensure!(
!headers.contains_key("transfer-encoding"),
!headers.contains_key(::http::header::TRANSFER_ENCODING),
"101 response must not use Transfer-Encoding"
);
ensure!(
headers.get_all("content-length").iter().count() <= 1
headers.get_all(::http::header::CONTENT_LENGTH).iter().count() <= 1
&& headers
.get("content-length")
.get(::http::header::CONTENT_LENGTH)
.is_none_or(|value| value.to_str().is_ok_and(|value| value.trim() == "0")),
"101 response Content-Length must be 0 when present"
);
for value in headers.get_all("connection").iter() {
for value in headers.get_all(::http::header::CONNECTION).iter() {
for field in value.to_str()?.split(',') {
ensure!(
!field
@@ -297,7 +297,7 @@ pub fn validate_response(
single(headers, "sec-websocket-accept")? == expected.accept,
"upstream WebSocket accept mismatch"
);
if headers.contains_key("sec-websocket-protocol") {
if headers.contains_key(::http::header::SEC_WEBSOCKET_PROTOCOL) {
let selected = single(headers, "sec-websocket-protocol")?;
ensure!(
expected.protocols.iter().any(|v| v == selected),
@@ -321,7 +321,7 @@ pub fn validate_h2_response(headers: &HeaderMap, expected: &Handshake) -> Result
"HTTP/1.1-only WebSocket field on HTTP/2 response"
);
}
if headers.contains_key("sec-websocket-protocol") {
if headers.contains_key(::http::header::SEC_WEBSOCKET_PROTOCOL) {
let selected = single(headers, "sec-websocket-protocol")?;
ensure!(
expected.protocols.iter().any(|value| value == selected),
+2 -2
View File
@@ -105,9 +105,9 @@ impl CompiledSecurity {
headers.insert(*name, value.clone());
}
if !tls {
headers.remove("strict-transport-security");
headers.remove(::http::header::STRICT_TRANSPORT_SECURITY);
} else if let Some(value) = &self.hsts {
headers.insert("strict-transport-security", value.clone());
headers.insert(::http::header::STRICT_TRANSPORT_SECURITY, value.clone());
}
}
}
+3 -1
View File
@@ -64,7 +64,9 @@ fn h2c_attempt<B>(request: &Request<B>) -> bool {
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|value| value.trim().eq_ignore_ascii_case("h2c"))
|| request.headers().contains_key("http2-settings")
|| request
.headers()
.contains_key(::http::HeaderName::from_static("http2-settings"))
}
fn hpack_integer(out: &mut Vec<u8>, value: usize, prefix_bits: u8, first: u8) {
+2 -2
View File
@@ -451,11 +451,11 @@ impl ServerHeader {
match self.mode {
ServerMode::Preserve => {}
ServerMode::Hide => {
headers.remove("server");
headers.remove(::http::header::SERVER);
}
ServerMode::Custom => {
if let Ok(value) = self.value.parse() {
headers.insert("server", value);
headers.insert(::http::header::SERVER, value);
}
}
}
+1 -2
View File
@@ -104,8 +104,7 @@ fn read_pem_with_policy(
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Reject special files below without blocking while opening a FIFO.
// O_NONBLOCK has no effect on ordinary certificate/key files.
// O_NONBLOCK avoids hanging on a FIFO; special files are rejected below.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options
+35 -13
View File
@@ -9,7 +9,8 @@ use std::{
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const QUEUE_CAPACITY: usize = 2048;
const QUEUE_CAPACITY: usize = 8192;
const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
const TOP_CAPACITY: usize = 32;
const MAX_VHOSTS: usize = 64;
const MAX_LABEL_BYTES: usize = 256;
@@ -228,22 +229,43 @@ impl Analytics {
let (tx, mut rx) = mpsc::channel::<Sample>(QUEUE_CAPACITY);
let worker_state = state.clone();
tokio::spawn(async move {
let mut batch = Vec::with_capacity(64);
// Drain on a short interval instead of waking once per request.
let mut tick = tokio::time::interval(DRAIN_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut batch = Vec::with_capacity(256);
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
count = rx.recv_many(&mut batch, 64) => {
if count == 0 { break; }
let mut state = worker_state.lock().unwrap_or_else(|e| e.into_inner());
for sample in batch.drain(..) {
if let Some(counters) = state.vhosts.get_mut(sample.vhost.as_str()) {
counters.record(sample);
} else if state.vhosts.len() < MAX_VHOSTS {
let name = sample.vhost.clone();
let mut counters = Counters::default();
counters.record(sample);
state.vhosts.insert(name, counters);
_ = tick.tick() => {
let mut closed = false;
loop {
while batch.len() < 256 {
match rx.try_recv() {
Ok(sample) => batch.push(sample),
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
closed = true;
break;
}
}
}
if batch.is_empty() {
break;
}
let mut state = worker_state.lock().unwrap_or_else(|e| e.into_inner());
for sample in batch.drain(..) {
if let Some(counters) = state.vhosts.get_mut(sample.vhost.as_str()) {
counters.record(sample);
} else if state.vhosts.len() < MAX_VHOSTS {
let name = sample.vhost.clone();
let mut counters = Counters::default();
counters.record(sample);
state.vhosts.insert(name, counters);
}
}
}
if closed {
break;
}
}
}
+9 -10
View File
@@ -23,7 +23,7 @@ pub async fn apply(
{
return Ok(skip(response, None, "status_ineligible"));
}
if response.headers().contains_key("content-range") {
if response.headers().contains_key(::http::header::CONTENT_RANGE) {
return Ok(skip(response, None, "content_range"));
}
let Some(kind) =
@@ -39,7 +39,7 @@ pub async fn apply(
};
let Some(content_length) = response
.headers()
.get("content-length")
.get(::http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
else {
@@ -121,18 +121,17 @@ pub async fn apply(
.get::<OriginValidators>()
.cloned()
.unwrap_or_else(|| OriginValidators {
etag: parts.headers.get("etag").cloned(),
last_modified: parts.headers.get("last-modified").cloned(),
etag: parts.headers.get(::http::header::ETAG).cloned(),
last_modified: parts.headers.get(::http::header::LAST_MODIFIED).cloned(),
});
parts.headers.remove("content-encoding");
parts.headers.remove("content-length");
parts.headers.remove(::http::header::CONTENT_ENCODING);
parts.headers.remove(::http::header::CONTENT_LENGTH);
parts.headers.remove("content-md5");
parts.headers.remove("digest");
parts.headers.remove("content-digest");
parts.headers.remove("accept-ranges");
parts.headers.insert("etag", transformed_etag(&transformed));
parts.headers.insert(
"content-length",
parts.headers.remove(::http::header::ACCEPT_RANGES);
parts.headers.insert(::http::header::ETAG, transformed_etag(&transformed));
parts.headers.insert(::http::header::CONTENT_LENGTH,
HeaderValue::from_str(&transformed.len().to_string()).expect("integer header"),
);
drop_vary_accept_encoding(&mut parts.headers);
+6 -6
View File
@@ -50,7 +50,7 @@ fn transformer(kind: Kind) -> &'static dyn Transformer {
pub fn no_transform(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.get_all(::http::header::CACHE_CONTROL)
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| {
@@ -65,7 +65,7 @@ pub fn no_transform(headers: &HeaderMap) -> bool {
}
fn content_type_kind(headers: &HeaderMap) -> Option<Kind> {
let value = headers.get("content-type")?.to_str().ok()?;
let value = headers.get(::http::header::CONTENT_TYPE)?.to_str().ok()?;
let mime = value.split(';').next()?.trim().to_ascii_lowercase();
match mime.as_str() {
"application/javascript"
@@ -118,7 +118,7 @@ fn kind_enabled(kind: Kind, settings: &Settings) -> bool {
}
fn supported_encoding(headers: &HeaderMap) -> Option<Option<&'static str>> {
let Some(value) = headers.get("content-encoding") else {
let Some(value) = headers.get(::http::header::CONTENT_ENCODING) else {
return Some(None);
};
let value = value.to_str().ok()?.trim();
@@ -188,7 +188,7 @@ fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result<Co
}
fn drop_vary_accept_encoding(headers: &mut HeaderMap) {
let values: Vec<_> = headers.get_all("vary").iter().cloned().collect();
let values: Vec<_> = headers.get_all(::http::header::VARY).iter().cloned().collect();
if values.is_empty() {
return;
}
@@ -216,10 +216,10 @@ fn drop_vary_accept_encoding(headers: &mut HeaderMap) {
}
}
}
headers.remove("vary");
headers.remove(::http::header::VARY);
if !retained.is_empty() {
if let Ok(value) = HeaderValue::from_str(&retained.join(", ")) {
headers.insert("vary", value);
headers.insert(::http::header::VARY, value);
}
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ async fn php_response_inner(
let mut response = builtin_error_response(StatusCode::SERVICE_UNAVAILABLE, &method);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
.insert(::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
return response;
}
};
+10
View File
@@ -177,6 +177,16 @@ async fn file_response_inner(
} else {
None
};
#[cfg(unix)]
if boundary.is_none() {
let stream = file_body::InlineFileStream::new(opened.file, start, body_length, stream_capacity);
*response.body_mut() = Body::new(file_body::FileBody::new(stream, body_length));
#[cfg(target_os = "linux")]
if let Some(candidate) = sendfile_candidate {
response.extensions_mut().insert(candidate);
}
return response;
}
let mut file = tokio::fs::File::from_std(opened.file);
if let Some(boundary) = boundary {
let ranges = ranges.expect("multipart ranges");
+59
View File
@@ -80,3 +80,62 @@ where
#[cfg(test)]
mod tests;
/// Reads a regular file in bounded chunks on the calling task.
#[cfg(unix)]
pub(super) struct InlineFileStream {
file: std::fs::File,
offset: u64,
remaining: u64,
chunk: usize,
}
#[cfg(unix)]
impl InlineFileStream {
pub(super) fn new(file: std::fs::File, offset: u64, length: u64, chunk: usize) -> Self {
Self {
file,
offset,
remaining: length,
chunk: chunk.max(1),
}
}
}
#[cfg(unix)]
impl Stream for InlineFileStream {
type Item = Result<Bytes, io::Error>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
use std::os::unix::fs::FileExt;
let this = self.get_mut();
if this.remaining == 0 {
return Poll::Ready(None);
}
let want = this.remaining.min(this.chunk as u64) as usize;
let mut buffer = bytes::BytesMut::zeroed(want);
let mut filled = 0;
while filled < want {
match this
.file
.read_at(&mut buffer[filled..], this.offset + filled as u64)
{
Ok(0) => break,
Ok(read) => filled += read,
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => {
this.remaining = 0;
return Poll::Ready(Some(Err(error)));
}
}
}
if filled == 0 {
this.remaining = 0;
return Poll::Ready(None);
}
buffer.truncate(filled);
this.offset += filled as u64;
this.remaining -= filled as u64;
Poll::Ready(Some(Ok(buffer.freeze())))
}
}
+2 -3
View File
@@ -66,8 +66,7 @@ pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Res
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
"x-content-type-options",
response.headers_mut().insert(::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response
@@ -249,7 +248,7 @@ fn add_download_header(response: &mut Response, service: &CompiledService, path:
pub(super) fn accepts_content_encoding(headers: &HeaderMap, wanted: &str) -> bool {
let Some(value) = headers
.get(http::header::ACCEPT_ENCODING)
.get(::http::header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
else {
return false;
+2 -4
View File
@@ -13,9 +13,7 @@ pub(super) async fn open_components(
components: Vec<String>,
) -> io::Result<Opened> {
let root = FilesystemRoot::for_service(service);
tokio::task::spawn_blocking(move || secure_open_sync(&root, &components))
.await
.map_err(|_| io::Error::other("Web Server filesystem task failed"))?
secure_open_sync(&root, &components)
}
fn target_path_and_query(
@@ -129,7 +127,7 @@ pub(super) async fn select_precompressed(
return None;
}
if headers
.get("range")
.get(::http::header::RANGE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains(','))
{