This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 22:22:46 +02:00
1 parent 76cb4acef0
commit 68678e98be
84 files changed
+1519 -325

No files matched your search

+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.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
View File
@@ -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
View File
@@ -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"))
}
+6 -4
View File
@@ -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&#10;upload_max_filesize = 64M&#10;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/&#10;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>
+13 -5
View File
@@ -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}`;
+7 -2
View File
@@ -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()) },
+17 -8
View File
@@ -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
View File
@@ -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");
+1
View File
@@ -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,
+34
View File
@@ -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
}
}
+5
View File
@@ -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
View File
@@ -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(),
+7 -3
View File
@@ -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
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.29.0"
about = "RustProxy - independent Rust reverse proxy and static Web Server"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.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();
+1
View File
@@ -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");
+1
View File
@@ -428,6 +428,7 @@ fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) {
counter.load(Ordering::Relaxed),
);
}
}
}
}
+1
View File
@@ -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);
+28
View File
@@ -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());
}
}
+155
View File
@@ -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)
}
+18
View File
@@ -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",
+32 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
include!("types.rs");
include!("error_fallback.rs");
include!("cache_helpers.rs");
include!("upstream_helpers.rs");
include!("redirect_helpers.rs");
+2
View File
@@ -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},
+8 -3
View File
@@ -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
+1
View File
@@ -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)]
+50
View File
@@ -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
View File
@@ -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>,
}
+38
View File
@@ -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();
+56
View File
@@ -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()?;
+7
View File
@@ -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,
+6
View File
@@ -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(),
}
}
}
+7 -3
View File
@@ -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)]
+9 -1
View File
@@ -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);
+20
View File
@@ -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,
) {
+27
View File
@@ -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);
}
}
+1
View File
@@ -8,6 +8,7 @@ mod config;
mod fastcgi;
mod filesystem;
mod htaccess;
mod metrics;
mod request;
mod response;
mod routing;
+69 -17
View File
@@ -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()
}
+32 -13
View File
@@ -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 {
+10
View File
@@ -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
View File
@@ -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
}
+37
View File
@@ -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();
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.29.0</b>"));
assert!(body.contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
}
#[tokio::test]
+76
View File
@@ -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());
}