This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 08:51:04 +02:00
1 parent a9f9450f80
commit 762eb9e28b
81 files changed
+1242 -1095

No files matched your search

+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.27.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.7_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.6_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.5_IMPLEMENTATION.md", "technical"),
+106 -5
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.7",
"version": "0.27.0",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -529,6 +529,9 @@
},
"source": {
"$ref": "#/components/schemas/Source"
},
"compression": {
"$ref": "#/components/schemas/CompressionOverrides"
}
},
"additionalProperties": true,
@@ -575,9 +578,7 @@
"$ref": "#/components/schemas/CacheSettings"
},
"compression": {
"type": "object",
"properties": {},
"additionalProperties": true
"$ref": "#/components/schemas/CompressionSettings"
},
"headers": {
"$ref": "#/components/schemas/HeaderRules"
@@ -2418,6 +2419,36 @@
"type": "boolean",
"default": false,
"description": "Enable the configured static-response transform engine for the generated Docker vhost. Global transform must also be enabled."
},
"compression_enabled": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Per-published-vhost dynamic compression override. null inherits the enclosing/global policy; true/false enables or disables the scope."
},
"compression_zstd": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional per-Docker-vhost Zstandard override. null inherits; globally disabled codecs remain unavailable."
},
"compression_br": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional per-Docker-vhost Brotli override. null inherits; globally disabled codecs remain unavailable."
},
"compression_gzip": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional per-Docker-vhost gzip override. null inherits; globally disabled codecs remain unavailable."
},
"compression_deflate": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional per-Docker-vhost deflate override. null inherits; globally disabled codecs remain unavailable."
}
},
"required": [
@@ -4761,6 +4792,9 @@
}
},
"description": "Optional Web Service hardening. This is a bounded deterministic compatibility/security layer, not a generic WAF."
},
"compression": {
"$ref": "#/components/schemas/CompressionOverrides"
}
},
"required": [
@@ -5061,6 +5095,73 @@
}
},
"additionalProperties": false
},
"CompressionSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"gzip": {
"type": "boolean",
"default": true
},
"br": {
"type": "boolean",
"default": true
},
"zstd": {
"type": "boolean",
"default": true
},
"deflate": {
"type": "boolean",
"default": false
},
"min_bytes": {
"type": "integer",
"format": "uint64",
"minimum": 32,
"maximum": 1048576,
"default": 1024
}
},
"description": "Global downstream compression codecs and minimum size. Client q-values win; equal-quality codecs use zstd, then br, gzip and deflate. Existing Content-Encoding is never recompressed."
},
"CompressionOverrides": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "null inherits the enclosing policy; true/false explicitly enables or disables dynamic compression at this scope."
},
"zstd": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional Zstandard override at this scope. null inherits; true is effective only when the codec is enabled globally; false disables it."
},
"br": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional Brotli override at this scope. null inherits; true is effective only when the codec is enabled globally; false disables it."
},
"gzip": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional gzip override at this scope. null inherits; true is effective only when the codec is enabled globally; false disables it."
},
"deflate": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Optional deflate override at this scope. null inherits; true is effective only when the codec is enabled globally; false disables it."
}
},
"description": "Scoped dynamic-compression policy. The enabled switch and individual codecs inherit by default; global Response codec switches are the hard availability ceiling and min_bytes remains global."
}
}
},
@@ -5423,7 +5524,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.7";
$("server-mode").value = server.mode || "custom"; $("server-value").value = server.value || "RustProxy";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+12 -4
View File
@@ -251,7 +251,8 @@
const inline = row.querySelector(".docker-acl-inline")?.checked;
return {
service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null,
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked,
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: (() => { const value = row.querySelector(".docker-compression-enabled").value; return value === "" ? null : value === "on"; })(),
compression_zstd: (() => { const value = row.querySelector(".docker-compression-zstd").value; return value === "" ? null : value === "on"; })(), compression_br: (() => { const value = row.querySelector(".docker-compression-br").value; return value === "" ? null : value === "on"; })(), compression_gzip: (() => { const value = row.querySelector(".docker-compression-gzip").value; return value === "" ? null : value === "on"; })(), compression_deflate: (() => { const value = row.querySelector(".docker-compression-deflate").value; return value === "" ? null : value === "on"; })(),
acl_profile_id: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
@@ -274,7 +275,7 @@
const runtime = runtimeFor(agent.id, key), visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url || stale; openApp.title = url || ""; };
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; };
syncOpenApp(legacyHostname(assignment));
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top);
@@ -288,8 +289,14 @@
const websockets = input("", "checkbox", "docker-websockets"); websockets.checked = assignment?.websockets !== false; const websocketField = checkboxField("dockerWebsockets", websockets);
const cacheEnabled = input("", "checkbox", "docker-cache-enabled"); cacheEnabled.checked = assignment?.cache_enabled === true; const cacheField = checkboxField("dockerCache", cacheEnabled);
const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled);
const compressionSelect = (className, current) => { const select = el("select", undefined, className); for (const [value, keyName] of [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]]) { const option = el("option", t(keyName)); option.value = value; select.append(option); } select.value = current === true ? "on" : current === false ? "off" : ""; return select; };
const compressionEnabled = compressionSelect("docker-compression-enabled", assignment?.compression_enabled);
const compressionZstd = compressionSelect("docker-compression-zstd", assignment?.compression_zstd), compressionBr = compressionSelect("docker-compression-br", assignment?.compression_br), compressionGzip = compressionSelect("docker-compression-gzip", assignment?.compression_gzip), compressionDeflate = compressionSelect("docker-compression-deflate", assignment?.compression_deflate);
const compressionFields = [[labeled("compressionZstd", compressionZstd), compressionZstd], [labeled("compressionBr", compressionBr), compressionBr], [labeled("compressionGzip", compressionGzip), compressionGzip], [labeled("compressionDeflate", compressionDeflate), compressionDeflate]];
const syncCompressionVisibility = () => { const disabled = compressionEnabled.value === "off"; for (const [field, control] of compressionFields) { field.hidden = disabled; control.disabled = disabled; } };
compressionEnabled.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), labeled("dockerCompression", compressionEnabled, t("dockerCompressionHelp")), ...compressionFields.map(([field]) => field), switches);
editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
@@ -312,7 +319,8 @@
const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name));
const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head);
if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; }
if (!state?.ok || !state.inventory) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); target.append(box); continue; }
if (!state?.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); }
if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; }
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary");
+12 -7
View File
@@ -1921,10 +1921,10 @@
id="server-mode">
<option data-i18n="serverPreserve" value="preserve">Preserve origin value</option>
<option data-i18n="serverHide" value="hide">Hide completely</option>
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
<option data-i18n="serverCustom" selected value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.7"
id="server-value" maxlength="256" placeholder="RustProxy" value="RustProxy"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1941,9 +1941,7 @@
<label data-i18n="compressionMin" for="compress-min">Minimum response size
(bytes)</label><input id="compress-min" max="1048576" min="32" required=""
type="number" />
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding. Skips private
responses, cookies, ranges, SSE and no-transform; existing compressed bodies are
passed through.</p>
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding using client q-values and zstd → br → gzip → deflate as the server preference for equal quality. Cookies, Authorization, Set-Cookie, private and no-store responses may be compressed. Existing Content-Encoding, ranges, SSE, upgrades and no-transform are passed through.</p>
<div class="form-actions"><button class="primary" data-i18n="saveResponse"
type="submit">Apply response settings</button></div>
</form>
@@ -2723,7 +2721,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.7</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.27.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3107,6 +3105,13 @@
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select id="web-service-compression"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global policy by default. Codecs can be filtered per service; globally disabled codecs stay unavailable.</small></label>
<div class="vh-wide vh-grid" id="web-service-compression-options">
<label><span data-i18n="compressionZstd">Zstandard (zstd)</span><select id="web-service-compression-zstd"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionBr">Brotli (br)</span><select id="web-service-compression-br"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionDeflate">deflate</span><select id="web-service-compression-deflate"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
</div>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span
data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span
@@ -3434,7 +3439,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.26.7</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.27.0</strong></div>
</div>
<dl class="about-meta">
<div>
+18 -1
View File
@@ -1738,8 +1738,25 @@ progress {
display: block;
width: 100%;
height: 5px;
margin: 6px 0 9px;
border: 0;
appearance: none;
-webkit-appearance: none;
background: var(--line);
accent-color: var(--green);
margin: 6px 0 9px
overflow: hidden
}
progress::-webkit-progress-bar {
background: var(--line)
}
progress::-webkit-progress-value {
background: var(--green)
}
progress::-moz-progress-bar {
background: var(--green)
}
.diagnostics-grid {
+4 -3
View File
@@ -22,7 +22,7 @@
};
const group = (key, advanced = false) => {
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
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; };
@@ -47,12 +47,13 @@
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
return {
+19 -2
View File
@@ -229,11 +229,28 @@
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
g = group("vhCompression"); const compression = value.compression || {};
const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled);
const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions);
const compressionCodecInputs = [
compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd),
compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br),
compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip),
compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate)
];
const syncCompressionVisibility = () => {
const disabled = compressionToggle.value === "off";
compressionOptions.hidden = disabled;
for (const input of compressionCodecInputs) input.disabled = disabled;
};
compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
note(g, "vhCompressionHelp");
g = group("vhCache"); const cache = value.cache || {};
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
@@ -315,7 +332,7 @@
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
+20 -1
View File
@@ -161,6 +161,12 @@
]) $(id).disabled = !enabled;
}
function syncCompression() {
const disabled = $("web-service-compression").value === "off";
$("web-service-compression-options").hidden = disabled;
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).disabled = disabled;
}
function syncPrecompressed() {
const enabled = $("web-service-precompressed").checked;
$("web-service-precompressed-br").disabled = !enabled;
@@ -186,7 +192,7 @@
return {
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
try_files: [], rewrites: [], precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
symlink_policy: "deny", php: {
enabled: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0,
@@ -228,6 +234,8 @@
$("web-service-cache-control").value = item.cache_control || "";
$("web-service-cache-rules").value = cacheLines(item.cache_rules);
$("web-service-response-headers").value = mapLines(item.response_headers);
$("web-service-compression").value = item.compression?.enabled === true ? "on" : item.compression?.enabled === false ? "off" : "";
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : "";
$("web-service-precompressed").checked = item.precompressed?.enabled === true;
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
@@ -252,6 +260,7 @@
$("web-service-hardening-attacks").checked = item.hardening?.block_common_attacks === true;
$("web-service-hardening-headers").checked = item.hardening?.strip_unnecessary_headers === true;
syncPhp();
syncCompression();
syncPrecompressed();
syncHtaccess();
}
@@ -297,6 +306,7 @@
function open(value = null) {
const item = Object.assign(defaults(), structuredClone(value || {}));
item.compression = Object.assign(defaults().compression, item.compression || {});
item.precompressed = Object.assign(defaults().precompressed, item.precompressed || {});
item.php = Object.assign(defaults().php, item.php || {});
item.htaccess = Object.assign(defaults().htaccess, item.htaccess || {});
@@ -357,6 +367,7 @@
["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"],
["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")],
["webServicePhpEnabled", t(service.php?.enabled ? "enabled" : "notEnabled")],
["webServiceCompression", service.compression?.enabled === true ? t("enabled") : service.compression?.enabled === false ? t("notEnabled") : t("inherit")],
["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")],
["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")],
["webServiceCacheControl", service.cache_control || t("inherit")],
@@ -390,6 +401,13 @@
response_headers: parseKeyValueLines($("web-service-response-headers").value, "webServiceResponseHeadersInvalid", key => key.toLowerCase()),
try_files: strictLines($("web-service-try-files").value),
rewrites: parseRewrites($("web-service-rewrites").value),
compression: {
enabled: $("web-service-compression").value === "" ? null : $("web-service-compression").value === "on",
zstd: $("web-service-compression-zstd").value === "" ? null : $("web-service-compression-zstd").value === "on",
br: $("web-service-compression-br").value === "" ? null : $("web-service-compression-br").value === "on",
gzip: $("web-service-compression-gzip").value === "" ? null : $("web-service-compression-gzip").value === "on",
deflate: $("web-service-compression-deflate").value === "" ? null : $("web-service-compression-deflate").value === "on"
},
precompressed: { enabled: $("web-service-precompressed").checked, brotli: $("web-service-precompressed-br").checked, gzip: $("web-service-precompressed-gzip").checked },
download_extensions: lines($("web-service-download-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
symlink_policy: $("web-service-symlink-policy").value,
@@ -435,6 +453,7 @@
form.addEventListener("input", () => { dirty = true; });
form.addEventListener("change", () => { dirty = true; });
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-compression").addEventListener("change", syncCompression);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
$("web-service-preset").addEventListener("change", presetChanged);
+173 -62
View File
@@ -1,5 +1,6 @@
//! Streaming content negotiation. No compression of private, ranged, upgraded,
//! event-stream or no-transform responses. No full-body aggregation.
//! Streaming content negotiation with deterministic server-side preference.
//! Existing Content-Encoding, ranges, upgrades, event streams and no-transform
//! responses are never recompressed. No full-body aggregation is introduced.
use anyhow::{ensure, Result};
use axum::{body::Body, response::Response};
use http::{HeaderMap, HeaderValue, Method, Request};
@@ -26,6 +27,7 @@ impl Predicate for MinimumSize {
size.is_none_or(|size| size >= self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -38,9 +40,9 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
gzip: false,
br: false,
zstd: false,
gzip: true,
br: true,
zstd: true,
deflate: false,
min_bytes: 1024,
}
@@ -57,16 +59,60 @@ impl Settings {
pub fn enabled(&self) -> bool {
self.gzip || self.br || self.zstd || self.deflate
}
pub fn scoped(&self, enabled: bool) -> Self {
if enabled {
return self.clone();
}
let mut disabled = self.clone();
disabled.gzip = false;
disabled.br = false;
disabled.zstd = false;
disabled.deflate = false;
disabled
}
}
/// Per-vhost/Web-Service compression policy. None inherits the enclosing policy.
/// Global Response settings remain the hard codec ceiling and own minimum size.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Overrides {
pub enabled: Option<bool>,
pub gzip: Option<bool>,
pub br: Option<bool>,
pub zstd: Option<bool>,
pub deflate: Option<bool>,
}
impl Overrides {
pub fn resolve(&self, global: &Settings, inherited: &Settings) -> Settings {
if self.enabled == Some(false) {
return global.scoped(false);
}
let mut resolved = if self.enabled == Some(true) {
global.clone()
} else {
inherited.clone()
};
if let Some(enabled) = self.gzip {
resolved.gzip = enabled && global.gzip;
}
if let Some(enabled) = self.br {
resolved.br = enabled && global.br;
}
if let Some(enabled) = self.zstd {
resolved.zstd = enabled && global.zstd;
}
if let Some(enabled) = self.deflate {
resolved.deflate = enabled && global.deflate;
}
resolved.min_bytes = global.min_bytes;
resolved
}
}
#[derive(Clone)]
pub struct Skip;
/// Internal proof that a request carrying Cookie may still be compressed.
/// Only shared-cache HITs accepted by cache policy and local static Web Server
/// responses set this marker; arbitrary authenticated/dynamic responses do not.
#[derive(Clone, Copy)]
pub struct AllowCookie;
/// Encoding applied by RustProxy itself. This marker is kept in response
/// extensions so accounting never has to infer the selected codec from a
/// header that later response rules may rewrite.
@@ -78,6 +124,26 @@ pub enum AppliedEncoding {
Deflate,
}
impl AppliedEncoding {
const PREFERENCE: [Self; 4] = [Self::Zstd, Self::Brotli, Self::Gzip, Self::Deflate];
fn as_str(self) -> &'static str {
match self {
Self::Gzip => "gzip",
Self::Brotli => "br",
Self::Zstd => "zstd",
Self::Deflate => "deflate",
}
}
fn enabled(self, settings: &Settings) -> bool {
match self {
Self::Gzip => settings.gzip,
Self::Brotli => settings.br,
Self::Zstd => settings.zstd,
Self::Deflate => settings.deflate,
}
}
fn from_header(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"gzip" => Some(Self::Gzip),
@@ -88,37 +154,25 @@ impl AppliedEncoding {
}
}
}
pub fn private_or_no_transform(headers: &HeaderMap) -> bool {
headers.contains_key("set-cookie")
|| headers
.get_all("cache-control")
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(',').any(|d| {
matches!(
d.trim()
.split('=')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
.as_str(),
"no-store" | "private" | "no-transform"
)
})
})
}
fn no_transform(headers: &HeaderMap) -> bool {
pub fn no_transform(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(',')
.any(|v| v.trim().eq_ignore_ascii_case("no-transform"))
v.split(',').any(|directive| {
directive
.trim()
.split('=')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("no-transform")
})
})
}
fn eligible(
response: &Response,
request: &HeaderMap,
@@ -126,7 +180,6 @@ fn eligible(
settings: &Settings,
) -> bool {
if response.extensions().get::<Skip>().is_some()
|| private_or_no_transform(response.headers())
|| !settings.enabled()
|| method == Method::HEAD
|| response.status().is_informational()
@@ -134,37 +187,93 @@ fn eligible(
|| response.status().as_u16() == 304
|| response.headers().contains_key("content-encoding")
|| response.headers().contains_key("content-range")
|| response.headers().contains_key("set-cookie")
|| response.headers().contains_key("upgrade")
|| request.contains_key("range")
|| request.contains_key("authorization")
|| (request.contains_key("cookie")
&& response.extensions().get::<AllowCookie>().is_none())
|| no_transform(request)
|| no_transform(response.headers())
{
return false;
}
let content_type = response
!response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if content_type.starts_with("text/event-stream") {
return false;
}
content_type.starts_with("text/")
|| content_type.starts_with("application/json")
|| content_type.starts_with("application/javascript")
|| content_type.starts_with("application/xml")
|| content_type.starts_with("image/svg+xml")
|| content_type.starts_with("application/wasm")
|| content_type
.split(';')
.next()
.is_some_and(|v| v.ends_with("+json") || v.ends_with("+xml"))
.is_some_and(|v| {
v.split(';')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("text/event-stream")
})
}
fn quality(raw: &str) -> Option<u16> {
let value = raw.trim().parse::<f32>().ok()?;
if !(0.0..=1.0).contains(&value) {
return None;
}
Some((value * 1000.0).round() as u16)
}
fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option<AppliedEncoding> {
let mut explicit = [None; 4];
let mut wildcard = None;
for line in headers.get_all("accept-encoding").iter() {
let Ok(line) = line.to_str() else { continue };
for item in line.split(',') {
let mut parts = item.split(';');
let coding = parts.next().unwrap_or("").trim().to_ascii_lowercase();
if coding.is_empty() {
continue;
}
let mut q = 1000;
let mut valid = true;
for parameter in parts {
let Some((name, value)) = parameter.trim().split_once('=') else {
continue;
};
if name.trim().eq_ignore_ascii_case("q") {
if let Some(parsed) = quality(value) {
q = parsed;
} else {
valid = false;
}
}
}
if !valid {
continue;
}
if coding == "*" {
wildcard = Some(wildcard.map_or(q, |current: u16| current.max(q)));
continue;
}
if let Some(encoding) = AppliedEncoding::from_header(&coding) {
let index = match encoding {
AppliedEncoding::Zstd => 0,
AppliedEncoding::Brotli => 1,
AppliedEncoding::Gzip => 2,
AppliedEncoding::Deflate => 3,
};
explicit[index] = Some(explicit[index].map_or(q, |current: u16| current.max(q)));
}
}
}
let mut best = None;
let mut best_q = 0;
for (index, encoding) in AppliedEncoding::PREFERENCE.into_iter().enumerate() {
if !encoding.enabled(settings) {
continue;
}
let q = explicit[index].or(wildcard).unwrap_or(0);
if q > best_q {
best = Some(encoding);
best_q = q;
}
}
best.filter(|_| best_q > 0)
}
pub async fn encode(
response: Response,
headers: &HeaderMap,
@@ -174,6 +283,9 @@ pub async fn encode(
if !eligible(&response, headers, method, settings) {
return response;
}
let Some(selected) = preferred_encoding(headers, settings) else {
return response;
};
let mut response = Some(response);
let service = service_fn(move |_: Request<Body>| {
let response = response.take().expect("one-shot compression service");
@@ -188,12 +300,10 @@ pub async fn encode(
.compress_when(DefaultPredicate::new().and(MinimumSize(settings.min_bytes)));
let mut request = Request::new(Body::empty());
*request.method_mut() = method.clone();
// Multiple Accept-Encoding lines are meaningful and must all be retained.
for value in headers.get_all("accept-encoding") {
request
.headers_mut()
.append("accept-encoding", value.clone());
}
request.headers_mut().insert(
"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 {},
@@ -229,5 +339,6 @@ pub async fn encode(
}
response
}
#[cfg(test)]
include!("compression/tests.rs");
+167 -76
View File
@@ -1,6 +1,7 @@
mod tests {
use super::*;
use http_body_util::BodyExt;
fn response() -> Response {
let mut response =
Response::new(Body::from("RustProxy compressible payload. ".repeat(500)));
@@ -12,6 +13,52 @@ mod tests {
.insert("etag", "\"origin\"".parse().unwrap());
response
}
#[test]
fn defaults_enable_modern_compression_without_deflate() {
let settings = Settings::default();
assert!(settings.zstd);
assert!(settings.br);
assert!(settings.gzip);
assert!(!settings.deflate);
assert!(settings.enabled());
}
#[test]
fn scoped_codec_overrides_keep_global_codecs_authoritative() {
let global = Settings::default();
assert!(!global.scoped(false).enabled());
let inherited = global.clone();
let selective = Overrides {
gzip: Some(false),
br: Some(true),
zstd: Some(false),
deflate: Some(true),
..Default::default()
}
.resolve(&global, &inherited);
assert!(!selective.gzip);
assert!(selective.br);
assert!(!selective.zstd);
assert!(!selective.deflate, "a scope cannot enable a globally disabled codec");
let disabled = Overrides { enabled: Some(false), ..Default::default() }
.resolve(&global, &inherited);
assert!(!disabled.enabled());
let parent_disabled = global.scoped(false);
let reenabled = Overrides {
enabled: Some(true),
gzip: Some(false),
..Default::default()
}
.resolve(&global, &parent_disabled);
assert!(reenabled.zstd && reenabled.br);
assert!(!reenabled.gzip);
assert_eq!(reenabled.min_bytes, global.min_bytes);
}
#[tokio::test]
async fn large_thresholds_do_not_truncate_or_panic() {
let mut h = HeaderMap::new();
@@ -19,8 +66,10 @@ mod tests {
for threshold in [65_535, 65_536, 1_048_576] {
let settings = Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
min_bytes: threshold,
..Default::default()
};
settings.validate().unwrap();
for (size, expected) in [(threshold - 1, false), (threshold, true)] {
@@ -38,9 +87,10 @@ mod tests {
}
}
}
#[tokio::test]
async fn negotiation_and_body_streaming() {
let s = Settings {
async fn negotiation_and_body_streaming_support_each_codec() {
let settings = Settings {
gzip: true,
br: true,
zstd: true,
@@ -48,10 +98,10 @@ mod tests {
..Default::default()
};
for encoding in ["gzip", "br", "zstd", "deflate"] {
let mut h = HeaderMap::new();
h.insert("accept-encoding", encoding.parse().unwrap());
let r = encode(response(), &h, &Method::GET, &s).await;
assert_eq!(r.headers()["content-encoding"], encoding);
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", encoding.parse().unwrap());
let response = encode(response(), &headers, &Method::GET, &settings).await;
assert_eq!(response.headers()["content-encoding"], encoding);
let expected = match encoding {
"gzip" => AppliedEncoding::Gzip,
"br" => AppliedEncoding::Brotli,
@@ -60,26 +110,21 @@ mod tests {
_ => unreachable!(),
};
assert_eq!(
r.extensions().get::<AppliedEncoding>().copied(),
response.extensions().get::<AppliedEncoding>().copied(),
Some(expected)
);
assert_eq!(r.headers()["etag"], "W/\"origin\"");
assert!(r
assert_eq!(response.headers()["etag"], "W/\"origin\"");
assert!(response
.headers()
.get_all("vary")
.iter()
.any(|v| v.to_str().unwrap().contains("accept-encoding")));
assert!(r.into_body().collect().await.unwrap().to_bytes().len() < 1000);
assert!(response.into_body().collect().await.unwrap().to_bytes().len() < 1000);
}
let mut h = HeaderMap::new();
h.insert("accept-encoding", "gzip;q=0, identity;q=1".parse().unwrap());
assert!(!encode(response(), &h, &Method::GET, &s)
.await
.headers()
.contains_key("content-encoding"));
}
#[tokio::test]
async fn quality_weights_can_select_each_enabled_codec() {
async fn equal_quality_uses_zstd_then_br_then_gzip_then_deflate() {
let settings = Settings {
gzip: true,
br: true,
@@ -88,57 +133,77 @@ mod tests {
..Default::default()
};
let cases = [
(
"gzip;q=1, br;q=0.2, zstd;q=0.2, deflate;q=0.2",
"gzip",
AppliedEncoding::Gzip,
),
(
"gzip;q=0.2, br;q=1, zstd;q=0.2, deflate;q=0.2",
"br",
AppliedEncoding::Brotli,
),
(
"gzip;q=0.2, br;q=0.2, zstd;q=1, deflate;q=0.2",
"zstd",
AppliedEncoding::Zstd,
),
(
"gzip;q=0.2, br;q=0.2, zstd;q=0.2, deflate;q=1",
"deflate",
AppliedEncoding::Deflate,
),
("gzip, br, zstd, deflate", "zstd"),
("gzip, br, deflate", "br"),
("gzip, deflate", "gzip"),
("deflate", "deflate"),
("*", "zstd"),
];
for (accept, expected_header, expected_marker) in cases {
for (accept, expected) in cases {
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", accept.parse().unwrap());
let response = encode(response(), &headers, &Method::GET, &settings).await;
assert_eq!(response.headers()["content-encoding"], expected_header);
assert_eq!(
response.extensions().get::<AppliedEncoding>().copied(),
Some(expected_marker),
"accept-encoding={accept}"
);
assert_eq!(response.headers()["content-encoding"], expected, "{accept}");
}
}
#[tokio::test]
async fn cache_safe_cookie_marker_allows_compression_without_weakening_default_guard() {
async fn quality_weights_win_before_server_priority() {
let settings = Settings {
gzip: true,
br: true,
zstd: true,
deflate: true,
..Default::default()
};
let cases = [
("gzip;q=1, br;q=.9, zstd;q=.8", "gzip"),
("gzip;q=.6, br;q=.9, zstd;q=.8", "br"),
("gzip;q=.6, br;q=.8, zstd;q=.9", "zstd"),
("gzip;q=0, br;q=0, zstd;q=0, deflate;q=1", "deflate"),
];
for (accept, expected) in cases {
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", accept.parse().unwrap());
let response = encode(response(), &headers, &Method::GET, &settings).await;
assert_eq!(response.headers()["content-encoding"], expected, "{accept}");
}
let mut headers = HeaderMap::new();
headers.insert(
"accept-encoding",
"gzip;q=0, br;q=0, zstd;q=0, deflate;q=0, identity;q=1"
.parse()
.unwrap(),
);
assert!(!encode(response(), &headers, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
}
#[tokio::test]
async fn cookies_authorization_and_private_cache_policy_do_not_disable_compression() {
let settings = Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
..Default::default()
};
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", "gzip".parse().unwrap());
headers.insert("cookie", "session=secret".parse().unwrap());
let blocked = encode(response(), &headers, &Method::GET, &settings).await;
assert!(!blocked.headers().contains_key("content-encoding"));
let mut safe = response();
safe.extensions_mut().insert(AllowCookie);
let encoded = encode(safe, &headers, &Method::GET, &settings).await;
headers.insert("authorization", "Bearer secret".parse().unwrap());
let mut dynamic = response();
dynamic
.headers_mut()
.insert("set-cookie", "session=next; HttpOnly".parse().unwrap());
dynamic.headers_mut().insert(
"cache-control",
"private, no-store".parse().unwrap(),
);
let encoded = encode(dynamic, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip");
}
@@ -146,6 +211,9 @@ mod tests {
async fn age_header_survives_downstream_compression() {
let settings = Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
..Default::default()
};
let mut headers = HeaderMap::new();
@@ -159,35 +227,58 @@ mod tests {
}
#[tokio::test]
async fn no_transform_personal_and_sse_are_not_compressed() {
let s = Settings {
async fn existing_content_encoding_is_never_recompressed() {
let settings = Settings::default();
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", "zstd, br, gzip".parse().unwrap());
let mut upstream = response();
upstream
.headers_mut()
.insert("content-encoding", "gzip".parse().unwrap());
let encoded = encode(upstream, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip");
assert!(encoded.extensions().get::<AppliedEncoding>().is_none());
}
#[tokio::test]
async fn no_transform_range_and_sse_are_not_compressed() {
let settings = Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
..Default::default()
};
let mut h = HeaderMap::new();
h.insert("accept-encoding", "gzip".parse().unwrap());
for name in ["authorization", "cookie", "range"] {
let mut private = h.clone();
private.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
"value".parse().unwrap(),
);
assert!(!encode(response(), &private, &Method::GET, &s)
.await
.headers()
.contains_key("content-encoding"));
}
let mut r = response();
r.headers_mut()
.insert("cache-control", "no-transform".parse().unwrap());
assert!(!encode(r, &h, &Method::GET, &s)
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", "gzip".parse().unwrap());
let mut ranged = headers.clone();
ranged.insert("range", "bytes=0-99".parse().unwrap());
assert!(!encode(response(), &ranged, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
let mut r = response();
r.headers_mut()
let mut no_transform_request = headers.clone();
no_transform_request.insert("cache-control", "no-transform".parse().unwrap());
assert!(!encode(response(), &no_transform_request, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
let mut no_transform_response = response();
no_transform_response
.headers_mut()
.insert("cache-control", "public, no-transform".parse().unwrap());
assert!(!encode(no_transform_response, &headers, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
let mut sse = response();
sse.headers_mut()
.insert("content-type", "text/event-stream".parse().unwrap());
assert!(!encode(r, &h, &Method::GET, &s)
assert!(!encode(sse, &headers, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
+7 -1
View File
@@ -17,6 +17,10 @@ pub const MAX_CONFIG_BYTES: usize = 262_144;
pub const MAX_BACKENDS: usize = 128;
pub const MAX_ROUTES: usize = 256;
fn legacy_server_header_default() -> crate::settings::ServerHeader {
crate::settings::ServerHeader::legacy_compat_default()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
@@ -50,7 +54,7 @@ pub struct Config {
pub pages: crate::pages::GlobalPages,
#[serde(default)]
pub compression: crate::compression::Settings,
#[serde(default)]
#[serde(default = "legacy_server_header_default")]
pub server_header: crate::settings::ServerHeader,
#[serde(default)]
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
@@ -198,6 +202,7 @@ pub struct CompiledRoute {
pub country_access: crate::rate_limit::CountryAccess,
pub method_policy: crate::vhost::MethodPolicy,
pub headers: CompiledHeaders,
pub unavailable: bool,
}
#[derive(Debug)]
pub struct CompiledFrontend {
@@ -233,6 +238,7 @@ pub struct CompiledFrontend {
pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders,
pub security: crate::security::CompiledSecurity,
pub compression: crate::compression::Overrides,
pub cache: crate::cache::Settings,
pub transform: crate::transform::Settings,
pub pages: crate::pages::VhostPages,
+13 -10
View File
@@ -190,6 +190,7 @@ fn compile_routing(
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
headers: host.headers.compile()?,
security: host.security_headers.compile()?,
compression: host.compression.clone(),
cache: host.cache.resolve(&config.cache)?,
transform: host.transform.resolve(&config.transform)?,
pages: host.pages.clone(),
@@ -198,17 +199,17 @@ fn compile_routing(
if host.enabled {
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} Reverse Proxy source has no backend pool",
host.id
if let Some(pool) = pool.as_ref() {
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(pool.members[0].backend.clone()),
)
})?;
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(pool.members[0].backend.clone()),
)
} else if host.transient_unavailable {
(CompiledSource::ReverseProxy, None, None)
} else {
anyhow::bail!("vhost {} Reverse Proxy source has no backend pool", host.id);
}
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
@@ -237,6 +238,7 @@ fn compile_routing(
country_access: crate::rate_limit::CountryAccess::default(),
method_policy: crate::vhost::MethodPolicy::default(),
headers: crate::headers::CompiledHeaders::default(),
unavailable: host.transient_unavailable,
};
for name in &host.frontend.hosts {
fallbacks
@@ -371,6 +373,7 @@ fn compile_routing(
country_access: route.country_access.clone(),
method_policy: route.method_policy.clone(),
headers,
unavailable: false,
};
for name in &host.frontend.hosts {
routes
+22 -17
View File
@@ -58,23 +58,28 @@ impl Manager {
})
.cloned()
.collect();
let futures = due.into_iter().map(|agent| async move {
let checked_at_unix_ms = now_ms();
match fetch(&agent).await {
Ok(inventory) => Observation {
agent_id: agent.id,
ok: true,
checked_at_unix_ms,
error: None,
inventory: Some(inventory),
},
Err(error) => Observation {
agent_id: agent.id,
ok: false,
checked_at_unix_ms,
error: Some(format!("{error:#}")),
inventory: None,
},
let futures = due.into_iter().map(|agent| {
let previous_inventory = snapshot
.get(&agent.id)
.and_then(|observation| observation.inventory.clone());
async move {
let checked_at_unix_ms = now_ms();
match fetch(&agent).await {
Ok(inventory) => Observation {
agent_id: agent.id,
ok: true,
checked_at_unix_ms,
error: None,
inventory: Some(inventory),
},
Err(error) => Observation {
agent_id: agent.id,
ok: false,
checked_at_unix_ms,
error: Some(format!("{error:#}")),
inventory: previous_inventory,
},
}
}
});
let refreshed = join_all(futures).await;
+10
View File
@@ -110,6 +110,11 @@ pub struct Assignment {
/// the per-app redirect switch existed. New UI assignments persist true.
pub force_ssl: Option<bool>,
pub websockets: bool,
pub compression_enabled: Option<bool>,
pub compression_gzip: Option<bool>,
pub compression_br: Option<bool>,
pub compression_zstd: Option<bool>,
pub compression_deflate: Option<bool>,
pub cache_enabled: bool,
pub transform_enabled: bool,
pub acl_profile_id: Option<String>,
@@ -130,6 +135,11 @@ impl Default for Assignment {
preserve_host: true,
force_ssl: None,
websockets: true,
compression_enabled: None,
compression_gzip: None,
compression_br: None,
compression_zstd: None,
compression_deflate: None,
cache_enabled: false,
transform_enabled: false,
acl_profile_id: None,
+56 -35
View File
@@ -59,52 +59,72 @@ pub fn effective_config(
mut desired: crate::config::Config,
observed: &BTreeMap<String, Observation>,
) -> Result<crate::config::Config> {
for agent in desired
.docker_agents
.agents
.iter()
.filter(|agent| agent.enabled)
{
let Some(inventory) = observed
.get(&agent.id)
.and_then(|item| item.ok.then_some(item.inventory.as_ref()).flatten())
else {
continue;
};
for agent in &desired.docker_agents.agents {
// Published assignments outlive transient discovery state. A disabled
// agent stops polling, but its still-published hostnames remain claimed
// and return 503 until the agent is re-enabled or the assignment is
// explicitly unpublished. A failed enabled-agent poll may retain the
// last successful inventory.
let inventory = agent
.enabled
.then(|| observed.get(&agent.id).and_then(|item| item.inventory.as_ref()))
.flatten();
for assignment in agent
.assignments
.iter()
.filter(|assignment| assignment.enabled)
{
let Some(service) = inventory
.services
.iter()
.find(|service| service.key == assignment.service_key)
else {
continue;
};
let Some((ip, port)) = route_target(agent, assignment, inventory, service) else {
continue;
};
usable_target_ip(ip)?;
let hosts = assignment_hosts(assignment, &agent.base_domain)?;
let url = match ip {
IpAddr::V4(ip) => format!("{}://{}:{}", assignment.scheme.as_str(), ip, port),
IpAddr::V6(ip) => format!("{}://[{}]:{}", assignment.scheme.as_str(), ip, port),
};
let target = inventory
.and_then(|inventory| {
inventory
.services
.iter()
.find(|service| service.key == assignment.service_key)
.and_then(|service| {
route_target(agent, assignment, inventory, service)
})
})
.map(|(ip, port)| -> Result<_> {
usable_target_ip(ip)?;
let url = match ip {
IpAddr::V4(ip) => {
format!("{}://{}:{}", assignment.scheme.as_str(), ip, port)
}
IpAddr::V6(ip) => {
format!("{}://[{}]:{}", assignment.scheme.as_str(), ip, port)
}
};
Ok((ip, url))
})
.transpose()?;
let backends = target
.as_ref()
.map(|(ip, url)| {
vec![serde_json::json!({
"id": "docker",
"url": url,
"allow_private": true,
"preserve_host": assignment.preserve_host,
"address_allowlist": [address_cidr(*ip)]
})]
})
.unwrap_or_default();
let value = serde_json::json!({
"id": generated_id(&agent.id, &assignment.service_key),
"frontend": {"hosts": hosts, "listener_ids": agent.listener_ids},
"certificate_id": agent.certificate_id.clone(),
"force_ssl": assignment.force_ssl.unwrap_or(false),
"websockets": assignment.websockets,
"backends": [{
"id": "docker",
"url": url,
"allow_private": true,
"preserve_host": assignment.preserve_host,
"address_allowlist": [address_cidr(ip)]
}],
"backends": backends,
"compression": {
"enabled": assignment.compression_enabled,
"gzip": assignment.compression_gzip,
"br": assignment.compression_br,
"zstd": assignment.compression_zstd,
"deflate": assignment.compression_deflate
},
"cache": {"enabled": assignment.cache_enabled},
"transform": {"enabled": assignment.transform_enabled},
"acl_profile_id": assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone()),
@@ -115,8 +135,9 @@ pub fn effective_config(
"passive_health": assignment.passive_health.clone()
}
});
let host: crate::vhost::Vhost =
let mut host: crate::vhost::Vhost =
serde_json::from_value(value).context("build Docker vhost")?;
host.transient_unavailable = target.is_none();
desired.vhosts.push(host);
}
}
+131
View File
@@ -198,9 +198,140 @@ mod tests {
assert_eq!(assignment.force_ssl, None);
assert!(assignment.websockets);
assert!(!assignment.cache_enabled);
assert_eq!(assignment.compression_enabled, None);
assert_eq!(assignment.compression_zstd, None);
assert_eq!(assignment.compression_br, None);
assert_eq!(assignment.compression_gzip, None);
assert_eq!(assignment.compression_deflate, None);
assert!(!assignment.passive_health.enabled);
}
#[test]
fn published_assignment_stays_routable_as_503_when_service_is_missing() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
desired.vhosts.clear();
desired.docker_agents.agents.push(Agent {
id: "node1".into(),
name: "Node 1".into(),
endpoint: "http://10.0.0.2:9876".into(),
token: "012345678901234567890123".into(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
assignments: vec![Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
compression_enabled: Some(false),
compression_zstd: Some(false),
..Default::default()
}],
..Default::default()
});
let mut observed = observation(service(), vec![]);
observed.get_mut("node1").unwrap().inventory.as_mut().unwrap().services.clear();
let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1);
assert!(effective.vhosts[0].backends.is_empty());
assert!(effective.vhosts[0].transient_unavailable);
assert_eq!(effective.vhosts[0].compression.enabled, Some(false));
assert_eq!(effective.vhosts[0].compression.zstd, Some(false));
let policy = crate::config::ValidationPolicy {
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
tls_addr: None,
cert_dir: None,
data_dir: None,
};
let snapshot = crate::config::compile(effective, 0, &policy).unwrap();
let route = snapshot
.select_on(
"app.docker1.lab.local",
"/",
"127.0.0.1:8080".parse().unwrap(),
false,
)
.unwrap();
assert!(route.unavailable);
assert!(route.pool.is_none());
assert!(route.backend.is_none());
}
#[test]
fn failed_agent_observation_with_last_inventory_keeps_backend_materialized() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
desired.vhosts.clear();
desired.docker_agents.agents.push(Agent {
id: "node1".into(),
name: "Node 1".into(),
endpoint: "http://10.0.0.2:9876".into(),
token: "012345678901234567890123".into(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
assignments: vec![Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
compression_enabled: Some(true),
compression_gzip: Some(false),
compression_br: Some(true),
..Default::default()
}],
..Default::default()
});
let mut observed = observation(
service(),
vec![HostAddress {
ip: "10.2.3.4".into(),
source: "interface".into(),
interface: "eno1".into(),
scope: "private".into(),
}],
);
let state = observed.get_mut("node1").unwrap();
state.ok = false;
state.error = Some("temporary agent outage".into());
let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1);
assert_eq!(effective.vhosts[0].backends.len(), 1);
assert!(!effective.vhosts[0].transient_unavailable);
assert_eq!(effective.vhosts[0].compression.enabled, Some(true));
assert_eq!(effective.vhosts[0].compression.gzip, Some(false));
assert_eq!(effective.vhosts[0].compression.br, Some(true));
}
#[test]
fn disabling_agent_stops_discovery_but_keeps_published_hostname_as_503() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
desired.vhosts.clear();
desired.docker_agents.agents.push(Agent {
id: "node1".into(),
name: "Node 1".into(),
endpoint: "http://10.0.0.2:9876".into(),
token: "012345678901234567890123".into(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
enabled: false,
assignments: vec![Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
..Default::default()
}],
..Default::default()
});
let observed = observation(
service(),
vec![HostAddress {
ip: "10.2.3.4".into(),
source: "interface".into(),
interface: "eno1".into(),
scope: "private".into(),
}],
);
let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1);
assert!(effective.vhosts[0].backends.is_empty());
assert!(effective.vhosts[0].transient_unavailable);
}
#[test]
fn docker_assignment_propagates_explicit_passive_health_policy() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.7"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.27.0"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.26.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.27.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+10
View File
@@ -18,6 +18,16 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
if let Some(spec) = &route.response {
return local_location_response(spec, request.method());
}
if route.unavailable {
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: published Docker service is currently unavailable\n",
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
return response;
}
if let crate::config::CompiledSource::WebServer(service) = &route.source {
if handshake.is_some() {
return error(
+1 -1
View File
@@ -130,7 +130,7 @@ 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::private_or_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(),
+1 -1
View File
@@ -333,7 +333,7 @@ async fn refresh_cache_once(
return false;
};
let origin_no_transform = crate::transform::no_transform(origin.headers());
let skip_compression = crate::compression::private_or_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(),
+10 -22
View File
@@ -284,29 +284,17 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
{
response.headers_mut().remove("alt-svc");
}
// Cookie-bearing requests are normally excluded from compression to avoid
// compressing potentially private dynamic content. The cache can explicitly
// ignore cookies for configured static extensions, though; once that request
// is a shared-cache HIT, cache policy has already proven the stored response
// contains no Set-Cookie/private/no-store metadata. Local Web Server static
// responses are equally independent of request cookies. Allow compression in
// those two bounded cases so cached/static browser assets still negotiate
// Content-Encoding.
if original_headers.contains_key("cookie")
&& (exchange.cache_status.load(Ordering::Relaxed) == 1
|| response.extensions().get::<StaticSourceResponse>().is_some())
{
response
.extensions_mut()
.insert(crate::compression::AllowCookie);
let global_compression = &snapshot.envelope.config.compression;
let mut compression = global_compression.clone();
if let Some(frontend) = frontend_context.as_ref() {
compression = frontend.compression.resolve(global_compression, &compression);
}
response = crate::compression::encode(
response,
&original_headers,
&method,
&snapshot.envelope.config.compression,
)
.await;
if let Some(route) = route_context.as_ref() {
if let crate::config::CompiledSource::WebServer(service) = &route.source {
compression = service.compression.resolve(global_compression, &compression);
}
}
response = crate::compression::encode(response, &original_headers, &method, &compression).await;
if let Some(encoding) = response
.extensions()
.get::<crate::compression::AppliedEncoding>()
+16 -3
View File
@@ -382,16 +382,29 @@ pub enum ServerMode {
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[serde(deny_unknown_fields)]
pub struct ServerHeader {
#[serde(default)]
pub mode: ServerMode,
#[serde(default = "legacy_server_header_value")]
pub value: String,
}
fn legacy_server_header_value() -> String {
format!("RustProxy/v{}", env!("CARGO_PKG_VERSION"))
}
impl ServerHeader {
pub(crate) fn legacy_compat_default() -> Self {
Self {
mode: ServerMode::Preserve,
value: legacy_server_header_value(),
}
}
}
impl Default for ServerHeader {
fn default() -> Self {
Self {
mode: ServerMode::Preserve,
value: format!("RustProxy/v{}", env!("CARGO_PKG_VERSION")),
mode: ServerMode::Custom,
value: "RustProxy".into(),
}
}
}
+10
View File
@@ -21,6 +21,16 @@ mod tests {
assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err());
assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err());
}
#[test]
fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() {
let fresh = crate::config::Config::default();
assert!(matches!(fresh.server_header.mode, ServerMode::Custom));
assert_eq!(fresh.server_header.value, "RustProxy");
let legacy: crate::config::Config = serde_json::from_str("{}").unwrap();
assert!(matches!(legacy.server_header.mode, ServerMode::Preserve));
}
#[test]
fn server_policy_and_injection() {
let mut headers = HeaderMap::new();
+5
View File
@@ -285,6 +285,8 @@ pub struct Vhost {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_policy: Option<crate::tls::ProtocolPolicy>,
#[serde(default)]
pub compression: crate::compression::Overrides,
#[serde(default)]
pub cache: crate::cache::Overrides,
#[serde(default)]
pub transform: crate::transform::Overrides,
@@ -311,6 +313,9 @@ pub struct Vhost {
/// Nginx-style upstream timeout controls. Empty values inherit process defaults.
#[serde(default)]
pub proxy_timeouts: ProxyTimeouts,
/// Runtime-only marker for a published Docker vhost whose service currently has no route target.
#[serde(skip)]
pub(crate) transient_unavailable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
+1 -1
View File
@@ -93,7 +93,7 @@ impl Vhost {
"vhost allows at most 32 backend members"
);
ensure!(
!needs_proxy || !self.backends.is_empty(),
!needs_proxy || !self.backends.is_empty() || self.transient_unavailable,
"vhost requires at least one backend member for Reverse Proxy sources"
);
let mut members = std::collections::HashSet::new();
+1
View File
@@ -388,6 +388,7 @@ impl Service {
try_files: self.try_files.clone(),
rewrites,
precompressed: self.precompressed.clone(),
compression: self.compression.clone(),
download_extensions: self.download_extensions.iter().cloned().collect(),
symlink_policy: self.symlink_policy,
script_extensions,
+3
View File
@@ -340,6 +340,8 @@ pub struct Service {
pub rewrites: Vec<RewriteRule>,
/// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client.
pub precompressed: Precompressed,
/// Optional downstream dynamic-compression override. Codecs remain global.
pub compression: crate::compression::Overrides,
/// Extensions that should be downloaded as attachments.
pub download_extensions: Vec<String>,
/// Symlink handling. The conservative default is `deny`.
@@ -368,6 +370,7 @@ impl Default for Service {
try_files: Vec::new(),
rewrites: Vec::new(),
precompressed: Precompressed::default(),
compression: crate::compression::Overrides::default(),
download_extensions: Vec::new(),
symlink_policy: SymlinkPolicy::Deny,
php: Php::default(),
+1
View File
@@ -53,6 +53,7 @@ pub struct CompiledService {
pub(super) try_files: Vec<String>,
pub(super) rewrites: Vec<CompiledRewriteRule>,
pub(super) precompressed: Precompressed,
pub compression: crate::compression::Overrides,
pub(super) download_extensions: HashSet<String>,
pub(super) symlink_policy: SymlinkPolicy,
pub(super) script_extensions: HashSet<String>,
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.7".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.27.0".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.7</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.27.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.26.7</b>"));
assert!(body.contains("RustProxy <b>0.27.0</b>"));
}
#[tokio::test]