v0.27.0
This commit is contained in:
1 parent
a9f9450f80
commit
762eb9e28b
81 files changed
+1242
-1095
No files matched your search
@@ -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
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user