v0.29.2
This commit is contained in:
1 parent
76cb4acef0
commit
68678e98be
84 files changed
+1519
-325
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.29.1_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.29.0_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
|
||||
|
||||
+77
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.2",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -412,6 +412,19 @@
|
||||
"target"
|
||||
],
|
||||
"description": "Backend-less client redirect for this location."
|
||||
},
|
||||
"internal": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Internal named location excluded from public URI matching; reachable only by internal dispatch."
|
||||
},
|
||||
"error_fallback": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ErrorFallback"
|
||||
}
|
||||
],
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -438,7 +451,10 @@
|
||||
"description": "Claim the direct www/non-www companion of the selected exact DNS hostname and redirect it permanently to that hostname."
|
||||
},
|
||||
"www_redirect_target": {
|
||||
"type": ["string", "null"],
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "An exact hostname from frontend.hosts to use as the canonical redirect destination. Null uses the first hostname for compatibility. Other hostnames continue serving the same vhost without redirects."
|
||||
},
|
||||
"forward_client_ip": {
|
||||
@@ -598,6 +614,15 @@
|
||||
},
|
||||
"compression": {
|
||||
"$ref": "#/components/schemas/CompressionOverrides"
|
||||
},
|
||||
"error_fallback": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ErrorFallback"
|
||||
}
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Error fallback for the main vhost source."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
@@ -5084,6 +5109,21 @@
|
||||
"maxLength": 8192
|
||||
},
|
||||
"description": "Trusted php.ini directive/value overrides combined into the FastCGI PHP_VALUE parameter. PHP_ADMIN_VALUE is not exposed."
|
||||
},
|
||||
"path_info": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Safely split /script.php/extra/path into SCRIPT_FILENAME/SCRIPT_NAME for the existing script plus PATH_INFO."
|
||||
},
|
||||
"php_admin_values": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"default": {},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
},
|
||||
"description": "Trusted operator PHP-FPM administrative php.ini directives packed into PHP_ADMIN_VALUE. Restrict access to FastCGI endpoints."
|
||||
}
|
||||
},
|
||||
"description": "Optional bounded direct FastCGI connection to a PHP-FPM pool."
|
||||
@@ -5574,6 +5614,41 @@
|
||||
"updated_files",
|
||||
"database"
|
||||
]
|
||||
},
|
||||
"ErrorFallback": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Enabled internal Custom Location ID in the same vhost."
|
||||
},
|
||||
"statuses": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"uniqueItems": true,
|
||||
"default": [
|
||||
404
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
403,
|
||||
404,
|
||||
500,
|
||||
502,
|
||||
503,
|
||||
504
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
],
|
||||
"description": "Nginx-style internal error fallback. The original request is internally dispatched without a client redirect."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+7
-1
@@ -2,10 +2,16 @@
|
||||
use crate::{auth::Session, runtime::ApiError};
|
||||
use axum::{extract::rejection::JsonRejection, response::Html, Extension};
|
||||
use http::StatusCode;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static ADMIN_INDEX: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub(super) async fn index(session: Option<Extension<Session>>) -> Html<&'static str> {
|
||||
if session.is_some() {
|
||||
Html(include_str!("web/index.html"))
|
||||
Html(ADMIN_INDEX.get_or_init(|| {
|
||||
include_str!("web/index.html")
|
||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"))
|
||||
}).as_str())
|
||||
} else {
|
||||
Html(include_str!("web/login.html"))
|
||||
}
|
||||
|
||||
@@ -2891,7 +2891,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3335,6 +3335,8 @@
|
||||
id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
|
||||
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span
|
||||
data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
|
||||
<label class="check"><input id="web-service-php-path-info" type="checkbox" /><span
|
||||
data-i18n="webServicePhpPathInfo">Enable PHP PATH_INFO splitting</span></label>
|
||||
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input
|
||||
id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
|
||||
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input
|
||||
@@ -3360,8 +3362,8 @@
|
||||
spellcheck="false"
|
||||
placeholder="memory_limit = 256M upload_max_filesize = 64M post_max_size = 64M"></textarea><small
|
||||
class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line.
|
||||
RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted
|
||||
here.</small></label>
|
||||
RustProxy sends the validated set as PHP_VALUE; Use the separate operator-only PHP_ADMIN_VALUE field for administrative overrides.</small></label>
|
||||
<label class="vh-wide"><span>PHP administrator INI overrides (PHP_ADMIN_VALUE)</span><textarea id="web-service-php-admin-values" maxlength="65536" rows="6" spellcheck="false" placeholder="open_basedir = /var/www/webmail/:/tmp/ display_errors = Off"></textarea><small class="fine">Trusted operator settings only. Restrict PHP-FPM socket/network access; do not use shared PHP-FPM pools as a security boundary.</small></label>
|
||||
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton"
|
||||
id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
|
||||
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a
|
||||
@@ -3635,7 +3637,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.29.0</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>__RUSTPROXY_VERSION__</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
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 }, 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()
|
||||
error_fallback: null, 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); }
|
||||
@@ -313,8 +313,10 @@
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
@@ -325,7 +327,8 @@
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -339,6 +342,7 @@
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
@@ -347,6 +351,9 @@
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
@@ -395,14 +402,15 @@
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
|
||||
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhLocations");
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhErrorFallbackHelp");
|
||||
locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
@@ -386,6 +390,7 @@
|
||||
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
|
||||
const bindingSelection = readBindings();
|
||||
const mainSourceType = str("source-type");
|
||||
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
|
||||
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
|
||||
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
|
||||
@@ -419,7 +424,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"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, 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"),
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, 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()) },
|
||||
|
||||
@@ -80,8 +80,10 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
function validatePhpCustomData(customParams, phpValues) {
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues)]
|
||||
function validatePhpCustomData(customParams, phpValues, phpAdminValues = {}) {
|
||||
// Never allow an admin override to conflict with ordinary PHP_VALUE.
|
||||
if (Object.keys(phpValues).some(name => Object.hasOwn(phpAdminValues, name))) throw new Error(t("webServicePhpValuesInvalid"));
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues), ...Object.entries(phpAdminValues)]
|
||||
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
|
||||
if (size > 128 * 1024) throw new Error(t("webServicePhpCustomDataTooLarge"));
|
||||
}
|
||||
@@ -156,8 +158,9 @@
|
||||
for (const id of [
|
||||
"web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout",
|
||||
"web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization",
|
||||
"web-service-php-path-info",
|
||||
"web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit",
|
||||
"web-service-php-custom-params", "web-service-php-values"
|
||||
"web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values"
|
||||
]) $(id).disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -194,9 +197,9 @@
|
||||
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
|
||||
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,
|
||||
enabled: false, path_info: 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,
|
||||
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}
|
||||
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}, php_admin_values: {}
|
||||
},
|
||||
htaccess: { enabled: false, filename: ".htaccess", scan_subdirectories: true, strict: false },
|
||||
hardening: { block_common_attacks: false, strip_unnecessary_headers: false }
|
||||
@@ -241,6 +244,7 @@
|
||||
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
|
||||
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
|
||||
$("web-service-php-enabled").checked = item.php?.enabled === true;
|
||||
$("web-service-php-path-info").checked = item.php?.path_info === true;
|
||||
$("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock";
|
||||
$("web-service-php-extensions").value = (item.php?.extensions || ["php"]).join(", ");
|
||||
$("web-service-php-connect-timeout").value = item.php?.connect_timeout_ms ?? 1000;
|
||||
@@ -252,6 +256,7 @@
|
||||
$("web-service-php-stderr-limit").value = item.php?.stderr_log_bytes ?? 4096;
|
||||
$("web-service-php-custom-params").value = mapLines(item.php?.custom_params);
|
||||
$("web-service-php-values").value = mapLines(item.php?.php_values);
|
||||
$("web-service-php-admin-values").value = mapLines(item.php?.php_admin_values);
|
||||
$("web-service-symlink-policy").value = item.symlink_policy || "deny";
|
||||
$("web-service-htaccess-enabled").checked = item.htaccess?.enabled === true;
|
||||
$("web-service-htaccess-filename").value = item.htaccess?.filename || ".htaccess";
|
||||
@@ -280,12 +285,14 @@
|
||||
preset.php.request_timeout_ms = Number($("web-service-php-request-timeout").value || preset.php.request_timeout_ms);
|
||||
preset.php.max_concurrent_requests = Number($("web-service-php-concurrency").value || preset.php.max_concurrent_requests);
|
||||
preset.php.pass_authorization = $("web-service-php-pass-authorization").checked;
|
||||
preset.php.path_info = $("web-service-php-path-info").checked;
|
||||
preset.php.max_request_body_bytes = Number($("web-service-php-body-limit").value || 0);
|
||||
preset.php.max_response_header_bytes = Number($("web-service-php-header-limit").value || preset.php.max_response_header_bytes);
|
||||
preset.php.stderr_log_bytes = Number($("web-service-php-stderr-limit").value || 0);
|
||||
preset.php.custom_params = parseFastCgiParams($("web-service-php-custom-params").value);
|
||||
preset.php.php_values = parsePhpValues($("web-service-php-values").value);
|
||||
validatePhpCustomData(preset.php.custom_params, preset.php.php_values);
|
||||
preset.php.php_admin_values = parsePhpValues($("web-service-php-admin-values").value);
|
||||
validatePhpCustomData(preset.php.custom_params, preset.php.php_values, preset.php.php_admin_values);
|
||||
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
|
||||
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
|
||||
populateSettings(preset);
|
||||
@@ -423,6 +430,7 @@
|
||||
},
|
||||
php: {
|
||||
enabled: phpEnabled,
|
||||
path_info: $("web-service-php-path-info").checked,
|
||||
extensions: lines($("web-service-php-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
|
||||
endpoint: $("web-service-php-endpoint").value.trim() || "unix:/run/php/php-fpm.sock",
|
||||
connect_timeout_ms: Number($("web-service-php-connect-timeout").value || 1000),
|
||||
@@ -433,10 +441,11 @@
|
||||
max_response_header_bytes: Number($("web-service-php-header-limit").value || 65536),
|
||||
stderr_log_bytes: Number($("web-service-php-stderr-limit").value || 0),
|
||||
custom_params: parseFastCgiParams($("web-service-php-custom-params").value),
|
||||
php_values: parsePhpValues($("web-service-php-values").value)
|
||||
php_values: parsePhpValues($("web-service-php-values").value),
|
||||
php_admin_values: parsePhpValues($("web-service-php-admin-values").value)
|
||||
}
|
||||
};
|
||||
validatePhpCustomData(item.php.custom_params, item.php.php_values);
|
||||
validatePhpCustomData(item.php.custom_params, item.php.php_values, item.php.php_admin_values);
|
||||
if (phpEnabled && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
|
||||
const next = structuredClone(envelope.config.web_services || []);
|
||||
const duplicate = next.find(service => service.id === item.id && service.id !== editing);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Desired configuration and its validated, immutable routing snapshot.
|
||||
include!("config/model.rs");
|
||||
include!("config/snapshot.rs");
|
||||
include!("config/internal_routing.rs");
|
||||
include!("config/backend.rs");
|
||||
include!("config/routing.rs");
|
||||
include!("config/compile.rs");
|
||||
|
||||
@@ -292,6 +292,7 @@ pub fn compile_with_active(
|
||||
"HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy"
|
||||
);
|
||||
Ok(Snapshot {
|
||||
web_php_services: web_services,
|
||||
vhost_metrics,
|
||||
forwarding,
|
||||
quic_tls,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
impl Snapshot {
|
||||
/// Resolve a vhost-local internal named location. Internal locations are
|
||||
/// deliberately excluded from ordinary URI matching and can only be reached
|
||||
/// by an explicit internal dispatch such as an error fallback.
|
||||
pub fn select_internal_on(
|
||||
&self,
|
||||
host: &str,
|
||||
vhost_id: &str,
|
||||
target: &str,
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&CompiledRoute> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
let owns_host = self.fallbacks.get(&pattern).is_some_and(|routes| {
|
||||
routes.iter().any(|route| {
|
||||
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
if !owns_host {
|
||||
continue;
|
||||
}
|
||||
return self.routes.get(&pattern).and_then(|routes| {
|
||||
routes.iter().find(|route| {
|
||||
route.internal
|
||||
&& route.name == target
|
||||
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -197,13 +197,17 @@ pub struct CompiledRoute {
|
||||
pub frontend: Option<Arc<CompiledFrontend>>,
|
||||
pub pool: Option<Arc<crate::balance::Pool>>,
|
||||
pub cache: Option<bool>,
|
||||
/// Vhost-local stable location ID (`main` for the main source).
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub internal: bool,
|
||||
pub path_prefix: String,
|
||||
pub match_type: crate::vhost::PathMatch,
|
||||
pub path_regex: Option<regex::Regex>,
|
||||
pub rewrite: crate::vhost::PathRewrite,
|
||||
pub response: Option<crate::vhost::LocationResponse>,
|
||||
pub redirect: Option<CompiledLocationRedirect>,
|
||||
pub error_fallback: Option<crate::vhost::ErrorFallback>,
|
||||
pub source: CompiledSource,
|
||||
pub backend: Option<Arc<CompiledBackend>>,
|
||||
pub acl_profile_id: Option<String>,
|
||||
@@ -265,6 +269,7 @@ pub struct ResolvedLogVhost {
|
||||
}
|
||||
|
||||
pub struct Snapshot {
|
||||
pub web_php_services: HashMap<String, Arc<crate::web_server::CompiledService>>,
|
||||
pub vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
|
||||
pub forwarding: crate::forwarding::Compiled,
|
||||
pub quic_tls: Option<Arc<quinn::crypto::rustls::QuicServerConfig>>,
|
||||
|
||||
+23
-6
@@ -91,8 +91,17 @@ fn compile_routing(
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
// Backend definitions are irrelevant to a Web Server main source unless
|
||||
// an enabled Custom Location explicitly uses Reverse Proxy.
|
||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||
|| host.routes.iter().any(|route| {
|
||||
route.enabled
|
||||
&& route.response.is_none()
|
||||
&& route.redirect.is_none()
|
||||
&& route.source.is_reverse_proxy()
|
||||
});
|
||||
let mut compiled_members = Vec::new();
|
||||
for definition in &host.backends {
|
||||
for definition in host.backends.iter().filter(|_| needs_proxy_pool) {
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
&desired_policy,
|
||||
@@ -240,13 +249,16 @@ fn compile_routing(
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: fallback_pool,
|
||||
cache: None,
|
||||
name: "main".into(),
|
||||
id: format!("{}/main", host.id),
|
||||
internal: false,
|
||||
path_prefix: "/".into(),
|
||||
match_type: crate::vhost::PathMatch::Prefix,
|
||||
path_regex: None,
|
||||
rewrite: crate::vhost::PathRewrite::default(),
|
||||
response: None,
|
||||
redirect: None,
|
||||
error_fallback: host.error_fallback.clone(),
|
||||
source: fallback_source,
|
||||
backend: fallback_backend,
|
||||
acl_profile_id: None,
|
||||
@@ -256,7 +268,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,
|
||||
unavailable: host.transient_unavailable && host.source.is_reverse_proxy(),
|
||||
};
|
||||
for name in &routing_hosts {
|
||||
fallbacks
|
||||
@@ -305,10 +317,12 @@ fn compile_routing(
|
||||
None
|
||||
}
|
||||
};
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
if !route.internal {
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
}
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
validate_location_response(route.response.as_ref())?;
|
||||
let compiled_redirect = validate_location_redirect(route.redirect.as_ref())?;
|
||||
@@ -377,13 +391,16 @@ fn compile_routing(
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: route_pool,
|
||||
cache: route.cache,
|
||||
name: route.id.clone(),
|
||||
id: format!("{}/{}", host.id, route.id),
|
||||
internal: route.internal,
|
||||
path_prefix: route.path_prefix.clone(),
|
||||
match_type: route.match_type,
|
||||
path_regex,
|
||||
rewrite: route.rewrite.clone(),
|
||||
response: route.response.clone(),
|
||||
redirect: compiled_redirect,
|
||||
error_fallback: route.error_fallback.clone(),
|
||||
source,
|
||||
backend,
|
||||
acl_profile_id: route.acl_profile_id.clone(),
|
||||
|
||||
@@ -76,6 +76,7 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn frontend(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -224,7 +225,8 @@ where
|
||||
F: Fn(&CompiledRoute) -> bool,
|
||||
{
|
||||
if let Some(exact) = routes.iter().find(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& route.match_type == crate::vhost::PathMatch::Exact
|
||||
&& route.path_prefix == path
|
||||
}) {
|
||||
@@ -233,7 +235,8 @@ where
|
||||
let prefix = routes
|
||||
.iter()
|
||||
.filter(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& matches!(
|
||||
route.match_type,
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority
|
||||
@@ -245,7 +248,8 @@ where
|
||||
return prefix;
|
||||
}
|
||||
if let Some(regex) = routes.iter().find(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& matches!(
|
||||
route.match_type,
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive
|
||||
|
||||
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.29.0"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server"
|
||||
)]
|
||||
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.29.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs {} - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.", env!("CARGO_PKG_VERSION"));
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
include!("prometheus/model.rs");
|
||||
include!("prometheus/core.rs");
|
||||
include!("prometheus/traffic.rs");
|
||||
include!("prometheus/web_php.rs");
|
||||
include!("prometheus/vhosts_backends.rs");
|
||||
include!("prometheus/docker_misc.rs");
|
||||
|
||||
|
||||
@@ -428,6 +428,7 @@ fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,7 @@ pub fn render(app: &App) -> String {
|
||||
render_core(app, &snapshot, &mut out, &mut families);
|
||||
render_vhosts(&snapshot, &mut families);
|
||||
render_traffic(app, &snapshot, &mut families);
|
||||
render_web_php(&snapshot, &mut families);
|
||||
render_backends(&snapshot, &docker_targets, &mut families);
|
||||
render_docker(&snapshot, &docker_targets, &mut families);
|
||||
render_misc(app, &snapshot, &mut families);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
fn render_web_php(snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
for service in &config.web_services {
|
||||
let Some(compiled) = snapshot.web_php_services.get(&service.id) else { continue };
|
||||
let web = &compiled.web_metrics;
|
||||
let wl = labels(&[("web_service", service.id.as_str())]);
|
||||
f.add("rust_proxy_web_server_requests_total", "counter", "Web Server requests completed through response headers, including PHP requests.", &wl, web.requests.load(Ordering::Relaxed));
|
||||
for (class, count) in [
|
||||
("2xx", &web.status_2xx), ("3xx", &web.status_3xx),
|
||||
("4xx", &web.status_4xx), ("5xx", &web.status_5xx),
|
||||
] {
|
||||
f.add("rust_proxy_web_server_responses_total", "counter", "Web Server responses by status class.", &format!("{wl},class=\"{class}\""), count.load(Ordering::Relaxed));
|
||||
}
|
||||
f.add("rust_proxy_web_server_response_header_duration_microseconds_total", "counter", "Web Server response header latency, excluding streamed body transfer.", &wl, web.response_headers_us.load(Ordering::Relaxed));
|
||||
let Some(php) = &compiled.php else { continue };
|
||||
let l = labels(&[("web_service", service.id.as_str())]);
|
||||
let m = &php.metrics;
|
||||
f.add("rust_proxy_php_requests_total", "counter", "PHP-FPM requests completed through response headers.", &l, m.requests.load(Ordering::Relaxed));
|
||||
for (class, count) in [
|
||||
("2xx", &m.status_2xx), ("3xx", &m.status_3xx),
|
||||
("4xx", &m.status_4xx), ("5xx", &m.status_5xx),
|
||||
] {
|
||||
f.add("rust_proxy_php_responses_total", "counter", "PHP response headers by status class.", &format!("{l},class=\"{class}\""), count.load(Ordering::Relaxed));
|
||||
}
|
||||
f.add("rust_proxy_php_response_header_duration_microseconds_total", "counter", "Cumulative PHP response header latency in microseconds, excluding streamed body time.", &l, m.response_headers_us.load(Ordering::Relaxed));
|
||||
f.add("rust_proxy_php_concurrency_available", "gauge", "Available PHP-FPM request permits in this Web Service.", &l, php.permits.available_permits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#[derive(Clone)]
|
||||
struct InternalFallbackRoute {
|
||||
vhost_id: String,
|
||||
target: String,
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FinalRouteContext(crate::config::CompiledRoute);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ErrorFallbackRequest {
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
version: Version,
|
||||
headers: HeaderMap,
|
||||
transport: Option<Transport>,
|
||||
snapshot: Arc<crate::config::Snapshot>,
|
||||
exchange: Option<Arc<Exchange>>,
|
||||
connection_lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
impl ErrorFallbackRequest {
|
||||
fn capture(request: &Request, snapshot: &Arc<crate::config::Snapshot>) -> Self {
|
||||
Self {
|
||||
method: request.method().clone(),
|
||||
uri: request.uri().clone(),
|
||||
version: request.version(),
|
||||
headers: request.headers().clone(),
|
||||
transport: request.extensions().get::<Transport>().copied(),
|
||||
snapshot: snapshot.clone(),
|
||||
exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
|
||||
connection_lease: request
|
||||
.extensions()
|
||||
.get::<Arc<ConnectionLease>>()
|
||||
.cloned(),
|
||||
stop: request.extensions().get::<ServerShutdown>().cloned(),
|
||||
depth: request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.map(|dispatch| dispatch.depth)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request {
|
||||
let mut request = Request::new(Body::from(body));
|
||||
*request.method_mut() = self.method.clone();
|
||||
*request.uri_mut() = self.uri.clone();
|
||||
*request.version_mut() = self.version;
|
||||
*request.headers_mut() = self.headers.clone();
|
||||
request.extensions_mut().insert(peer);
|
||||
request.extensions_mut().insert(local);
|
||||
if let Some(transport) = self.transport {
|
||||
request.extensions_mut().insert(transport);
|
||||
}
|
||||
request.extensions_mut().insert(self.snapshot.clone());
|
||||
if let Some(exchange) = &self.exchange {
|
||||
request.extensions_mut().insert(exchange.clone());
|
||||
}
|
||||
if let Some(lease) = &self.connection_lease {
|
||||
request.extensions_mut().insert(lease.clone());
|
||||
}
|
||||
if let Some(stop) = &self.stop {
|
||||
request.extensions_mut().insert(stop.clone());
|
||||
}
|
||||
request.extensions_mut().insert(dispatch);
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
enum ErrorFallbackBody {
|
||||
Disabled,
|
||||
Replayable(Bytes),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
|
||||
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result<ErrorFallbackBody, Response> {
|
||||
if !enabled {
|
||||
return Ok(ErrorFallbackBody::Disabled);
|
||||
}
|
||||
if request.body().is_end_stream() {
|
||||
return Ok(ErrorFallbackBody::Replayable(Bytes::new()));
|
||||
}
|
||||
let Some(length) = request.body().size_hint().exact() else {
|
||||
return Ok(ErrorFallbackBody::Unavailable);
|
||||
};
|
||||
if length > MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES {
|
||||
return Ok(ErrorFallbackBody::Unavailable);
|
||||
}
|
||||
let body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let collected = body.collect().await.map_err(|_| {
|
||||
error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n",
|
||||
)
|
||||
})?;
|
||||
let bytes = collected.to_bytes();
|
||||
*request.body_mut() = Body::from(bytes.clone());
|
||||
Ok(ErrorFallbackBody::Replayable(bytes))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_error_fallback(
|
||||
state: Arc<App>,
|
||||
peer: PeerAddr,
|
||||
local: LocalAddr,
|
||||
snapshot: &Arc<crate::config::Snapshot>,
|
||||
host: &str,
|
||||
tls: bool,
|
||||
route: &crate::config::CompiledRoute,
|
||||
status: StatusCode,
|
||||
template: &ErrorFallbackRequest,
|
||||
body: &ErrorFallbackBody,
|
||||
) -> Option<Response> {
|
||||
let fallback = route
|
||||
.error_fallback
|
||||
.as_ref()
|
||||
.filter(|fallback| fallback.matches(status))?;
|
||||
let frontend = route.frontend.as_ref()?;
|
||||
if template.depth >= MAX_ERROR_FALLBACK_DEPTH {
|
||||
tracing::warn!(vhost = %frontend.id, route = %route.id, target = %fallback.target, "error fallback depth limit reached");
|
||||
return None;
|
||||
}
|
||||
let Some(target) = snapshot
|
||||
.select_internal_on(host, &frontend.id, &fallback.target, local.0, tls)
|
||||
.cloned()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let ErrorFallbackBody::Replayable(body) = body else {
|
||||
tracing::warn!(vhost = %frontend.id, route = %route.id, target = %fallback.target, "error fallback request body is not replayable");
|
||||
return None;
|
||||
};
|
||||
let request = template.rebuild(
|
||||
body.clone(),
|
||||
peer,
|
||||
local,
|
||||
InternalFallbackRoute {
|
||||
vhost_id: frontend.id.clone(),
|
||||
target: fallback.target.clone(),
|
||||
depth: template.depth.saturating_add(1),
|
||||
},
|
||||
);
|
||||
let mut response = Box::pin(forward_request(state, peer, local, request)).await;
|
||||
if response.extensions().get::<FinalRouteContext>().is_none() {
|
||||
response.extensions_mut().insert(FinalRouteContext(target));
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
match upstream_result {
|
||||
Ok(Ok(mut response)) => {
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
|
||||
include!("success_prepare.rs");
|
||||
include!("success_finish.rs")
|
||||
@@ -53,6 +59,12 @@
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
status, &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
let mut response = error(
|
||||
status,
|
||||
if status == StatusCode::SERVICE_UNAVAILABLE {
|
||||
@@ -95,6 +107,12 @@
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
StatusCode::GATEWAY_TIMEOUT, &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
error(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: upstream response deadline exceeded\n",
|
||||
|
||||
@@ -39,6 +39,12 @@ if route.unavailable {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
@@ -88,6 +94,12 @@ if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
return response;
|
||||
}
|
||||
@@ -108,6 +120,12 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
Err(crate::balance::SelectionFailure::Capacity) => {
|
||||
@@ -118,6 +136,12 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
};
|
||||
@@ -128,10 +152,17 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
)
|
||||
} else {
|
||||
let Some(backend) = route.backend.clone() else {
|
||||
return error(
|
||||
let response = error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Reverse Proxy route has no backend\n",
|
||||
);
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
(backend, None, None)
|
||||
};
|
||||
|
||||
+43
-15
@@ -2,6 +2,15 @@ async fn forward(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(peer): Extension<PeerAddr>,
|
||||
Extension(local): Extension<LocalAddr>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
forward_request(state, peer, local, request).await
|
||||
}
|
||||
|
||||
async fn forward_request(
|
||||
state: Arc<App>,
|
||||
peer: PeerAddr,
|
||||
local: LocalAddr,
|
||||
mut request: Request,
|
||||
) -> Response {
|
||||
if request.method() == Method::CONNECT
|
||||
@@ -88,6 +97,10 @@ async fn forward(
|
||||
.get::<Arc<crate::config::Snapshot>>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let internal_dispatch = request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.cloned();
|
||||
let Ok(path) = crate::acl::routing_path(request.uri().path()) else {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -226,21 +239,23 @@ async fn forward(
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
if let Err(limited) = state.rate_limiter.check(
|
||||
snapshot.envelope.revision,
|
||||
&frontend.id,
|
||||
context.client_ip,
|
||||
country.as_deref(),
|
||||
&frontend.rate_limit,
|
||||
) {
|
||||
let mut response = error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RustProxy: request rate limit exceeded\n",
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) {
|
||||
response.headers_mut().insert("retry-after", value);
|
||||
if internal_dispatch.is_none() {
|
||||
if let Err(limited) = state.rate_limiter.check(
|
||||
snapshot.envelope.revision,
|
||||
&frontend.id,
|
||||
context.client_ip,
|
||||
country.as_deref(),
|
||||
&frontend.rate_limit,
|
||||
) {
|
||||
let mut response = error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RustProxy: request rate limit exceeded\n",
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) {
|
||||
response.headers_mut().insert("retry-after", value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
// Global host ACLs remain an independent restrictive scope and also apply to
|
||||
@@ -272,7 +287,15 @@ async fn forward(
|
||||
});
|
||||
return response;
|
||||
}
|
||||
let selected = snapshot.select_on(&host, &path, local.0, tls);
|
||||
let selected = if let Some(dispatch) = &internal_dispatch {
|
||||
if frontend.is_none_or(|frontend| frontend.id != dispatch.vhost_id) {
|
||||
None
|
||||
} else {
|
||||
snapshot.select_internal_on(&host, &dispatch.vhost_id, &dispatch.target, local.0, tls)
|
||||
}
|
||||
} else {
|
||||
snapshot.select_on(&host, &path, local.0, tls)
|
||||
};
|
||||
if let Some(route) = selected {
|
||||
if let Some(response) = method_policy_denial(&route.method_policy, request.method()) {
|
||||
return response;
|
||||
@@ -306,6 +329,7 @@ async fn forward(
|
||||
);
|
||||
}
|
||||
}
|
||||
let fallback_request = ErrorFallbackRequest::capture(&request, &snapshot);
|
||||
// The admin cookie is never ordinary application state. Strip it BEFORE
|
||||
// auth/cache and keep only the removed fragment locally. A verified self-admin
|
||||
// backend may restore it after the actual upstream has been selected.
|
||||
@@ -374,6 +398,10 @@ async fn forward(
|
||||
)
|
||||
});
|
||||
};
|
||||
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let (
|
||||
backend,
|
||||
backend_lease,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
include!("types.rs");
|
||||
include!("error_fallback.rs");
|
||||
include!("cache_helpers.rs");
|
||||
include!("upstream_helpers.rs");
|
||||
include!("redirect_helpers.rs");
|
||||
|
||||
@@ -20,6 +20,7 @@ use http::{
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
use bytes::Bytes;
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -38,6 +39,7 @@ struct LocalHostResponse {
|
||||
struct OriginTiming(Duration);
|
||||
#[derive(Clone)]
|
||||
struct OriginBackend(String);
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
|
||||
|
||||
@@ -191,6 +191,11 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
))
|
||||
});
|
||||
let mut response = next.run(request).await;
|
||||
let final_route_context = response
|
||||
.extensions()
|
||||
.get::<FinalRouteContext>()
|
||||
.map(|context| context.0.clone());
|
||||
let effective_route_context = final_route_context.as_ref().or(route_context.as_ref());
|
||||
let minimal_welcome = response
|
||||
.extensions()
|
||||
.get::<crate::pages::MinimalWelcome>()
|
||||
@@ -255,7 +260,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
effective_route_context,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -289,7 +294,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
if let Some(frontend) = frontend_context.as_ref() {
|
||||
compression = frontend.compression.resolve(global_compression, &compression);
|
||||
}
|
||||
if let Some(route) = route_context.as_ref() {
|
||||
if let Some(route) = effective_route_context {
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
compression = service.compression.resolve(global_compression, &compression);
|
||||
}
|
||||
@@ -355,7 +360,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
effective_route_context,
|
||||
);
|
||||
}
|
||||
if response
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! A Host header is never evidence that traffic arrived on a private interface.
|
||||
|
||||
include!("vhost/model.rs");
|
||||
include!("vhost/error_fallback.rs");
|
||||
include!("vhost/redirect.rs");
|
||||
include!("vhost/validation.rs");
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
pub const ERROR_FALLBACK_STATUSES: [u16; 6] = [403, 404, 500, 502, 503, 504];
|
||||
|
||||
fn default_error_fallback_statuses() -> Vec<u16> {
|
||||
vec![404]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ErrorFallback {
|
||||
/// Stable ID of an internal Custom Location within the same vhost.
|
||||
pub target: String,
|
||||
/// HTTP statuses intercepted before normal response finalization/cache publication.
|
||||
#[serde(default = "default_error_fallback_statuses")]
|
||||
pub statuses: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Default for ErrorFallback {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: String::new(),
|
||||
statuses: default_error_fallback_statuses(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorFallback {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.target = self.target.trim().to_owned();
|
||||
ensure!(crate::config::valid_id(&self.target), "invalid error fallback target");
|
||||
ensure!(
|
||||
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
|
||||
"error fallback requires 1..={} status codes",
|
||||
ERROR_FALLBACK_STATUSES.len()
|
||||
);
|
||||
self.statuses.sort_unstable();
|
||||
self.statuses.dedup();
|
||||
ensure!(
|
||||
self.statuses
|
||||
.iter()
|
||||
.all(|status| ERROR_FALLBACK_STATUSES.contains(status)),
|
||||
"error fallback status must be one of 403, 404, 500, 502, 503, 504"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, status: http::StatusCode) -> bool {
|
||||
self.statuses.contains(&status.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -7,7 +7,6 @@ use crate::{
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -17,12 +16,10 @@ fn root() -> String {
|
||||
fn weight() -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
pub const DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10;
|
||||
pub const DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120;
|
||||
pub const DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60;
|
||||
pub const DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ProxyTimeouts {
|
||||
@@ -53,7 +50,6 @@ pub enum MethodPolicyMode {
|
||||
Allow,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MethodPolicy {
|
||||
@@ -78,7 +74,6 @@ impl MethodPolicy {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn allows(&self, method: &http::Method) -> bool {
|
||||
let listed = self
|
||||
.methods
|
||||
@@ -90,14 +85,12 @@ impl MethodPolicy {
|
||||
MethodPolicyMode::Block => !listed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow_header(&self) -> Option<http::HeaderValue> {
|
||||
(self.mode == MethodPolicyMode::Allow)
|
||||
.then(|| self.methods.join(", ").parse::<http::HeaderValue>().ok())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -136,7 +129,6 @@ impl ProxyTimeouts {
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PathMatch {
|
||||
@@ -148,7 +140,6 @@ pub enum PathMatch {
|
||||
Regex,
|
||||
RegexInsensitive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RewriteMode {
|
||||
@@ -301,6 +292,9 @@ pub struct Vhost {
|
||||
/// Main source for the vhost fallback. Missing in older configs means Reverse Proxy.
|
||||
#[serde(default)]
|
||||
pub source: Source,
|
||||
/// Nginx-style internal error_page/proxy_intercept_errors behavior for the main source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
#[serde(default)]
|
||||
pub backends: Vec<PoolMember>,
|
||||
#[serde(default)]
|
||||
@@ -461,6 +455,10 @@ pub struct PathRoute {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_id: Option<String>,
|
||||
pub id: String,
|
||||
/// Internal named location. It is never selected by the public URI matcher and
|
||||
/// can only be reached through an error fallback from this vhost.
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
/// Prefix/exact path or a Rust-regex pattern, depending on `match_type`.
|
||||
#[serde(default = "root")]
|
||||
pub path_prefix: String,
|
||||
@@ -492,4 +490,8 @@ pub struct PathRoute {
|
||||
/// Local HTTP redirect; mutually exclusive with `response`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub redirect: Option<LocationRedirect>,
|
||||
/// Intercept selected upstream/Web Server error statuses and internally dispatch
|
||||
/// the original request to another internal Custom Location.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
}
|
||||
@@ -147,6 +147,44 @@ mod default_listener_tests {
|
||||
.unwrap();
|
||||
assert!(vhost.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_fallback_requires_internal_target_and_rejects_cycles() {
|
||||
let base = json!({
|
||||
"id": "fallback-test",
|
||||
"frontend": {"hosts": ["example.test"], "listener_ids": ["http"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9001", "allow_private": true}],
|
||||
"error_fallback": {"target": "redirect-check", "statuses": [503, 404, 404]},
|
||||
"routes": [{
|
||||
"id": "redirect-check",
|
||||
"internal": true,
|
||||
"path_prefix": "/",
|
||||
"source": {"type": "reverse_proxy"}
|
||||
}]
|
||||
});
|
||||
let mut valid: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
valid.validate().unwrap();
|
||||
assert_eq!(valid.error_fallback.as_ref().unwrap().statuses, vec![404, 503]);
|
||||
|
||||
let mut public_target: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
public_target.routes[0].internal = false;
|
||||
assert!(public_target
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("internal location"));
|
||||
|
||||
let mut cyclic: Vhost = serde_json::from_value(base).unwrap();
|
||||
cyclic.routes[0].error_fallback = Some(ErrorFallback {
|
||||
target: "redirect-check".into(),
|
||||
statuses: vec![404],
|
||||
});
|
||||
assert!(cyclic
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("recursive error fallback"));
|
||||
}
|
||||
#[test]
|
||||
fn collision_explains_addresses_owners_and_protocol_limits() {
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -4,6 +4,7 @@ impl Default for PathRoute {
|
||||
source: Source::default(),
|
||||
backend_id: None,
|
||||
id: "root".into(),
|
||||
internal: false,
|
||||
path_prefix: root(),
|
||||
match_type: PathMatch::Prefix,
|
||||
enabled: true,
|
||||
@@ -16,6 +17,7 @@ impl Default for PathRoute {
|
||||
rewrite: PathRewrite::default(),
|
||||
response: None,
|
||||
redirect: None,
|
||||
error_fallback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +33,7 @@ impl PathRoute {
|
||||
&& self.enabled
|
||||
&& self.source == Source::ReverseProxy
|
||||
&& self.backend_id.is_none()
|
||||
&& !self.internal
|
||||
&& self.cache.is_none()
|
||||
&& self.acl_profile_id.is_none()
|
||||
&& self.acl.is_none()
|
||||
@@ -42,6 +45,7 @@ impl PathRoute {
|
||||
&& self.rewrite.value.is_empty()
|
||||
&& self.response.is_none()
|
||||
&& self.redirect.is_none()
|
||||
&& self.error_fallback.is_none()
|
||||
}
|
||||
}
|
||||
impl Vhost {
|
||||
@@ -140,7 +144,15 @@ impl Vhost {
|
||||
// Normalize the untouched pre-0.16 synthetic root route out of the custom
|
||||
// location list so adding/removing Custom locations cannot delete fallback.
|
||||
self.routes.retain(|route| !route.is_legacy_main_fallback());
|
||||
if let Some(fallback) = &mut self.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
let mut route_ids = std::collections::HashSet::new();
|
||||
for route in &mut self.routes {
|
||||
ensure!(
|
||||
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
route.source.validate()?;
|
||||
ensure!(
|
||||
route.response.is_none() || route.redirect.is_none(),
|
||||
@@ -155,11 +167,55 @@ impl Vhost {
|
||||
route.source.is_reverse_proxy() || route.backend_id.is_none(),
|
||||
"Web Server location cannot pin a reverse-proxy backend"
|
||||
);
|
||||
if let Some(fallback) = &mut route.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
self.routes.len() <= 64,
|
||||
"at most 64 custom locations per vhost"
|
||||
);
|
||||
let resolve_target = |target: &str| {
|
||||
self.routes
|
||||
.iter()
|
||||
.find(|route| route.id == target && route.enabled && route.internal)
|
||||
};
|
||||
if let Some(fallback) = &self.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"vhost error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
for route in &self.routes {
|
||||
if let Some(fallback) = &route.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"location error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reject cycles at configuration time; a runtime depth guard remains as a
|
||||
// second line of defense for hot-reload/state inconsistencies.
|
||||
let mut starts = vec![None];
|
||||
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
|
||||
for start in starts {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut current = start;
|
||||
loop {
|
||||
let key = current.unwrap_or("<main>");
|
||||
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
|
||||
let fallback = match current {
|
||||
None => self.error_fallback.as_ref(),
|
||||
Some(id) => self
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.id == id)
|
||||
.and_then(|route| route.error_fallback.as_ref()),
|
||||
};
|
||||
let Some(fallback) = fallback else { break };
|
||||
current = Some(fallback.target.as_str());
|
||||
}
|
||||
}
|
||||
self.load_balancing.validate()?;
|
||||
self.proxy_timeouts.validate()?;
|
||||
self.rate_limit.validate()?;
|
||||
|
||||
@@ -242,11 +242,14 @@ impl Service {
|
||||
);
|
||||
self.php.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?;
|
||||
self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?;
|
||||
self.php.php_admin_values = normalize_php_values(std::mem::take(&mut self.php.php_admin_values))?;
|
||||
ensure!(!self.php.php_values.keys().any(|key| self.php.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
|
||||
let php_custom_bytes = self
|
||||
.php
|
||||
.custom_params
|
||||
.iter()
|
||||
.chain(self.php.php_values.iter())
|
||||
.chain(self.php.php_admin_values.iter())
|
||||
.map(|(name, value)| name.len() + value.len() + 2)
|
||||
.sum::<usize>();
|
||||
ensure!(
|
||||
@@ -350,6 +353,7 @@ impl Service {
|
||||
let php = if self.php.enabled {
|
||||
Some(CompiledPhp {
|
||||
extensions: self.php.extensions.iter().cloned().collect(),
|
||||
path_info: self.php.path_info,
|
||||
endpoint: FastCgiEndpoint::parse(&self.php.endpoint)?,
|
||||
connect_timeout: Duration::from_millis(self.php.connect_timeout_ms),
|
||||
request_timeout: Duration::from_millis(self.php.request_timeout_ms),
|
||||
@@ -360,11 +364,14 @@ impl Service {
|
||||
stderr_log_bytes: self.php.stderr_log_bytes,
|
||||
custom_params: self.php.custom_params.clone(),
|
||||
php_values: self.php.php_values.clone(),
|
||||
php_admin_values: self.php.php_admin_values.clone(),
|
||||
metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(CompiledService {
|
||||
web_metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
root,
|
||||
|
||||
@@ -257,6 +257,9 @@ impl Default for Precompressed {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Php {
|
||||
pub enabled: bool,
|
||||
/// Safely split `/script.php/extra/path` into SCRIPT_FILENAME + PATH_INFO.
|
||||
/// Disabled by default because it changes URL-to-script resolution semantics.
|
||||
pub path_info: bool,
|
||||
#[serde(default = "default_php_extensions")]
|
||||
pub extensions: Vec<String>,
|
||||
/// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`.
|
||||
@@ -282,12 +285,14 @@ pub struct Php {
|
||||
pub custom_params: BTreeMap<String, String>,
|
||||
/// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter.
|
||||
pub php_values: BTreeMap<String, String>,
|
||||
pub php_admin_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for Php {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
path_info: false,
|
||||
extensions: default_php_extensions(),
|
||||
endpoint: "unix:/run/php/php-fpm.sock".into(),
|
||||
connect_timeout_ms: default_php_connect_timeout_ms(),
|
||||
@@ -299,6 +304,7 @@ impl Default for Php {
|
||||
stderr_log_bytes: default_php_stderr_log_bytes(),
|
||||
custom_params: BTreeMap::new(),
|
||||
php_values: BTreeMap::new(),
|
||||
php_admin_values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,23 +24,27 @@ impl FastCgiEndpoint {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CompiledPhp {
|
||||
pub(crate) struct CompiledPhp {
|
||||
pub(super) extensions: HashSet<String>,
|
||||
pub(super) path_info: bool,
|
||||
pub(super) endpoint: FastCgiEndpoint,
|
||||
pub(super) connect_timeout: Duration,
|
||||
pub(super) request_timeout: Duration,
|
||||
pub(super) permits: Arc<Semaphore>,
|
||||
pub(crate) permits: Arc<Semaphore>,
|
||||
pub(super) pass_authorization: bool,
|
||||
pub(super) max_request_body_bytes: u64,
|
||||
pub(super) max_response_header_bytes: usize,
|
||||
pub(super) stderr_log_bytes: usize,
|
||||
pub(super) custom_params: BTreeMap<String, String>,
|
||||
pub(super) php_values: BTreeMap<String, String>,
|
||||
pub(super) php_admin_values: BTreeMap<String, String>,
|
||||
pub(crate) metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledService {
|
||||
pub id: String,
|
||||
pub(crate) web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
pub name: String,
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) index: Vec<String>,
|
||||
@@ -57,7 +61,7 @@ pub struct CompiledService {
|
||||
pub(super) download_extensions: HashSet<String>,
|
||||
pub(super) symlink_policy: SymlinkPolicy,
|
||||
pub(super) script_extensions: HashSet<String>,
|
||||
pub(super) php: Option<CompiledPhp>,
|
||||
pub(crate) php: Option<CompiledPhp>,
|
||||
pub(super) htaccess: Option<htaccess::Compiled>,
|
||||
pub(super) hardening: Hardening,
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -123,6 +123,7 @@ pub(super) fn fastcgi_params(
|
||||
headers: &HeaderMap,
|
||||
original_uri: &Uri,
|
||||
script_path: &str,
|
||||
path_info: Option<&str>,
|
||||
query: Option<&str>,
|
||||
context: &RequestContext,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -141,6 +142,9 @@ pub(super) fn fastcgi_params(
|
||||
params.insert("REQUEST_METHOD".into(), method.as_str().into());
|
||||
params.insert("SCRIPT_FILENAME".into(), script_filename);
|
||||
params.insert("SCRIPT_NAME".into(), script_path.into());
|
||||
if let Some(path_info) = path_info.filter(|value| !value.is_empty()) {
|
||||
params.insert("PATH_INFO".into(), path_info.into());
|
||||
}
|
||||
params.insert("QUERY_STRING".into(), query.unwrap_or("").into());
|
||||
params.insert("REQUEST_URI".into(), request_uri.into());
|
||||
params.insert("DOCUMENT_ROOT".into(), document_root);
|
||||
@@ -159,7 +163,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.29.0".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), concat!("RustProxy/", env!("CARGO_PKG_VERSION")).into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
@@ -213,6 +217,10 @@ pub(super) fn fastcgi_params(
|
||||
}
|
||||
params.insert("PHP_VALUE".into(), value);
|
||||
}
|
||||
if !php.php_admin_values.is_empty() {
|
||||
let value = php.php_admin_values.iter().map(|(name, item)| format!("{name}={item}")).collect::<Vec<_>>().join("\n");
|
||||
params.insert("PHP_ADMIN_VALUE".into(), value);
|
||||
}
|
||||
// Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_*
|
||||
// names are derived above. PHP_VALUE comes exclusively from validated operator config.
|
||||
let mut encoded = Vec::with_capacity(4096);
|
||||
|
||||
@@ -5,6 +5,25 @@ pub(super) async fn php_response(
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let response = php_response_inner( service, php, method, headers, original_uri, script_path, path_info, query, body, context).await;
|
||||
php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
|
||||
async fn php_response_inner(
|
||||
service: &CompiledService,
|
||||
php: &CompiledPhp,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
@@ -45,6 +64,7 @@ pub(super) async fn php_response(
|
||||
&headers,
|
||||
&original_uri,
|
||||
&script_path,
|
||||
path_info.as_deref(),
|
||||
query.as_deref(),
|
||||
&context,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Fixed-cardinality PHP counters. Values are per compiled Web Service snapshot.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PhpMetrics {
|
||||
pub(crate) requests: AtomicU64,
|
||||
pub(crate) status_2xx: AtomicU64,
|
||||
pub(crate) status_3xx: AtomicU64,
|
||||
pub(crate) status_4xx: AtomicU64,
|
||||
pub(crate) status_5xx: AtomicU64,
|
||||
pub(crate) response_headers_us: AtomicU64,
|
||||
}
|
||||
|
||||
impl PhpMetrics {
|
||||
pub fn record(&self, status: http::StatusCode, elapsed_us: u64) {
|
||||
self.requests.fetch_add(1, Ordering::Relaxed);
|
||||
let counter = match status.as_u16() / 100 {
|
||||
2 => Some(&self.status_2xx),
|
||||
3 => Some(&self.status_3xx),
|
||||
4 => Some(&self.status_4xx),
|
||||
5 => Some(&self.status_5xx),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = counter { counter.fetch_add(1, Ordering::Relaxed); }
|
||||
self.response_headers_us.fetch_add(elapsed_us, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod fastcgi;
|
||||
mod filesystem;
|
||||
mod htaccess;
|
||||
mod metrics;
|
||||
mod request;
|
||||
mod response;
|
||||
mod routing;
|
||||
|
||||
@@ -30,9 +30,9 @@ pub(super) async fn file_response(
|
||||
}
|
||||
|
||||
let range_header = headers.get(RANGE).and_then(|value| value.to_str().ok());
|
||||
let range = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_range(value, length)) {
|
||||
Some(range) => Some(range),
|
||||
let ranges = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_ranges(value, length)) {
|
||||
Some(ranges) => Some(ranges),
|
||||
None => {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
@@ -64,25 +64,49 @@ pub(super) async fn file_response(
|
||||
None
|
||||
};
|
||||
|
||||
let (status, start, body_length) = match range {
|
||||
Some(range) => (
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
range.start,
|
||||
range.end - range.start + 1,
|
||||
),
|
||||
let multipart = ranges.as_ref().is_some_and(|ranges| ranges.len() > 1);
|
||||
let single_range = ranges.as_ref().and_then(|ranges| (ranges.len() == 1).then_some(ranges[0]));
|
||||
let (status, start, body_length) = match single_range {
|
||||
Some(range) => (StatusCode::PARTIAL_CONTENT, range.start, range.end - range.start + 1),
|
||||
None if multipart => (StatusCode::PARTIAL_CONTENT, 0, 0),
|
||||
None => (StatusCode::OK, 0, length),
|
||||
};
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(CONTENT_TYPE, mime.clone());
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&body_length.to_string()).unwrap(),
|
||||
);
|
||||
let boundary = multipart.then(|| format!("rustproxy-{:016x}", rand::random::<u64>()));
|
||||
if let Some(boundary) = &boundary {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(&format!("multipart/byteranges; boundary={boundary}")).unwrap(),
|
||||
);
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream");
|
||||
let mut total = 0u64;
|
||||
for range in ranges.as_ref().expect("multipart range set") {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
total = total
|
||||
.saturating_add(header.len() as u64)
|
||||
.saturating_add(range.end - range.start + 1)
|
||||
.saturating_add(2);
|
||||
}
|
||||
total = total.saturating_add(format!("--{boundary}--\r\n").len() as u64);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&total.to_string()).unwrap(),
|
||||
);
|
||||
} else {
|
||||
response.headers_mut().insert(CONTENT_TYPE, mime.clone());
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&body_length.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
if let Some(range) = range {
|
||||
if let Some(range) = single_range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end))
|
||||
@@ -100,11 +124,39 @@ pub(super) async fn file_response(
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
if method == Method::HEAD || body_length == 0 {
|
||||
if method == Method::HEAD || (!multipart && body_length == 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::from_std(opened.file);
|
||||
if let Some(boundary) = boundary {
|
||||
let ranges = ranges.expect("multipart ranges");
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream").to_owned();
|
||||
let (mut writer, reader) = tokio::io::duplex(64 * 1024);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
for range in ranges {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
if writer.write_all(header.as_bytes()).await.is_err()
|
||||
|| file.seek(std::io::SeekFrom::Start(range.start)).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut limited = (&mut file).take(range.end - range.start + 1);
|
||||
if tokio::io::copy(&mut limited, &mut writer).await.is_err()
|
||||
|| writer.write_all(b"\r\n").await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = writer.write_all(format!("--{boundary}--\r\n").as_bytes()).await;
|
||||
});
|
||||
*response.body_mut() = Body::from_stream(ReaderStream::new(reader));
|
||||
return response;
|
||||
}
|
||||
if start != 0 {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
@@ -185,7 +237,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.29.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(&format!(r#"</div></section><footer class="page-footer"><span>RustProxy <b>{}</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#, env!("CARGO_PKG_VERSION")));
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -308,33 +308,52 @@ struct ByteRange {
|
||||
end: u64,
|
||||
}
|
||||
|
||||
fn parse_range(value: &str, length: u64) -> Option<ByteRange> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.contains(',') || value.is_empty() || length == 0 {
|
||||
return None;
|
||||
const MAX_BYTE_RANGES: usize = 16;
|
||||
|
||||
fn parse_range_item(value: &str, length: u64) -> Result<Option<ByteRange>, ()> {
|
||||
if length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (start, end) = value.split_once('-')?;
|
||||
let (start, end) = value.split_once('-').ok_or(())?;
|
||||
if start.is_empty() {
|
||||
let suffix: u64 = end.parse().ok()?;
|
||||
let suffix: u64 = end.parse().map_err(|_| ())?;
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let size = suffix.min(length);
|
||||
return Some(ByteRange {
|
||||
return Ok(Some(ByteRange {
|
||||
start: length - size,
|
||||
end: length - 1,
|
||||
});
|
||||
}));
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let start: u64 = start.parse().map_err(|_| ())?;
|
||||
if start >= length {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
length - 1
|
||||
} else {
|
||||
end.parse::<u64>().ok()?.min(length - 1)
|
||||
end.parse::<u64>().map_err(|_| ())?.min(length - 1)
|
||||
};
|
||||
(end >= start).then_some(ByteRange { start, end })
|
||||
Ok((end >= start).then_some(ByteRange { start, end }))
|
||||
}
|
||||
|
||||
fn parse_ranges(value: &str, length: u64) -> Option<Vec<ByteRange>> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
for (index, item) in value.split(',').enumerate() {
|
||||
if index >= MAX_BYTE_RANGES {
|
||||
return None;
|
||||
}
|
||||
match parse_range_item(item.trim(), length).ok()? {
|
||||
Some(range) => ranges.push(range),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
(!ranges.is_empty()).then_some(ranges)
|
||||
}
|
||||
|
||||
fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
||||
|
||||
@@ -126,6 +126,16 @@ pub(super) async fn select_precompressed(
|
||||
if !service.precompressed.enabled || components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Multipart byte ranges mix MIME boundaries with representation bytes. Keep
|
||||
// those responses on the identity representation so Content-Encoding never
|
||||
// describes only the individual file fragments.
|
||||
if headers
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains(','))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let candidates = [
|
||||
(service.precompressed.brotli, "br", ".br"),
|
||||
(service.precompressed.gzip, "gzip", ".gz"),
|
||||
|
||||
+57
-1
@@ -20,6 +20,35 @@ use http::{
|
||||
};
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
async fn resolve_php_path_info(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
) -> Option<(String, String)> {
|
||||
let php = service.php.as_ref().filter(|php| php.path_info)?;
|
||||
if components.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
for script_end in 0..components.len() - 1 {
|
||||
let script_path = format!("/{}", components[..=script_end].join("/"));
|
||||
if !php.extensions.iter().any(|extension| {
|
||||
PathBuf::from(&script_path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
match open_components(service, components[..=script_end].to_vec()).await {
|
||||
Ok(opened) if opened.metadata.is_file() => {
|
||||
let path_info = format!("/{}", components[script_end + 1..].join("/"));
|
||||
return Some((script_path, path_info));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn serve_inner(
|
||||
service: &CompiledService,
|
||||
method: Method,
|
||||
@@ -104,11 +133,34 @@ async fn serve_inner(
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let mut request_body = Some(body);
|
||||
let opened = match open_components(service, components.clone()).await {
|
||||
Ok(opened) => opened,
|
||||
Err(error)
|
||||
if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
|
||||
{
|
||||
if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await {
|
||||
let public_path = PathBuf::from(&script_path);
|
||||
if let Some(php) = php_for_path(service, &public_path) {
|
||||
return php_response(
|
||||
service,
|
||||
php,
|
||||
method,
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
Some(path_info),
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return io_response(&error);
|
||||
}
|
||||
Err(error) => return io_response(&error),
|
||||
};
|
||||
let mut request_body = Some(body);
|
||||
|
||||
if opened.metadata.is_file() {
|
||||
let public_path = PathBuf::from(&effective_path);
|
||||
@@ -120,6 +172,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
effective_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -172,6 +225,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -289,9 +343,11 @@ pub async fn serve(
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
let mut response = serve_inner(service, method, headers, uri, path, body, context).await;
|
||||
add_service_headers(&mut response, service);
|
||||
htaccess::apply_response(service.htaccess.as_ref(), &mut response, path);
|
||||
strip_unnecessary_headers(&mut response, service);
|
||||
service.web_metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
@@ -182,6 +182,43 @@ async fn validators_and_single_ranges_work() {
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_byte_ranges_stream_each_requested_part() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-1,6-8".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned();
|
||||
assert!(content_type.starts_with("multipart/byteranges; boundary="));
|
||||
assert!(!response.headers().contains_key(CONTENT_RANGE));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body.contains("Content-Range: bytes 0-1/10"));
|
||||
assert!(body.contains("Content-Range: bytes 6-8/10"));
|
||||
assert!(body.contains("\r\n01\r\n"));
|
||||
assert!(body.contains("\r\n678\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn too_many_byte_ranges_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let ranges = std::iter::repeat("0-0").take(17).collect::<Vec<_>>().join(",");
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, format!("bytes={ranges}").parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_escapes_html_and_encodes_urls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -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.29.0</b>"));
|
||||
assert!(body.contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,6 +22,7 @@ fn fastcgi_never_forwards_proxy_header_as_http_proxy() {
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
@@ -69,6 +70,7 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
@@ -86,6 +88,49 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_path_info_keeps_script_filename_separate() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-path-info".into(),
|
||||
name: "PHP PATH_INFO".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
assert!(service.php.as_ref().unwrap().path_info);
|
||||
let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/index.php",
|
||||
Some("/foo/bar"),
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(encoded.windows(b"PATH_INFO".len()).any(|window| window == b"PATH_INFO"));
|
||||
assert!(encoded.windows(b"/foo/bar".len()).any(|window| window == b"/foo/bar"));
|
||||
assert!(encoded.windows(b"SCRIPT_FILENAME".len()).any(|window| window == b"SCRIPT_FILENAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -209,3 +254,34 @@ async fn hardening_strips_fingerprint_headers_after_source_rules() {
|
||||
assert!(!response.headers().contains_key("x-powered-by"));
|
||||
assert_eq!(response.headers()["x-custom"], "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_admin_values_are_operator_only_and_serialized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-admin".into(), name: "PHP admin".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true, endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
php_admin_values: BTreeMap::from([("open_basedir".into(), "/var/www/webmail/:/tmp/".into())]),
|
||||
..Default::default()
|
||||
}, ..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(),
|
||||
&"/index.php".parse().unwrap(), "/index.php", None, None,
|
||||
&RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None,
|
||||
server_name: "webmail.example.test".into(), server_port: 8589, tls: false,
|
||||
version: Version::HTTP_11 },
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"PHP_ADMIN_VALUE".len()).any(|w| w == b"PHP_ADMIN_VALUE"));
|
||||
assert!(encoded.windows(b"open_basedir=/var/www/webmail/:/tmp/".len())
|
||||
.any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/"));
|
||||
configured.php.php_values.insert("open_basedir".into(), "/".into());
|
||||
assert!(configured.validate().is_err());
|
||||
configured.php.php_values.clear();
|
||||
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
|
||||
assert!(configured.validate().is_err());
|
||||
}
|
||||
Reference in new issue
Block a user