This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 00:18:06 +02:00
1 parent ad95bfb050
commit 1431dd47f0
62 files changed
+1360 -198

No files matched your search

+5
View File
@@ -173,6 +173,11 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md")),
),
(
"docs/PHP_FPM_ISOLATION.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM_ISOLATION.md")),
),
(
"docs/PROMETHEUS.md",
"guides",
+44 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.29.6",
"version": "0.29.7",
"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": [
@@ -527,6 +527,30 @@
}
},
"description": "Dangerous per-vhost-only request-side forced-cache target prefixes. Response privacy exclusions remain authoritative."
},
"expose_only_hits": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "If true, only emit cache diagnostic headers for actual HIT responses, never MISS/BYPASS. null inherits global expose_only_hits. Via is controlled independently."
}
},
"additionalProperties": true
},
"pages": {
"type": "object",
"description": "Vhost response/error pages and optional application HTTP-error interception. Interception replaces application response HTML with the configured vhost/global page, or a built-in RustProxy page, before cache admission. The application's HTTP status is always retained, regardless of standalone page status overrides. Internal location fallbacks take priority.",
"properties": {
"intercept_statuses": {
"type": "array",
"default": [],
"uniqueItems": true,
"maxItems": 11,
"description": "Off by default. For these origin response HTTP statuses, replace the application response with a RustProxy error page (no-store). Does not change RustProxy-generated routing/security errors.",
"items": {
"type": "integer",
"enum": [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
}
}
},
"additionalProperties": true
@@ -1818,6 +1842,11 @@
"maximum": 95,
"default": 80,
"description": "HOT usage waterline targeted by automatic idle trimming."
},
"expose_only_hits": {
"type": "boolean",
"default": false,
"description": "Suppress RustProxy cache diagnostic headers on MISS/BYPASS. Cache HIT diagnostics remain governed by expose_* flags. The independently configured Via header is unaffected."
}
},
"additionalProperties": false
@@ -1904,6 +1933,20 @@
"type": "boolean",
"default": false,
"description": "Explicitly allow this backend to resolve to a local RustProxy listener, including a listener using port 9090. This can create a proxy loop or expose the administration plane. Loopback/private targets still require allow_private=true."
},
"health_check_enabled": {
"type": "boolean",
"default": true,
"description": "Enable both active and passive health monitoring for this backend. False still permits internal pinned-location traffic and does not change pool_eligible."
},
"health_check": {
"allOf": [
{
"$ref": "#/components/schemas/DockerHealthCheck"
}
],
"nullable": true,
"description": "Optional per-backend active health policy; null inherits the vhost policy, including its default Host."
}
},
"additionalProperties": true,
+2 -2
View File
@@ -29,7 +29,7 @@ function renderSettings() {
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; $("cache-only-hits").checked = !!c.expose_only_hits;
const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header;
$("cache-via-enabled").checked = viaHeader !== null; $("cache-via-value").value = viaHeader || "RustProxy"; syncCacheVia();
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
@@ -285,7 +285,7 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
+42 -50
View File
@@ -2277,6 +2277,7 @@
type="checkbox" /><span>Cache-Status (RFC 9211)</span></label><label
class="check"><input id="cache-resident-age-header"
type="checkbox" /><span>X-Cache-Resident-Age</span></label></div>
<label class="check"><input id="cache-only-hits" type="checkbox" /><span data-i18n="cacheOnlyHits">Expose cache diagnostics only on HIT (hide MISS/BYPASS)</span></label>
<div class="cache-via-setting">
<label class="check"><input id="cache-via-enabled" type="checkbox" /><span
data-i18n="cacheViaEnable">Add Via response header</span></label>
@@ -3331,47 +3332,35 @@
<small class="fine" data-i18n="phpProfileHelp">Select a shared profile or edit PHP-FPM locally. Profile changes apply to all attached Web Services.</small>
</label>
<div class="vh-wide"><button type="button" class="outline" id="web-service-php-profiles" data-i18n="phpProfileManage">Manage PHP-FPM profiles</button></div>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span
data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<div class="vh-wide php-switch-row"><label class="check php-switch-label"><input id="web-service-php-enabled" type="checkbox" /><span data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label><details class="metric-info php-field-info"><summary data-i18n-aria="phpHintEnabled" aria-label="About enabling PHP-FPM">i</summary><div class="metric-info-popover"><p class="fine" data-i18n="phpHintEnabled">Enables execution of configured PHP extensions via the PHP-FPM socket. The pool must already be running; RustProxy does not start PHP. Other files remain static.</p></div></details></div>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input
id="web-service-php-endpoint" maxlength="512"
placeholder="unix:/run/php/php-fpm.sock" /><small class="fine"
data-i18n="webServicePhpEndpointHelp">Unix: unix:/run/php/php-fpm.sock. TCP:
tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback
addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input
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
id="web-service-php-request-timeout" min="100" max="3600000" step="1"
type="number" /></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input
id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 =
unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1"
type="number" /></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input
id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input
id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input id="web-service-php-extensions" maxlength="512" placeholder="php, phtml" /><small class="fine" data-i18n="phpHintExtensions">Comma-separated extensions without dots (e.g. php, phtml). Only listed types are executed as PHP.</small></label>
<div class="vh-wide php-switch-row"><label class="check php-switch-label"><input id="web-service-php-pass-authorization" type="checkbox" /><span data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label><details class="metric-info php-field-info"><summary data-i18n-aria="phpHintPassAuthorization" aria-label="Authorization forwarding information">i</summary><div class="metric-info-popover"><p class="fine" data-i18n="phpHintPassAuthorization">Passes the incoming Authorization header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic authentication; disable if the application does not need it.</p></div></details></div>
<div class="vh-wide php-switch-row"><label class="check php-switch-label"><input id="web-service-php-path-info" type="checkbox" /><span data-i18n="webServicePhpPathInfo">Enable PHP PATH_INFO splitting</span></label><details class="metric-info php-field-info"><summary data-i18n-aria="phpHintPathInfo" aria-label="PHP PATH_INFO information">i</summary><div class="metric-info-popover"><p class="fine" data-i18n="phpHintPathInfo">Allows /static.php/skins/file.css to run the existing static.php script with PATH_INFO=/skins/file.css. Needed by Roundcube static.php routes; usually not required for WordPress. Off by default.</p></div></details></div>
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /><small class="fine" data-i18n="phpHintConnectTimeout">How long to wait for the FPM socket connection; e.g. 1000 = 1 s (50–60000).</small></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input id="web-service-php-request-timeout" min="100" max="3600000" step="1" type="number" /><small class="fine" data-i18n="phpHintRequestTimeout">Time for the complete FastCGI exchange; e.g. 30000 = 30 s (100–3600000).</small></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /><small class="fine" data-i18n="phpHintConcurrency">Max simultaneous PHP requests for this service; e.g. 64. Exhaustion returns 503 (1–4096).</small></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 = unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1" type="number" /><small class="fine" data-i18n="phpHintBodyLimit">Bytes, not MB: 67108864 = 64 MiB; 0 = no proxy-side limit. PHP post_max_size is separate.</small></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /><small class="fine" data-i18n="phpHintHeaderLimit">Max PHP response-header size in bytes; default 65536 = 64 KiB (4096–1048576).</small></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /><small class="fine" data-i18n="phpHintStderrLimit">Max FastCGI STDERR bytes kept per request; e.g. 4096. 0 disables capture.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI
parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6"
spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small
class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line.
Static trusted values only; core CGI, HTTP_* and PHP_* control names are
reserved.</small></label>
reserved. Example: APP_ENV = production; no variable interpolation.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides
(PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6"
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; 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>
Examples: memory_limit = 256M, upload_max_filesize = 64M, post_max_size = 64M, max_execution_time = 30. Use PHP_ADMIN_VALUE for operator-only settings.</small></label>
<label class="vh-wide"><span data-i18n="phpProfileAdminValues">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" data-i18n="phpHintAdminValues">Admin-only directives, e.g. open_basedir = /var/www/webmail/:/tmp/. For isolation use a dedicated PHP-FPM pool/socket; dynamic INI overrides can persist across requests sharing a worker.</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
@@ -3411,6 +3400,8 @@
<p class="fine" data-i18n="phpProfileDescription">Expand a profile to edit it. Changes are saved immediately and shared by every attached Web Service.</p>
<div id="php-fpm-profiles-list"></div>
<button type="button" class="outline" id="php-fpm-profile-add" data-i18n="phpProfileAdd">Add profile</button>
<hr class="php-profile-help-divider" />
<button type="button" class="outline" id="php-fpm-profiles-help" data-i18n="webServicePhpDocsButton">FastCGI syntax and safety</button>
</div>
</dialog>
<dialog aria-labelledby="htaccess-help-title" class="vhost-dialog location-help-dialog" id="htaccess-help-dialog">
@@ -3461,41 +3452,42 @@ RewriteRule . /index.php [L]</code></pre>
<h2 data-i18n="webServicePhpDocsTitle" id="php-help-title">PHP-FPM / FastCGI configuration</h2>
<button class="outline" data-i18n="close" id="php-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServicePhpDocsIntro">RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix
socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.
</p>
<article class="card location-help-content php-reference">
<p data-i18n="phpDocsScope">The same fields and syntax apply to a local Web Service PHP configuration and a reusable PHP-FPM profile. A selected profile overrides local PHP fields for all attached services. RustProxy connects to an already running PHP-FPM pool.</p>
<h3 data-i18n="phpDocsSwitchesTitle">Enabling PHP and PATH_INFO</h3>
<p data-i18n="phpHintEnabled">Enables execution of configured PHP extensions via the PHP-FPM socket. The pool must already be running; RustProxy does not start PHP. Other files remain static.</p>
<p data-i18n="phpHintPathInfo">Allows /static.php/skins/file.css to run the existing static.php script with PATH_INFO=/skins/file.css. Needed by Roundcube static.php routes; usually not required for WordPress. Off by default.</p>
<p data-i18n="phpHintPassAuthorization">Passes the incoming Authorization header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic authentication; disable if the application does not need it.</p>
<h3 data-i18n="webServicePhpDocsEndpointTitle">Endpoint syntax</h3>
<pre><code>unix:/run/php/php-fpm.sock
<pre><code>unix:/run/php/php_wordpress.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000</code></pre>
<p class="fine" data-i18n="webServicePhpDocsEndpoint">For TCP, include the tcp: prefix, a literal loopback
IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have
permission to connect to the socket.</p>
<p data-i18n="webServicePhpDocsEndpoint">Use unix: for the full local socket path or tcp: plus a literal loopback IP and port. Hostnames and non-loopback TCP are rejected. The RustProxy user must be allowed to connect to the socket. Separate pools when PHP INI security settings differ.</p>
<h3 data-i18n="phpDocsLimitsTitle">Execution and size limits</h3>
<p data-i18n="phpDocsLimitsText">PHP extensions: comma-separated names such as php, phtml (no leading dots). Connect timeout: 1000 ms = 1 second. Full FastCGI request timeout: 30000 ms = 30 seconds. Concurrent requests: 64 (returns 503 when exhausted). Request body: 67108864 bytes = 64 MiB; 0 means unlimited by RustProxy. Response headers: 65536 bytes. STDERR: 4096 bytes; 0 disables capture. All numeric fields require integers; PHP INI limits are separate.</p>
<h3 data-i18n="webServicePhpDocsCustomTitle">Custom FastCGI parameters</h3>
<pre><code>APP_ENV = production
APP_DEBUG = 0
TENANT = main</code></pre>
<p data-i18n="webServicePhpDocsCustom">Use one NAME = value entry per line. Names are normalized to
uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy
rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request
routing, script selection or HTTP-derived parameters.</p>
<h3 data-i18n="webServicePhpDocsValuesTitle">PHP INI overrides</h3>
<p data-i18n="webServicePhpDocsCustom">Put these in Custom FastCGI parameters only. One NAME = value per line; names start with A-Z and then contain A-Z, 0-9 or underscore (max 64 characters). Values are static, up to 8192 bytes each; no placeholders are expanded. Do not use SCRIPT_FILENAME, HTTP_* or PHP_VALUE/PHP_ADMIN_VALUE here.</p>
<h3 data-i18n="webServicePhpDocsValuesTitle">PHP_VALUE — regular php.ini overrides</h3>
<pre><code>memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 30</code></pre>
<p data-i18n="webServicePhpDocsValues">These lines are combined into the FastCGI PHP_VALUE parameter.
PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are
rejected and PHP_ADMIN_VALUE is intentionally unavailable.</p>
<h3 data-i18n="webServicePhpDocsLimitsTitle">Limits and headers</h3>
<p data-i18n="webServicePhpDocsLimits">Request timeout covers the FastCGI exchange. The body limit is
enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit.
Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled
for applications that do not need HTTP_AUTHORIZATION.</p>
<p class="fine" data-i18n="webServicePhpDocsSecurity">Do not put secrets in custom parameters or PHP_VALUE
unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM
pool identity and keep PHP-FPM off public networks.</p>
max_execution_time = 30
error_reporting = E_ALL
log_errors = On
display_errors = Off
date.timezone = Europe/Warsaw</code></pre>
<p data-i18n="phpDocsValuesText">Enter these in PHP INI overrides (PHP_VALUE), not in FastCGI parameters. Use one directive = value per line (php.ini notation, no [section] headers or PHP tags). Exact support depends on which directives PHP-FPM permits to change. Common sizes use M/G, booleans On/Off and times seconds. Do not duplicate a directive in PHP_ADMIN_VALUE.</p>
<h3 data-i18n="phpDocsAdminTitle">PHP_ADMIN_VALUE — administrator-only overrides</h3>
<pre><code>open_basedir = /var/www/webmail/:/tmp/:/usr/lib/php/
display_errors = Off
log_errors = On</code></pre>
<p data-i18n="phpDocsAdminText">Put these in PHP administrator INI overrides (PHP_ADMIN_VALUE). open_basedir lists allowed directories separated by ':' on Unix. Set it to paths of THAT application; do not reuse Roundcube restrictions in a WordPress pool. For security-sensitive values, prefer php_admin_value[...] in the configuration of a dedicated PHP-FPM pool. Shared FPM workers can retain request-supplied INI settings.</p>
<h3 data-i18n="phpDocsOtherTitle">Storage, validation and troubleshooting</h3>
<p data-i18n="phpDocsOtherText">Each INI name may contain letters, digits, '.', '_' and '-' (up to 64 bytes); a value must be nonempty (up to 8192 bytes). Max 64 custom FastCGI entries and 64 INI overrides per map, with a total 128 KiB budget. No control characters or multi-line values. Values are saved in normal configuration: never place secrets there unless that storage is acceptable. A syntax-valid directive can still be rejected/ignored by PHP itself.</p>
<p class="fine" data-i18n="phpDocsPoolWarning">Critical: per-service PHP_VALUE/PHP_ADMIN_VALUE do NOT isolate applications when they share one PHP-FPM worker pool. Use different Unix sockets and restart the pools after moving WordPress and Roundcube apart. PATH_INFO does not fix open_basedir isolation.</p>
</article>
</dialog>
<dialog aria-labelledby="vhost-editor-title" class="vhost-dialog" id="vhost-dialog">
+24 -2
View File
@@ -30,8 +30,30 @@
return { read() { return Object.fromEntries([...rows].map(([code, editor]) => [code, editor.read()]).filter(([, page]) => page)); } };
}
function vhostEditor(parent, values = {}) {
const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback), errors = errorsEditor(parent, values.errors);
parent.append(label("p", "pagesSafetyHint", "fine")); return { read: () => ({ response: response.read(), fallback: fallback.read(), errors: errors.read() }) };
const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback);
const interception = el("section", undefined, "php-origin-errors");
const toggle = el("label", undefined, "check"), enabled = document.createElement("input");
enabled.type = "checkbox"; enabled.checked = (values.intercept_statuses || []).length > 0;
toggle.append(enabled, label("span", "vhostInterceptOriginErrors"));
const codesLabel = el("label"), codesCaption = label("span", "vhostInterceptOriginStatuses"), statuses = document.createElement("input");
statuses.type = "text"; statuses.maxLength = 64;
statuses.value = (values.intercept_statuses?.length ? values.intercept_statuses : [404]).join(", ");
statuses.placeholder = "404, 500, 502, 503, 504";
codesLabel.append(codesCaption, statuses);
const hint = label("p", "vhostInterceptOriginErrorsHelp", "fine");
const sync = () => { statuses.disabled = !enabled.checked; };
enabled.addEventListener("change", sync); sync();
interception.append(toggle, codesLabel, hint); parent.append(interception);
const errors = errorsEditor(parent, values.errors);
parent.append(label("p", "pagesSafetyHint", "fine"));
return { read: () => {
const selectedTokens = enabled.checked ? statuses.value.split(/[\s,]+/).filter(Boolean) : [];
const selected = selectedTokens.map(Number);
if (enabled.checked && (!selected.length || selectedTokens.some(token => !/^\d{3}$/.test(token)) || selected.length > codes.length || new Set(selected).size !== selected.length || selected.some(code => !codes.includes(code)))) {
throw new Error(t("vhostInterceptOriginInvalid"));
}
return { response: response.read(), fallback: fallback.read(), errors: errors.read(), intercept_statuses: selected };
} };
}
let global = null;
function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; }
+48
View File
@@ -9871,3 +9871,51 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
grid-template-columns: 1fr
}
}
/* 0.29.7: PHP-FPM local/profile toggle rows with accessible info popovers. */
.php-switch-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 30px;
margin: 4px 0;
}
.php-switch-row .php-switch-label {
display: inline-flex !important;
flex: 0 1 auto;
align-items: center;
gap: 9px;
min-width: 0;
margin: 0 !important;
line-height: 1.5;
}
.php-switch-row .php-switch-label input[type="checkbox"] {
position: static;
flex: 0 0 var(--ui-check-size);
margin: 0;
}
.php-switch-row .php-switch-label span { min-width: 0; }
.php-switch-row .php-field-info { flex: 0 0 auto; }
.php-switch-row .php-field-info .metric-info-popover { right: auto; left: 0; }
.php-reference pre {
overflow-x: auto;
white-space: pre;
padding: 10px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--panel2);
font: 12px/1.6 var(--mono);
}
.php-profile-help-divider {
width: 100%;
height: 0;
margin: 16px 0 12px;
border: 0;
border-top: 1px solid var(--line);
}
.php-origin-errors { grid-column: 1 / -1; display: grid; gap: 8px; padding: 10px 0; border-top: 1px solid var(--line); }
.php-origin-errors > label { max-width: 430px; }
.php-origin-errors > .check { margin: 0 !important; }
.php-origin-errors .fine { margin: 0; }
+21 -2
View File
@@ -52,7 +52,7 @@
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
@@ -204,7 +204,7 @@
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
const controls = {};
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) controls[name] = field(routing, name, key, checked, "checkbox");
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
@@ -222,6 +222,25 @@
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
const customHealth = field(extra, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
const health = value.health_check || {};
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); extra.append(healthFields);
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true });
field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true });
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
const syncHealth = () => {
healthFields.hidden = !customHealth.checked;
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !customHealth.checked; });
};
customHealth.addEventListener("change", syncHealth); syncHealth();
note(extra, "backendHealthOverridesHelp");
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
+16 -2
View File
@@ -350,7 +350,7 @@
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
@@ -387,7 +387,21 @@
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
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 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),
health_check_enabled: yes("member-health-enabled", row),
health_check: yes("member-health-custom", row) ? {
mode: str("member-health-mode", row), path: str("member-health-path", row), host: nullable("member-health-host", row),
interval_ms: num("member-health-interval", row), timeout_ms: num("member-health-timeout", row), slow_response_ms: num("member-health-slow", row),
fall: num("member-health-fall", row), rise: num("member-health-rise", row),
expected_status_min: num("member-health-min", row), expected_status_max: num("member-health-max", row)
} : null,
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));
+1 -1
View File
@@ -258,7 +258,7 @@
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
for (const member of members) {
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
+34 -6
View File
@@ -31,6 +31,16 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
["php_admin_values", "phpProfileAdminValues", "textarea"],
];
const helpKeys = {
enabled: "phpHintEnabled", path_info: "phpHintPathInfo", endpoint: "webServicePhpDocsEndpoint",
extensions: "phpHintExtensions", pass_authorization: "phpHintPassAuthorization",
connect_timeout_ms: "phpHintConnectTimeout", request_timeout_ms: "phpHintRequestTimeout",
max_concurrent_requests: "phpHintConcurrency", max_request_body_bytes: "phpHintBodyLimit",
max_response_header_bytes: "phpHintHeaderLimit", stderr_log_bytes: "phpHintStderrLimit",
custom_params: "webServicePhpCustomParamsHelp", php_values: "phpDocsValuesText",
php_admin_values: "phpHintAdminValues"
};
function profileEditor(profile, creating = false) {
const details = document.createElement("details"); details.className = "card";
if (creating) details.open = true;
@@ -46,17 +56,35 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
const nameInput = namedInput("phpProfileName", profile.name);
const controls = {};
for (const [key, label, kind, min, max] of profileFields) {
const labelEl = el("label"); if (kind === "textarea") labelEl.className = "vh-wide";
const caption = el("span", t(label)); labelEl.append(caption);
const toggle = kind === "checkbox";
const wrapper = toggle ? el("div", undefined, "php-switch-row vh-wide") : el("label");
if (!toggle && kind === "textarea") wrapper.className = "vh-wide";
const labelEl = toggle ? el("label", undefined, "check php-switch-label") : wrapper;
const input = document.createElement(kind === "textarea" ? "textarea" : "input");
if (kind !== "textarea") input.type = kind;
if (kind === "checkbox") input.checked = profile.php?.[key] === true;
else if (kind === "textarea") { input.rows = 3; input.maxLength = 65536; input.value = mapLines(profile.php?.[key]); }
const caption = el("span", t(label));
if (toggle) labelEl.append(input, caption); else labelEl.append(caption, input);
if (toggle) input.checked = profile.php?.[key] === true;
else if (kind === "textarea") { input.rows = 4; input.maxLength = 65536; input.value = mapLines(profile.php?.[key]); }
else if (key === "extensions") input.value = (profile.php?.extensions || ["php"]).join(", ");
else input.value = profile.php?.[key] ?? "";
if (kind === "number") { input.min = min; input.max = max; input.step = 1; input.required = true; }
if (key === "endpoint") { input.required = true; input.maxLength = 512; }
labelEl.append(input); editor.append(labelEl); controls[key] = input;
if (key === "endpoint") { input.required = true; input.maxLength = 512; input.placeholder = "unix:/run/php/php-fpm.sock"; }
if (key === "extensions") input.placeholder = "php, phtml";
if (key === "custom_params") input.placeholder = "APP_ENV = production\nAPP_DEBUG = 0";
if (key === "php_values") input.placeholder = "memory_limit = 256M\npost_max_size = 64M";
if (key === "php_admin_values") input.placeholder = "open_basedir = /var/www/webmail/:/tmp/";
if (toggle) {
const tip = el("details", undefined, "metric-info php-field-info"), summaryTip = el("summary", "i");
summaryTip.setAttribute("aria-label", t(helpKeys[key]));
const popover = el("div", undefined, "metric-info-popover");
popover.append(el("p", t(helpKeys[key]), "fine")); tip.append(summaryTip, popover);
wrapper.append(labelEl, tip);
} else {
const hint = el("small", t(helpKeys[key]), "fine");
labelEl.append(hint);
}
editor.append(wrapper); controls[key] = input;
}
const actions = el("div", undefined, "form-actions vh-wide");
const save = el("button", t("apply"), "primary"); save.type = "submit";
+1
View File
@@ -476,6 +476,7 @@
$("web-service-php-profiles-open").addEventListener("click", openProfiles);
$("php-fpm-profiles-close").addEventListener("click", () => $("php-fpm-profiles-dialog").close());
$("php-fpm-profile-add").addEventListener("click", () => renderProfiles(true));
$("php-fpm-profiles-help").addEventListener("click", () => $("php-help-dialog").showModal());
$("web-service-compression").addEventListener("change", syncCompression);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
+5 -3
View File
@@ -345,8 +345,10 @@ pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationT
_ = interval.tick() => {
let Some(app) = app.upgrade() else { break; };
let snapshot = app.config.load_full();
// Own an Arc for each candidate. JoinSet tasks require an
// owned, 'static member rather than a reference into snapshot.
let members: Vec<_> = snapshot.pools.iter().flat_map(|pool|
pool.members.iter().map(move |member| (member, &pool.settings.health_check))).collect();
pool.members.iter().map(move |member| (Arc::clone(member), pool.settings.health_for(&member.definition)))).collect();
for (member, _) in &members {
member.state.sample_traffic();
// Time-based pool recycling must not depend on receiving a
@@ -361,9 +363,9 @@ pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationT
for offset in 0..members.len() {
if checks.len() >= 16 { break; }
let index = (start + offset) % members.len();
let (member, health) = members[index];
let (member, health) = &members[index];
if member.definition.enabled && member.state.claim_probe(health) {
checks.spawn(probe(member.clone(), health.clone()));
checks.spawn(probe(Arc::clone(member), health.clone()));
cursor = (index + 1) % members.len();
}
}
+19
View File
@@ -216,6 +216,25 @@ impl Default for Settings {
}
}
impl Settings {
/// Resolve active health per member; a disabled monitor never probes.
pub fn health_for(&self, member: &PoolMember) -> HealthCheck {
let mut health = member.health_check.clone().unwrap_or_else(|| self.health_check.clone());
if health.host.is_none() && health.mode == HealthMode::Http {
health.host = self.health_check.host.clone();
}
if !member.health_check_enabled {
health.mode = HealthMode::Off;
}
health
}
/// Monitoring off also excludes passive ejection; failures remain logged.
pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth {
let mut passive = self.passive_health.clone();
if !member.health_check_enabled {
passive.enabled = false;
}
passive
}
pub fn validate(&self) -> Result<()> {
let h = &self.health_check;
let k = &self.keepalive;
+12 -12
View File
@@ -21,7 +21,7 @@ impl Pool {
.zip(backends)
.map(|(definition, backend)| {
Arc::new(Member {
state: Arc::new(BackendState::new(settings.health_check.mode)),
state: Arc::new(BackendState::new(settings.health_for(&definition).mode)),
definition,
backend,
token: rand::thread_rng()
@@ -54,13 +54,13 @@ impl Pool {
let same_origin = serde_json::to_vec(&member.definition.backend()).ok()
== serde_json::to_vec(&old.definition.backend()).ok();
let same_health =
serde_json::to_vec(&(&self.settings.health_check, &self.settings.passive_health))
.ok()
== serde_json::to_vec(&(
&previous.settings.health_check,
&previous.settings.passive_health,
))
.ok();
serde_json::to_vec(&(
self.settings.health_for(&member.definition),
self.settings.passive_for(&member.definition),
)).ok() == serde_json::to_vec(&(
previous.settings.health_for(&old.definition),
previous.settings.passive_for(&old.definition),
)).ok();
let same_clients = member.backend.client.reusable_with(&old.backend.client)
&& member
.backend
@@ -195,8 +195,8 @@ impl Pool {
Some(Selection {
lease: Arc::new(Lease {
member: member.clone(),
health: self.settings.health_check.clone(),
passive_health: self.settings.passive_health.clone(),
health: self.settings.health_for(&member.definition),
passive_health: self.settings.passive_for(&member.definition),
error_recorded: AtomicBool::new(false),
}),
cookie: None,
@@ -336,8 +336,8 @@ impl Pool {
Some(Selection {
lease: Arc::new(Lease {
member: selected.clone(),
health: self.settings.health_check.clone(),
passive_health: self.settings.passive_health.clone(),
health: self.settings.health_for(&selected.definition),
passive_health: self.settings.passive_for(&selected.definition),
error_recorded: AtomicBool::new(false),
}),
cookie,
+4 -1
View File
@@ -6,13 +6,14 @@ impl Pool {
.map(|m| {
let h = lock(&m.state.health);
let failure = lock(&m.state.last_failure);
let member_health = self.settings.health_for(&m.definition);
let status = if !m.definition.enabled {
"disabled"
} else if m.definition.drain {
"draining"
} else if h.healthy {
"up"
} else if !h.checked && self.settings.health_check.mode != HealthMode::Off {
} else if !h.checked && member_health.mode != HealthMode::Off {
"checking"
} else {
"down"
@@ -27,6 +28,8 @@ impl Pool {
item.insert("url".into(), json!(m.definition.url));
item.insert("weight".into(), json!(m.definition.weight));
item.insert("backup".into(), json!(m.definition.backup));
item.insert("health_check_enabled".into(), json!(m.definition.health_check_enabled));
item.insert("health_mode".into(), json!(member_health.mode));
item.insert(
"max_concurrent_requests".into(),
json!(m.definition.max_concurrent_requests),
+23
View File
@@ -52,6 +52,29 @@ fn select(pool: &Pool) -> Selection {
.unwrap()
}
#[test]
fn per_backend_monitoring_can_be_disabled_or_overridden() {
let mut defaults: crate::vhost::PoolMember = serde_json::from_value(serde_json::json!({
"id": "error_404", "url": "http://127.0.0.1:8282", "pool_eligible": false
})).unwrap();
let mut settings = Settings::default();
settings.passive_health.enabled = true;
assert!(defaults.health_check_enabled);
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Tcp);
assert!(settings.passive_for(&defaults).enabled);
defaults.health_check_enabled = false;
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
assert!(!settings.passive_for(&defaults).enabled);
assert!(!defaults.pool_eligible); // routing intent is independent
defaults.health_check_enabled = true;
defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, path: "/healthz".into(), ..Default::default() });
let effective = settings.health_for(&defaults);
assert_eq!(effective.mode, HealthMode::Http);
assert_eq!(effective.path, "/healthz");
assert_eq!(effective.host, settings.health_check.host);
let restored: crate::vhost::PoolMember = serde_json::from_str(&serde_json::to_string(&defaults).unwrap()).unwrap();
assert_eq!(settings.health_for(&restored).path, "/healthz");
}
#[test]
fn default_health_check_uses_tcp() {
assert_eq!(HealthMode::default(), HealthMode::Tcp);
assert_eq!(HealthCheck::default().mode, HealthMode::Tcp);
+12 -2
View File
@@ -188,6 +188,9 @@ impl Settings {
value.parse::<HeaderValue>().expect("validated cache Via header"),
);
}
if self.expose_only_hits && outcome != "HIT" {
return;
}
if self.expose_status {
headers.insert("x-cache", HeaderValue::from_static(outcome));
}
@@ -222,7 +225,10 @@ impl Settings {
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled || !self.expose_cache_status || !transformed {
if !self.enabled || !self.expose_cache_status || !transformed
|| (self.expose_only_hits && !headers.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit"))) {
return;
}
let base = headers
@@ -249,7 +255,7 @@ impl Settings {
reason: &'static str,
) {
self.annotate(headers, outcome, hits, ttl);
if !self.enabled {
if !self.enabled || (self.expose_only_hits && outcome != "HIT") {
return;
}
if self.expose_status {
@@ -333,6 +339,7 @@ pub struct Overrides {
pub expose_hits: Option<bool>,
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
pub expose_only_hits: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce,
}
@@ -380,6 +387,9 @@ impl Overrides {
if let Some(value) = self.expose_resident_age {
result.expose_resident_age = value;
}
if let Some(value) = self.expose_only_hits {
result.expose_only_hits = value;
}
result.validate()?;
Ok(result)
}
+3
View File
@@ -173,6 +173,8 @@ pub struct Settings {
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
pub expose_only_hits: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>,
@@ -243,6 +245,7 @@ impl Default for Settings {
expose_hits: false,
expose_cache_status: false,
expose_resident_age: false,
expose_only_hits: false,
via_header: Some("RustProxy".into()),
}
}
+33
View File
@@ -127,6 +127,39 @@ mod dashboard_tests {
);
}
#[test]
fn hit_only_cache_diagnostics_hides_miss_bypass_and_preserves_hits() {
let global = Settings {
expose_status: true,
expose_hits: true,
expose_cache_status: true,
expose_resident_age: true,
expose_only_hits: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream"));
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true);
for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] {
assert!(!headers.contains_key(name), "{name} leaked on MISS");
}
assert_eq!(headers["via"], "RustProxy"); // independent proxy policy
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status"));
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
global.annotate_resident_age(&mut headers, 10);
assert_eq!(headers["x-cache"], "HIT");
assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() };
let normal = override_on.resolve(&global).unwrap();
assert!(!normal.expose_only_hits);
override_on.expose_only_hits = Some(true);
assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits);
}
#[test]
fn via_header_is_configurable_and_preserves_upstream_chain() {
let settings = Settings::default();
let mut headers = HeaderMap::new();
+19
View File
@@ -57,6 +57,10 @@ pub fn compile_with_active(
}
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
// workers. Different directive sets on one socket can leak to the next vhost.
// Report unsafe legacy configurations but keep them loadable for migration.
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
for service in &mut config.web_services {
service.validate().with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
@@ -68,6 +72,21 @@ pub fn compile_with_active(
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
if effective.php.enabled {
let php = &effective.php;
if let Some((owner, values, admin_values)) = php_endpoint_policies.get(&php.endpoint) {
if values != &php.php_values || admin_values != &php.php_admin_values {
tracing::warn!(
service = %effective.id, other_service = %owner, endpoint = %php.endpoint,
"PHP-FPM endpoint is shared by Web Services with different PHP_VALUE/PHP_ADMIN_VALUE settings; FPM worker settings can leak across requests. Use distinct PHP-FPM pools/sockets (especially for open_basedir)."
);
}
} else {
php_endpoint_policies.insert(php.endpoint.clone(), (
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
));
}
}
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
}
+4 -1
View File
@@ -122,7 +122,10 @@ fn compile_routing(
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none()
&& pool_settings.health_check.mode == crate::balance::HealthMode::Http
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|| host.backends.iter().any(|member| member.health_check.as_ref()
.is_some_and(|health| health.mode == crate::balance::HealthMode::Http
&& health.host.is_none())))
{
pool_settings.health_check.host = host
.frontend
+25
View File
@@ -6,6 +6,19 @@ fn path_boundaries() {
assert!(prefix_matches("/", "/anything"));
}
#[test]
fn invalid_per_backend_health_policy_rejected_without_global_override() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http,
interval_ms: 300,
timeout_ms: 400,
..Default::default()
});
assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200;
assert!(compile(c, 1, &policy()).is_ok());
}
#[test]
fn private_requires_opt_in_and_metadata_always_denied() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
assert!(compile(c.clone(), 1, &policy()).is_ok());
@@ -279,6 +292,18 @@ fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
);
}
#[test]
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp);
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default()
});
let snapshot = compile(c, 1, &policy()).unwrap();
let pool = &snapshot.pools[0];
assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test"));
}
#[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
+49
View File
@@ -57,6 +57,11 @@ pub struct GlobalPages {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VhostPages {
/// Optional status codes whose response body from an application origin is
/// replaced by the vhost/global error page (or RustProxy built-in page).
/// An empty set preserves normal application error bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub intercept_statuses: Vec<u16>,
/// A static/maintenance vhost response; no upstream request is made.
pub response: Option<Page>,
/// Only when the hostname is bound here but no path route matches.
@@ -65,6 +70,11 @@ pub struct VhostPages {
}
#[derive(Clone)]
pub struct PageServed;
/// A completed application-origin response, from a reverse-proxy backend or
/// the built-in Web Server (including PHP-FPM and cache hits). A global error
/// page must not overwrite it when origin-error interception is disabled.
#[derive(Clone, Copy)]
pub struct OriginProvidedResponse;
/// Marker for the built-in no-application placeholder. The outer proxy layer
/// keeps this response deliberately minimal: no cache storage and no optional
/// server/diagnostic/security/compression decoration.
@@ -215,6 +225,15 @@ impl GlobalPages {
impl VhostPages {
pub fn validate(&self) -> Result<()> {
validate_errors(&self.errors)?;
ensure!(
self.intercept_statuses.len() <= ERROR_CODES.len()
&& self.intercept_statuses.iter().all(|status| ERROR_CODES.contains(status))
&& {
let distinct: std::collections::HashSet<_> = self.intercept_statuses.iter().collect();
distinct.len() == self.intercept_statuses.len()
},
"intercepted application status codes must be unique supported HTTP error codes"
);
for page in [&self.response, &self.fallback].into_iter().flatten() {
page.validate()?;
}
@@ -240,6 +259,36 @@ pub fn replace_error(response: &mut Response, page: &Page) {
}
*response = replacement;
}
/// Replaces only opted-in application-origin errors, before the original
/// representation can be cached or sent. Local routing/security errors and
/// responses from internal error-fallback locations are not passed here.
/// The origin status is always preserved, independently of the selected
/// page's status override (which applies to locally generated pages only).
pub fn intercept_application_error(
mut response: Response,
vhost_pages: &VhostPages,
global_pages: &GlobalPages,
) -> Response {
let code = response.status().as_u16();
if !vhost_pages.intercept_statuses.contains(&code)
|| response.extensions().get::<PageServed>().is_some()
{
return response;
}
let default_page = Page {
kind: Kind::Builtin,
status: None,
html: String::new(),
};
let selected = vhost_pages.errors.get(&code)
.or_else(|| global_pages.errors.get(&code))
.unwrap_or(&default_page);
let mut body_only = selected.clone();
body_only.status = None;
replace_error(&mut response, &body_only);
response
}
pub fn literal_ip_authority(value: &str) -> bool {
!value.contains('@')
&& value
+100
View File
@@ -161,4 +161,104 @@ mod tests {
assert_eq!(page.serve(&Method::POST, StatusCode::OK).status(), 405);
assert_eq!(page.serve(&Method::GET, StatusCode::OK).status(), 200);
}
#[tokio::test]
async fn origin_error_interception_is_opt_in_and_keeps_http_status() {
let mk_origin = || {
let mut response = Response::new(Body::from("<html>secret application error</html>"));
*response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert("content-type", HeaderValue::from_static("text/html"));
response
};
let defaults: VhostPages = serde_json::from_str("{}").unwrap();
assert!(defaults.intercept_statuses.is_empty());
let unchanged = intercept_application_error(mk_origin(), &defaults, &GlobalPages::default());
assert_eq!(unchanged.status(), StatusCode::NOT_FOUND);
assert_eq!(unchanged.into_body().collect().await.unwrap().to_bytes(),
"<html>secret application error</html>");
let configured = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() };
configured.validate().unwrap();
let replaced = intercept_application_error(mk_origin(), &configured, &GlobalPages::default());
assert_eq!(replaced.status(), StatusCode::NOT_FOUND);
assert_eq!(replaced.headers().get("cache-control").unwrap(), "no-store");
assert!(replaced.extensions().get::<PageServed>().is_some());
let bytes = replaced.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&bytes).contains("404"));
assert!(!String::from_utf8_lossy(&bytes).contains("secret application error"));
}
#[tokio::test]
async fn interception_replaces_only_the_body_even_when_page_has_a_status_override() {
let mut vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() };
vhost.errors.insert(404, Page {
kind: Kind::Html,
// Overrides are valid for standalone error pages, not an origin
// response whose HTTP status must remain 404.
status: Some(503),
html: "custom 404".into(),
});
let mut origin = Response::new(Body::from("application 404"));
*origin.status_mut() = StatusCode::NOT_FOUND;
let intercepted = intercept_application_error(origin, &vhost, &GlobalPages::default());
assert_eq!(intercepted.status(), StatusCode::NOT_FOUND);
assert_eq!(intercepted.headers().get("cache-control").unwrap(), "no-store");
assert_eq!(intercepted.into_body().collect().await.unwrap().to_bytes(), "custom 404");
// Outside interception the configured status override still applies.
assert_eq!(vhost.errors.get(&404).unwrap().render(StatusCode::NOT_FOUND).status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn interception_uses_vhost_then_global_page_and_preserves_challenge() {
let mut global = GlobalPages::default();
global.errors.insert(401, Page { kind: Kind::Html,
html: "global denied".into(), status: None });
let mut vhost = VhostPages { intercept_statuses: vec![401], ..VhostPages::default() };
let mut response = Response::new(Body::from("application denied"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
response.headers_mut().insert("www-authenticate", HeaderValue::from_static("Basic realm=\"app\""));
response.headers_mut().insert("set-cookie", HeaderValue::from_static("secret=1"));
response.headers_mut().insert("content-encoding", HeaderValue::from_static("gzip"));
let global_response = intercept_application_error(response, &vhost, &global);
assert_eq!(global_response.headers().get("www-authenticate").unwrap(), "Basic realm=\"app\"");
assert!(!global_response.headers().contains_key("set-cookie"));
assert!(!global_response.headers().contains_key("content-encoding"));
assert_eq!(global_response.into_body().collect().await.unwrap().to_bytes(), "global denied");
vhost.errors.insert(401, Page { kind: Kind::Html,
html: "local denied".into(), status: None });
let mut response = Response::new(Body::from("application denied"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
let local = intercept_application_error(response, &vhost, &global);
assert_eq!(local.into_body().collect().await.unwrap().to_bytes(), "local denied");
}
#[tokio::test]
async fn interception_skips_nonmatching_and_already_served_responses() {
let vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() };
let mut response = Response::new(Body::from("ordinary ok"));
*response.status_mut() = StatusCode::OK;
let untouched = intercept_application_error(response, &vhost, &GlobalPages::default());
assert_eq!(untouched.into_body().collect().await.unwrap().to_bytes(), "ordinary ok");
let served = Page { kind: Kind::Html, status: Some(404), html: "internal fallback".into() }
.render(StatusCode::NOT_FOUND);
let preserved = intercept_application_error(served, &vhost, &GlobalPages::default());
assert_eq!(preserved.into_body().collect().await.unwrap().to_bytes(), "internal fallback");
}
#[test]
fn intercepted_error_code_configuration_is_validated_and_backwards_compatible() {
let mut pages: VhostPages = serde_json::from_str("{\"response\": null}").unwrap();
assert!(pages.intercept_statuses.is_empty());
pages.intercept_statuses = vec![404, 503];
pages.validate().unwrap();
let json = serde_json::to_string(&pages).unwrap();
assert!(json.contains("\"intercept_statuses\":[404,503]"));
pages.intercept_statuses = vec![404, 404];
assert!(pages.validate().is_err());
pages.intercept_statuses = vec![302];
assert!(pages.validate().is_err());
pages.intercept_statuses.clear();
pages.validate().unwrap();
assert!(!serde_json::to_string(&pages).unwrap().contains("intercept_statuses"));
}
}
+26
View File
@@ -181,3 +181,29 @@ async fn execute_error_fallback(
}
Some(response)
}
/// Called only for a response supplied by the Web Server or an HTTP origin.
/// Routing/security failures and internal fallback responses are excluded.
fn intercept_route_origin_error(
response: Response,
route: &crate::config::CompiledRoute,
snapshot: &crate::config::Snapshot,
) -> Response {
// An internal named location is the final, administrator-selected fallback
// representation. Interception inside its nested forward_request would
// otherwise erase its 404 HTML before ErrorFallbackResponse is attached.
if route.internal {
return response;
}
let mut response = if let Some(frontend) = route.frontend.as_ref() {
crate::pages::intercept_application_error(
response,
&frontend.pages,
&snapshot.envelope.config.pages,
)
} else {
response
};
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
response
}
+3 -3
View File
@@ -121,7 +121,7 @@ if let Some(ticket) = &cache_ticket {
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
return hit.response;
return intercept_route_origin_error(hit.response, route, &snapshot);
}
state.cache.record(scope, &host, "MISS", "cache_miss");
miss_recorded = true;
@@ -188,7 +188,7 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
Some(hit.response)
Some(intercept_route_origin_error(hit.response, route, &snapshot))
};
if only_cached {
let mut response = error(
@@ -281,7 +281,7 @@ if handshake.is_none()
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
return hit.response;
return intercept_route_origin_error(hit.response, route, &snapshot);
}
collapse_rounds = collapse_rounds.saturating_add(1);
if collapse_rounds >= 8 {
+4
View File
@@ -7,6 +7,10 @@
).await {
return fallback;
}
// Keep the upstream body as hyper::body::Incoming until the
// HTTP/2 WebSocket and regular response paths have adapted it.
// The opt-in application error page is applied to Response<Body>
// in success_prepare.rs, before transformation/cache admission.
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
include!("success_prepare.rs");
include!("success_finish.rs")
+1 -1
View File
@@ -87,7 +87,7 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
.unwrap_or_else(|e| e.into_inner()) = "cache_revalidated";
}
state.cache.record(scope, &host, "HIT", "cache_revalidated");
return revalidated;
return intercept_route_origin_error(revalidated, route, &snapshot);
}
}
}
+27
View File
@@ -333,6 +333,33 @@ let mut response = if h3_tunnel {
}
Response::from_parts(parts, Body::new(body))
};
// The upstream HTTP client returns Response<hyper::body::Incoming>. Adapt it
// before rendering a RustProxy error page, so HTTP/2 WebSocket tunnels can
// continue using the original Incoming body without type erasure.
// An internal named-location fallback has already won earlier in response.rs.
let intercept_origin_error = !route.internal && route.frontend.as_ref().is_some_and(|frontend| {
frontend.pages.intercept_statuses.contains(&response.status().as_u16())
});
// Mark every upstream response as application-supplied, even if this vhost
// does not intercept its status. The observer must not overwrite it merely
// because a global 404/5xx error page exists.
response = intercept_route_origin_error(response, route, &snapshot);
if intercept_origin_error {
// The earlier response rules modified the original origin headers;
// reapply them to the newly rendered page so the vhost's header policy
// remains effective. Do not allow a custom header rule to cache errors.
apply_response_rules(
response.headers_mut(),
&snapshot,
route.frontend.as_deref(),
Some(route),
admin_control_plane,
);
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("no-store"),
);
}
response.extensions_mut().insert(ResponseRulesApplied);
response
.extensions_mut()
+7 -2
View File
@@ -102,7 +102,7 @@
}
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
return response;
return intercept_route_origin_error(response, route, &snapshot);
}
}
}
@@ -115,7 +115,7 @@
// await: the filesystem/PHP source owns the body for its full lifetime.
let static_body = std::mem::replace(request.body_mut(), Body::empty());
let started = Instant::now();
let mut response = crate::web_server::serve(service, request_method.clone(),
let response = crate::web_server::serve(service, request_method.clone(),
request_headers.clone(), request_uri, &service_path, static_body,
crate::web_server::RequestContext {
client_ip: context.client_ip,
@@ -130,6 +130,7 @@
response.status(), &fallback_request, &fallback_body,
).await { return fallback; }
let mut response = intercept_route_origin_error(response, route, &snapshot);
let origin_headers = response.headers().clone();
let original_plan = crate::cache::plan(response.status(), &origin_headers,
cache_settings, started.elapsed());
@@ -151,6 +152,10 @@
});
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
if response.extensions().get::<crate::pages::PageServed>().is_some() {
// A vhost header rule must not make a replaced error cacheable.
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
}
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
+55 -3
View File
@@ -296,7 +296,6 @@ mod authority_tests {
"12.00ms"
);
}
}
#[test]
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
@@ -402,11 +401,64 @@ async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
Bytes::from_static(b"<html><title>ReactPHP 404</title></html>")
);
// A normal response with no fallback is still eligible for the configured
// global/vhost error-page replacement.
// A proxy-generated response is eligible for configured global/vhost
// pages even when interception of *application* errors is disabled.
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
assert!(should_replace_error_page(&response, false));
}
#[tokio::test]
async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() {
use http_body_util::BodyExt;
for source in ["reverse_proxy", "web_server_php"] {
let mut response = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(format!("{source}: origin 404 body")))
.unwrap();
// Both paths use intercept_route_origin_error to set this marker
// once the origin response reaches Response<Body>.
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
assert!(!should_replace_error_page(&response, false), "{source}");
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes.as_ref(), format!("{source}: origin 404 body").as_bytes());
}
}
#[tokio::test]
async fn compiled_vhost_origin_interception_controls_application_404() {
for enabled in [false, true] {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.pages.errors.insert(404, crate::pages::Page {
kind: crate::pages::Kind::Html,
status: Some(503),
html: "RustProxy custom error page".into(),
});
if enabled {
config.vhosts[0].pages.intercept_statuses = vec![404];
}
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
let response = Response::builder().status(StatusCode::NOT_FOUND)
.body(Body::from("application custom 404"))
.unwrap();
let response = intercept_route_origin_error(response, route, &snapshot);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(response.extensions().get::<crate::pages::OriginProvidedResponse>().is_some());
assert!(!should_replace_error_page(&response, false));
assert_eq!(response.extensions().get::<crate::pages::PageServed>().is_some(), enabled);
if enabled {
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
}
let bytes = response.into_body().collect().await.unwrap().to_bytes();
if enabled {
assert_eq!(bytes, "RustProxy custom error page");
} else {
assert_eq!(bytes, "application custom 404");
}
}
}
}
+2
View File
@@ -14,6 +14,8 @@ fn request_id_hash(sequence: u64) -> String {
fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool {
!minimal_welcome
&& response.extensions().get::<ErrorFallbackResponse>().is_none()
&& response.extensions().get::<crate::pages::OriginProvidedResponse>().is_none()
&& response.extensions().get::<crate::pages::PageServed>().is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
+6
View File
@@ -60,6 +60,7 @@ mod cache_revision_tests {
wire_only.cache.expose_hits = !wire_only.cache.expose_hits;
wire_only.cache.expose_cache_status = !wire_only.cache.expose_cache_status;
wire_only.cache.expose_resident_age = !wire_only.cache.expose_resident_age;
wire_only.cache.expose_only_hits = !wire_only.cache.expose_only_hits;
wire_only.cache.via_header = Some("RustProxy edge".into());
assert_eq!(cache_revision(&wire_only), identity);
assert!(matches!(
@@ -67,6 +68,11 @@ mod cache_revision_tests {
CacheInvalidation::None
));
let mut vhost_diagnostics = base.clone();
vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true);
assert_eq!(cache_revision(&vhost_diagnostics), identity);
assert!(matches!(cache_invalidation(&base, &vhost_diagnostics), CacheInvalidation::None));
let mut cache = base.clone();
cache.cache.max_entries += 1;
let cache_identity = cache_revision(&cache);
+8 -1
View File
@@ -99,6 +99,7 @@ fn cache_revision(config: &config::Config) -> u64 {
cache.expose_hits = false;
cache.expose_cache_status = false;
cache.expose_resident_age = false;
cache.expose_only_hits = false;
cache.via_header = None;
// Resource-only transform controls decide how much optimization work may
// happen concurrently, but do not change the transformed representation.
@@ -108,12 +109,18 @@ fn cache_revision(config: &config::Config) -> u64 {
transform.preset = None;
transform.memory_budget_bytes = 0;
transform.max_inflight = 0;
// A per-vhost diagnostics override is also presentation-only and must not
// invalidate cached objects when switched from inherited to HIT-only.
let mut vhosts = config.vhosts.clone();
for vhost in &mut vhosts {
vhost.cache.expose_only_hits = None;
}
let encoded = serde_json::to_vec(&(
&cache,
&transform,
&config.headers,
&config.forwarding,
&config.vhosts,
&vhosts,
))
.expect("serializable cache compatibility view");
let digest = Sha256::digest(encoded);
+5 -2
View File
@@ -4,7 +4,7 @@ use crate::{
headers::HeaderRules,
security::SecurityHeaders,
};
use anyhow::{ensure, Result};
use anyhow::{ensure, Context, Result};
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, SocketAddr};
fn yes() -> bool {
@@ -156,7 +156,6 @@ pub struct PathRewrite {
pub mode: RewriteMode,
pub value: String,
}
fn response_status() -> u16 {
200
}
@@ -403,6 +402,10 @@ pub struct PoolMember {
pub backup: bool,
#[serde(default)]
pub drain: bool,
#[serde(default = "yes")]
pub health_check_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check: Option<crate::balance::HealthCheck>,
#[serde(default)]
pub allow_private: bool,
#[serde(default)]
+7
View File
@@ -217,6 +217,13 @@ impl Vhost {
}
}
self.load_balancing.validate()?;
for member in &self.backends {
if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone();
settings.health_check = custom.clone();
settings.validate().with_context(|| format!("backend {} health check", member.id))?;
}
}
self.proxy_timeouts.validate()?;
self.rate_limit.validate()?;
if let Some(policy) = &self.tls_policy {