v0.29.4
This commit is contained in:
1 parent
f87eec7287
commit
dd8c195600
38 files changed
+5491
-4682
No files matched your search
@@ -89,6 +89,10 @@ async fn update_section(
|
||||
config.web_services =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"php-fpm-profiles" => {
|
||||
config.php_fpm_profiles =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"redirects" => {
|
||||
config.redirects =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
|
||||
+39
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.29.3",
|
||||
"version": "0.29.4",
|
||||
"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": [
|
||||
@@ -848,6 +848,14 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WebService"
|
||||
}
|
||||
},
|
||||
"php_fpm_profiles": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PhpFpmProfile"
|
||||
},
|
||||
"description": "Named PHP-FPM configurations reusable by Web Services."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -5185,6 +5193,12 @@
|
||||
},
|
||||
"compression": {
|
||||
"$ref": "#/components/schemas/CompressionOverrides"
|
||||
},
|
||||
"php_profile_id": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Optional shared PHP-FPM profile ID; when absent the local php configuration applies.",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5649,6 +5663,30 @@
|
||||
"target"
|
||||
],
|
||||
"description": "Nginx-style internal error fallback. The original request is internally dispatched without a client redirect."
|
||||
},
|
||||
"PhpFpmProfile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "ASCII letters, numbers, - and _."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96
|
||||
},
|
||||
"php": {
|
||||
"$ref": "#/components/schemas/WebService/properties/php"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -230,6 +230,10 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/assets/web-server-profiles.js", get(|| async {
|
||||
([ ("content-type", "text/javascript; charset=utf-8") ],
|
||||
include_str!("web/web-server-profiles.js"))
|
||||
}))
|
||||
.route(
|
||||
"/assets/web-server.js",
|
||||
get(|| async {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script defer="" src="/assets/drafts.js"></script>
|
||||
<script defer="" src="/assets/integrations.js"></script>
|
||||
<script defer="" src="/assets/vhosts.js"></script>
|
||||
<script defer="" src="/assets/web-server-profiles.js"></script>
|
||||
<script defer="" src="/assets/web-server.js"></script>
|
||||
<script defer="" src="/assets/docker.js"></script>
|
||||
<script defer="" src="/assets/streams.js"></script>
|
||||
@@ -684,6 +685,8 @@
|
||||
<input autocomplete="off" data-i18n-aria="webServiceSearch"
|
||||
data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search"
|
||||
type="search" />
|
||||
<button class="outline" data-i18n="phpProfileToolbar" id="web-service-php-profiles-open"
|
||||
type="button">PHP-FPM profiles</button>
|
||||
<button class="outline" data-i18n="webServiceNew" id="web-service-new"
|
||||
type="button">New Web Service</button>
|
||||
</div>
|
||||
@@ -3271,8 +3274,7 @@
|
||||
placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine"
|
||||
data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location
|
||||
response-header rules are applied later and can override them.</small></label>
|
||||
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache;
|
||||
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
|
||||
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">With vhost cache enabled, eligible Web Server responses also use the RustProxy object cache. Private, cookie-bearing and no-store responses are never stored.</p>
|
||||
</div>
|
||||
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
|
||||
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
|
||||
@@ -3323,6 +3325,11 @@
|
||||
keep validators, single-range requests, 206/416 and streaming delivery.</p>
|
||||
</div>
|
||||
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
|
||||
<label class="vh-wide"><span data-i18n="phpProfileSelect">PHP-FPM configuration source</span>
|
||||
<select id="web-service-php-profile"><option value="" data-i18n="phpProfileLocal">Local configuration</option></select>
|
||||
<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>
|
||||
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input
|
||||
@@ -3394,6 +3401,17 @@
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="php-profiles-title" class="vhost-dialog location-help-dialog" id="php-fpm-profiles-dialog">
|
||||
<div class="window-heading">
|
||||
<h2 id="php-profiles-title" data-i18n="phpProfileTitle">Shared PHP-FPM profiles</h2>
|
||||
<button type="button" class="outline" id="php-fpm-profiles-close" data-i18n="close">Close</button>
|
||||
</div>
|
||||
<div class="card location-help-content">
|
||||
<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>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="htaccess-help-title" class="vhost-dialog location-help-dialog" id="htaccess-help-dialog">
|
||||
<div class="window-heading">
|
||||
<h2 data-i18n="webServiceHtaccessDocsTitle" id="htaccess-help-title">.htaccess compatibility</h2>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use strict";
|
||||
// Shared profile editor is independent of the Web Service editor. Values are
|
||||
// never rendered as HTML and saves use the revisioned configuration API.
|
||||
window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData }) => {
|
||||
function refreshProfileSelect(chosen = $("web-service-php-profile").value) {
|
||||
const select = $("web-service-php-profile");
|
||||
const local = el("option", t("phpProfileLocal")); local.value = "";
|
||||
const profiles = (envelope?.config.php_fpm_profiles || []).map(profile => {
|
||||
const option = el("option", profile.name || profile.id); option.value = profile.id; return option;
|
||||
});
|
||||
select.replaceChildren(local, ...profiles);
|
||||
select.value = chosen;
|
||||
}
|
||||
|
||||
// Use exactly the same validators and FastCGI field semantics for local and
|
||||
// shared configuration. None of the profile values are interpolated as HTML.
|
||||
const profileFields = [
|
||||
["enabled", "webServicePhpEnabled", "checkbox"],
|
||||
["endpoint", "webServicePhpEndpoint", "text"],
|
||||
["extensions", "webServicePhpExtensions", "text"],
|
||||
["path_info", "webServicePhpPathInfo", "checkbox"],
|
||||
["pass_authorization", "webServicePhpPassAuthorization", "checkbox"],
|
||||
["connect_timeout_ms", "webServicePhpConnectTimeout", "number", 50, 60000],
|
||||
["request_timeout_ms", "webServicePhpRequestTimeout", "number", 100, 3600000],
|
||||
["max_concurrent_requests", "webServicePhpConcurrency", "number", 1, 4096],
|
||||
["max_request_body_bytes", "webServicePhpBodyLimit", "number", 0, 8589934592],
|
||||
["max_response_header_bytes", "webServicePhpHeaderLimit", "number", 4096, 1048576],
|
||||
["stderr_log_bytes", "webServicePhpStderrLimit", "number", 0, 65536],
|
||||
["custom_params", "webServicePhpCustomParams", "textarea"],
|
||||
["php_values", "webServicePhpValues", "textarea"],
|
||||
["php_admin_values", "phpProfileAdminValues", "textarea"],
|
||||
];
|
||||
|
||||
function profileEditor(profile, creating = false) {
|
||||
const details = document.createElement("details"); details.className = "card";
|
||||
if (creating) details.open = true;
|
||||
const summary = el("summary", profile.name || profile.id || t("phpProfileNew"));
|
||||
const editor = document.createElement("form"); editor.className = "vh-grid"; editor.noValidate = true;
|
||||
const namedInput = (key, value, readonly = false) => {
|
||||
const wrapper = el("label");
|
||||
wrapper.append(el("span", t(key)));
|
||||
const input = document.createElement("input"); input.value = value || ""; input.maxLength = key === "phpProfileId" ? 64 : 96;
|
||||
input.required = true; input.readOnly = readonly; wrapper.append(input); editor.append(wrapper); return input;
|
||||
};
|
||||
const idInput = namedInput("phpProfileId", profile.id, !creating);
|
||||
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 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]); }
|
||||
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;
|
||||
}
|
||||
const actions = el("div", undefined, "form-actions vh-wide");
|
||||
const save = el("button", t("apply"), "primary"); save.type = "submit";
|
||||
actions.append(save);
|
||||
if (!creating) {
|
||||
const remove = el("button", t("remove"), "quiet"); remove.type = "button";
|
||||
remove.addEventListener("click", async () => {
|
||||
const used = (envelope?.config.web_services || []).filter(service => service.php_profile_id === profile.id).map(service => service.id);
|
||||
if (used.length) return toast(`${t("phpProfileInUse")}: ${used.join(", ")}`, true);
|
||||
if (!(await appConfirm(t("phpProfileDelete"), { danger: true }))) return;
|
||||
const next = (envelope.config.php_fpm_profiles || []).filter(item => item.id !== profile.id);
|
||||
if (await saveSection("php-fpm-profiles", next)) { refreshProfileSelect(); renderProfiles(); }
|
||||
});
|
||||
actions.append(remove);
|
||||
}
|
||||
editor.append(actions);
|
||||
editor.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const id = idInput.value.trim(), name = nameInput.value.trim();
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id) || !name || name.length > 96) throw new Error(t("phpProfileInvalid"));
|
||||
for (const input of Object.values(controls)) if (!input.checkValidity()) throw new Error(t("phpProfileInvalid"));
|
||||
const php = Object.fromEntries(profileFields.map(([key, , kind]) => [key,
|
||||
kind === "checkbox" ? controls[key].checked : kind === "number" ? Number(controls[key].value) :
|
||||
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
|
||||
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
|
||||
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
|
||||
]));
|
||||
validatePhpCustomData(php.custom_params, php.php_values, php.php_admin_values);
|
||||
const next = structuredClone(envelope.config.php_fpm_profiles || []);
|
||||
if (next.some(item => item.id === id && (creating || item.id !== profile.id))) throw new Error(t("phpProfileDuplicate"));
|
||||
const position = next.findIndex(item => item.id === profile.id);
|
||||
if (!creating && position >= 0) next[position] = { id, name, php }; else next.push({ id, name, php });
|
||||
if (await saveSection("php-fpm-profiles", next)) { refreshProfileSelect(); renderProfiles(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
details.append(summary, editor);
|
||||
return details;
|
||||
}
|
||||
|
||||
function renderProfiles(addNew = false) {
|
||||
const profiles = envelope?.config.php_fpm_profiles || [];
|
||||
const list = profiles.map(item => profileEditor(item));
|
||||
if (addNew) list.unshift(profileEditor({ id: "", name: "", php: defaults().php }, true));
|
||||
$("php-fpm-profiles-list").replaceChildren(...list);
|
||||
}
|
||||
function openProfiles() {
|
||||
renderProfiles();
|
||||
$("php-fpm-profiles-dialog").showModal();
|
||||
}
|
||||
|
||||
return { renderProfiles, openProfiles, refreshProfileSelect };
|
||||
};
|
||||
@@ -153,15 +153,22 @@
|
||||
activateTab("general");
|
||||
}
|
||||
|
||||
const { renderProfiles, openProfiles, refreshProfileSelect } = window.rustProxyPhpProfiles({
|
||||
defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData
|
||||
});
|
||||
|
||||
function syncPhp() {
|
||||
const attached = !!$("web-service-php-profile").value;
|
||||
const enabled = $("web-service-php-enabled").checked;
|
||||
$("web-service-php-enabled").disabled = attached;
|
||||
|
||||
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-admin-values"
|
||||
]) $(id).disabled = !enabled;
|
||||
]) $(id).disabled = !enabled || attached;
|
||||
}
|
||||
|
||||
function syncCompression() {
|
||||
@@ -196,7 +203,7 @@
|
||||
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
|
||||
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
|
||||
try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
|
||||
symlink_policy: "deny", php: {
|
||||
symlink_policy: "deny", php_profile_id: null, php: {
|
||||
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: {}, php_admin_values: {}
|
||||
@@ -243,6 +250,7 @@
|
||||
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
|
||||
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
|
||||
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
|
||||
refreshProfileSelect(item.php_profile_id || "");
|
||||
$("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";
|
||||
@@ -373,7 +381,7 @@
|
||||
["webServiceDirectoryListing", t(service.directory_listing ? "enabled" : "notEnabled")],
|
||||
["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"],
|
||||
["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")],
|
||||
["webServicePhpEnabled", t(service.php?.enabled ? "enabled" : "notEnabled")],
|
||||
["webServicePhpEnabled", service.php_profile_id ? `${service.php_profile_id} (${t("phpProfileShared")})` : t(service.php?.enabled ? "enabled" : "notEnabled")],
|
||||
["webServiceCompression", service.compression?.enabled === true ? t("enabled") : service.compression?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")],
|
||||
["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")],
|
||||
@@ -428,6 +436,7 @@
|
||||
block_common_attacks: $("web-service-hardening-attacks").checked,
|
||||
strip_unnecessary_headers: $("web-service-hardening-headers").checked
|
||||
},
|
||||
php_profile_id: $("web-service-php-profile").value || null,
|
||||
php: {
|
||||
enabled: phpEnabled,
|
||||
path_info: $("web-service-php-path-info").checked,
|
||||
@@ -446,7 +455,7 @@
|
||||
}
|
||||
};
|
||||
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"));
|
||||
if ((phpEnabled || item.php_profile_id && (envelope.config.php_fpm_profiles || []).find(profile => profile.id === item.php_profile_id)?.php?.enabled) && 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);
|
||||
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
|
||||
@@ -462,6 +471,11 @@
|
||||
form.addEventListener("input", () => { dirty = true; });
|
||||
form.addEventListener("change", () => { dirty = true; });
|
||||
$("web-service-php-enabled").addEventListener("change", syncPhp);
|
||||
$("web-service-php-profile").addEventListener("change", syncPhp);
|
||||
$("web-service-php-profiles").addEventListener("click", openProfiles);
|
||||
$("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));
|
||||
$("web-service-compression").addEventListener("change", syncCompression);
|
||||
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
|
||||
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
|
||||
|
||||
+20
-16
@@ -48,24 +48,28 @@ pub fn compile_with_active(
|
||||
config.compression.validate()?;
|
||||
config.server_header.validate()?;
|
||||
let forwarding = config.forwarding.compile()?;
|
||||
ensure!(
|
||||
config.web_services.len() <= 128,
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
|
||||
let mut php_profiles = HashMap::new();
|
||||
for profile in &mut config.php_fpm_profiles {
|
||||
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
|
||||
"duplicate PHP-FPM profile id");
|
||||
}
|
||||
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
|
||||
let mut web_services = HashMap::new();
|
||||
for service in &mut config.web_services {
|
||||
service
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {}", service.id))?;
|
||||
let compiled = Arc::new(
|
||||
service
|
||||
.compile()
|
||||
.with_context(|| format!("Web Service {}", service.id))?,
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id"
|
||||
);
|
||||
service.validate().with_context(|| format!("Web Service {}", service.id))?;
|
||||
// A profile changes effective runtime settings, not the saved standalone
|
||||
// PHP configuration. Detaching a profile restores local settings exactly.
|
||||
let mut effective = service.clone();
|
||||
if let Some(id) = &service.php_profile_id {
|
||||
effective.php = php_profiles.get(id).with_context(|| {
|
||||
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
|
||||
})?.clone();
|
||||
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
}
|
||||
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");
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
let mut vhost_ids = HashSet::new();
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct Config {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub web_services: Vec<crate::web_server::Service>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub vhosts: Vec<crate::vhost::Vhost>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_policies: Vec<HostPolicy>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
}
|
||||
@@ -480,3 +480,4 @@ fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
assert!(frontend.backend_header);
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
|
||||
id: "php-default".into(),
|
||||
name: "Default PHP pool".into(),
|
||||
php: crate::web_server::Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
let service = crate::web_server::Service {
|
||||
id: "site-one".into(),
|
||||
name: "Site One".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("php-default".into()),
|
||||
// Standalone settings remain distinct and disabled.
|
||||
php: crate::web_server::Php::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut second = service.clone();
|
||||
second.id = "site-two".into();
|
||||
second.name = "Site Two".into();
|
||||
config.web_services.extend([service, second]);
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site-one".into(),
|
||||
};
|
||||
config.vhosts[0].routes.clear();
|
||||
config.vhosts[0].backends.clear();
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
for id in ["site-one", "site-two"] {
|
||||
let compiled = snapshot.web_php_services.get(id).unwrap();
|
||||
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile");
|
||||
}
|
||||
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
|
||||
assert!(!snapshot.envelope.config.web_services[0].php.enabled);
|
||||
assert!(!snapshot.envelope.config.web_services[1].php.enabled);
|
||||
|
||||
let serialized = serde_json::to_string(&snapshot.envelope.config).unwrap();
|
||||
let mut detached: Config = serde_json::from_str(&serialized).unwrap();
|
||||
detached.web_services[0].php_profile_id = None;
|
||||
let snapshot = compile(detached, 2, &policy()).unwrap();
|
||||
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
|
||||
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("missing".into()),
|
||||
..Default::default()
|
||||
});
|
||||
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown PHP-FPM profile"), "{err}");
|
||||
config.web_services[0].php_profile_id = None;
|
||||
let profile = crate::web_server::PhpFpmProfile {
|
||||
id: "duplicate".into(),
|
||||
name: "Duplicate".into(),
|
||||
php: Default::default(),
|
||||
};
|
||||
config.php_fpm_profiles = vec![profile.clone(), profile];
|
||||
let err = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
|
||||
}
|
||||
@@ -48,60 +48,7 @@ if route.unavailable {
|
||||
return response;
|
||||
}
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
if handshake.is_some() {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: Web Server source does not support WebSocket upgrades\n",
|
||||
);
|
||||
}
|
||||
let service_path = match route.rewritten_path(&path) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => path.clone(),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite path\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
|
||||
}
|
||||
// Do not hold a shared reference to Request<Body> across await. Axum handlers
|
||||
// must return Send futures, while Body is not Sync. Clone only static-request
|
||||
// metadata needed by the filesystem source before entering the async server.
|
||||
let static_method = request.method().clone();
|
||||
let static_headers = request.headers().clone();
|
||||
let static_uri = request.uri().clone();
|
||||
let static_version = request.version();
|
||||
let static_body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let mut response = crate::web_server::serve(
|
||||
service,
|
||||
static_method,
|
||||
static_headers,
|
||||
static_uri,
|
||||
&service_path,
|
||||
static_body,
|
||||
crate::web_server::RequestContext {
|
||||
client_ip: context.client_ip,
|
||||
client_port: Some(peer.0.port()),
|
||||
server_name: host.clone(),
|
||||
server_port: local.0.port(),
|
||||
tls,
|
||||
version: static_version,
|
||||
},
|
||||
)
|
||||
.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;
|
||||
return include!("web_source.rs");
|
||||
}
|
||||
let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool {
|
||||
let selection = match &route.backend_id {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
// Web Server is an origin just like a reverse-proxy backend. It must pass
|
||||
// through the same vhost cache policy, transform stage and response rules.
|
||||
if handshake.is_some() {
|
||||
return error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: Web Server source does not support WebSocket upgrades\n");
|
||||
}
|
||||
let service_path = match route.rewritten_path(&path) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => path.clone(),
|
||||
Err(_) => return error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite path\n"),
|
||||
};
|
||||
let cache_settings = route.frontend.as_ref()
|
||||
.map(|frontend| &frontend.cache)
|
||||
.unwrap_or(&snapshot.envelope.config.cache);
|
||||
let transform_settings = route.frontend.as_ref().map(|frontend| &frontend.transform);
|
||||
let scope = route.frontend.as_ref().map(|frontend| frontend.id.as_str()).unwrap_or("");
|
||||
let enabled = cache_settings.active_for(route.cache)
|
||||
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.tinyauth.is_none());
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(enabled);
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
}
|
||||
// The authority is already validated by effective_host. HTTP/2 and HTTP/3
|
||||
// requests commonly have :authority but no Host field, which otherwise
|
||||
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
|
||||
request.headers_mut().insert(HOST, original_host.clone());
|
||||
let request_method = request.method().clone();
|
||||
let request_headers = request.headers().clone();
|
||||
let request_uri = request.uri().clone();
|
||||
let request_version = request.version();
|
||||
let public_suffix = request_uri.path_and_query()
|
||||
.map(|item| item.as_str()).unwrap_or("/").to_owned();
|
||||
// H2/H3 may expose an unknown-length empty stream before its final frame.
|
||||
// Reuse the bounded probe that preserves any nonempty streaming body.
|
||||
let empty_body = if enabled && (request_method == Method::GET || request_method == Method::HEAD) {
|
||||
match empty_for_cache(&mut request).await {
|
||||
Ok(empty) => empty,
|
||||
Err(_) => return error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n"),
|
||||
}
|
||||
} else { request.body().is_end_stream() };
|
||||
let force_cache = enabled && route.frontend.as_ref().is_some_and(|frontend|
|
||||
frontend.force_cache.matches(&path, &public_suffix));
|
||||
let policy = if enabled {
|
||||
if force_cache {
|
||||
crate::cache::forced_request_policy(&request_method, &request_headers, empty_body)
|
||||
} else {
|
||||
crate::cache::request_policy(
|
||||
&request_method, &request_headers, empty_body, &path,
|
||||
request_uri.query().is_some(), cache_settings)
|
||||
}
|
||||
} else {
|
||||
crate::cache::RequestPolicy::Bypass("cache_disabled")
|
||||
};
|
||||
// HEAD can use an existing GET representation but may not fill a cache
|
||||
// entry with its empty downstream body.
|
||||
let eligible = enabled && policy.bypass_reason().is_none();
|
||||
let mut key_headers = request_headers.clone();
|
||||
let ignore_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
|
||||
if ignore_cookie { key_headers.remove("cookie"); }
|
||||
let cache_ticket = if eligible {
|
||||
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
|
||||
original_host.to_str().unwrap_or(""), &route.id, &public_suffix, &key_headers)
|
||||
} else { None };
|
||||
let range = crate::cache::request_range(&request_method, &request_headers);
|
||||
let if_range = request_headers.get("if-range").cloned();
|
||||
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
|
||||
let needs_validation = policy.revalidation_reason().is_some();
|
||||
if let Some(ticket) = &cache_ticket {
|
||||
if !needs_validation {
|
||||
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
|
||||
// Filesystem/PHP does not have the reverse-proxy background
|
||||
// revalidator. Refresh stale representations synchronously.
|
||||
if hit.stale {
|
||||
// Fall through and fetch from the local origin.
|
||||
} else {
|
||||
let conditional = crate::cache::conditional_not_modified(
|
||||
&request_headers, hit.response.headers());
|
||||
if !conditional {
|
||||
if let crate::cache::RangeSelection::Single(requested) = range {
|
||||
hit = hit.apply_range(requested, if_range.as_ref());
|
||||
}
|
||||
}
|
||||
let mut response = if conditional {
|
||||
crate::cache::make_not_modified(hit.response)
|
||||
} else { hit.response };
|
||||
if request_method == Method::HEAD {
|
||||
*response.body_mut() = Body::empty();
|
||||
}
|
||||
cache_settings.annotate_reason(response.headers_mut(), "HIT", hit.hits,
|
||||
hit.remaining_ttl, "cache_hit");
|
||||
cache_settings.annotate_transformed(response.headers_mut(), hit.transformed);
|
||||
cache_settings.annotate_resident_age(response.headers_mut(), hit.resident_age_seconds);
|
||||
state.cache.record(scope, &host, "HIT", "cache_hit");
|
||||
if hit.transformed { state.transform_metrics.record_cache_hit(scope); }
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = "cache_hit";
|
||||
}
|
||||
response.extensions_mut().insert(ResponseRulesApplied);
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cache_ticket.is_some() && crate::cache::only_if_cached(&request_headers) {
|
||||
return error(StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: only-if-cached request has no usable Web Server representation\n");
|
||||
}
|
||||
// Neither the Request nor the streaming Body is held across a borrowed
|
||||
// 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(),
|
||||
request_headers.clone(), request_uri, &service_path, static_body,
|
||||
crate::web_server::RequestContext {
|
||||
client_ip: context.client_ip,
|
||||
client_port: Some(peer.0.port()),
|
||||
server_name: host.clone(),
|
||||
server_port: local.0.port(),
|
||||
tls,
|
||||
version: request_version,
|
||||
}).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; }
|
||||
|
||||
let origin_headers = response.headers().clone();
|
||||
let original_plan = crate::cache::plan(response.status(), &origin_headers,
|
||||
cache_settings, started.elapsed());
|
||||
// Do not allow a later response-header rewrite to override a private,
|
||||
// explicitly uncacheable PHP/CGI origin response.
|
||||
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|
||||
|| origin_headers.contains_key("expires")
|
||||
|| origin_headers.contains_key("set-cookie")
|
||||
|| origin_headers.contains_key("vary");
|
||||
let origin_safe = !explicit_origin_policy || original_plan.is_some();
|
||||
let no_transform = crate::transform::no_transform(&origin_headers);
|
||||
let skip_compression = crate::compression::no_transform(&origin_headers);
|
||||
if skip_compression || range_requested {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.extensions_mut().insert(crate::transform::OriginValidators {
|
||||
etag: origin_headers.get("etag").cloned(),
|
||||
last_modified: origin_headers.get("last-modified").cloned(),
|
||||
});
|
||||
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
|
||||
Some(route), false);
|
||||
response.extensions_mut().insert(ResponseRulesApplied);
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
|
||||
// Cache representation must reflect vhost response rules and transforms,
|
||||
// not solely the Web Service's Cache-Control header.
|
||||
let origin_eligible_plan = if origin_safe
|
||||
&& !(ignore_cookie && crate::cache::varies_on(response.headers(), "cookie"))
|
||||
{
|
||||
crate::cache::plan(response.status(), response.headers(),
|
||||
cache_settings, started.elapsed())
|
||||
} else { None };
|
||||
if request_method == Method::GET && !range_requested {
|
||||
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
|
||||
let transform_start = Instant::now();
|
||||
response = match crate::transform::apply(response, &public_suffix,
|
||||
settings, no_transform).await {
|
||||
Ok(value) => {
|
||||
state.transform_metrics.record_response(scope, &value, transform_start.elapsed());
|
||||
value
|
||||
}
|
||||
Err(cause) => {
|
||||
state.transform_metrics.record_error(scope, transform_start.elapsed());
|
||||
tracing::debug!(error = %cause, "Web Server transform failed");
|
||||
return error(StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: Web Server response could not be transformed\n");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
let fill_plan = if origin_eligible_plan.is_some() {
|
||||
crate::cache::plan(response.status(), response.headers(),
|
||||
cache_settings, started.elapsed())
|
||||
} else { None };
|
||||
let reason = if !enabled { "cache_disabled" }
|
||||
else if let Some(reason) = policy.bypass_reason() { reason }
|
||||
else if needs_validation { "request_revalidated" }
|
||||
else if cache_ticket.is_some() && fill_plan.is_none() { "response_headers_policy" }
|
||||
else if cache_ticket.is_some() { "cache_miss" }
|
||||
else { "cache_unavailable" };
|
||||
let fillable = request_method == Method::GET && !range_requested;
|
||||
let outcome = if cache_ticket.is_some() { "MISS" } else { "BYPASS" };
|
||||
if let (Some(ticket), Some(origin), Some(transformed)) = (
|
||||
cache_ticket, origin_eligible_plan, fill_plan,
|
||||
) {
|
||||
if fillable {
|
||||
let canonical = response.extensions().get::<crate::transform::CanonicalIdentity>().is_some();
|
||||
let mut plan = if canonical { origin.without_vary("accept-encoding") }
|
||||
else { origin };
|
||||
if force_cache {
|
||||
if request_headers.contains_key("cookie") { plan = plan.with_vary("cookie"); }
|
||||
if request_headers.contains_key("authorization") { plan = plan.with_vary("authorization"); }
|
||||
}
|
||||
response = state.cache.wrap_with_fill(response, ticket,
|
||||
plan.restrict(transformed), cache_settings, None);
|
||||
}
|
||||
}
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
exchange.cache_status.store(if outcome == "MISS" { 2 } else { 3 }, Ordering::Relaxed);
|
||||
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
state.cache.record(scope, &host, outcome, reason);
|
||||
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
|
||||
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
|
||||
cache_settings.annotate_transformed(response.headers_mut(), transformed);
|
||||
response
|
||||
}
|
||||
@@ -1,3 +1,72 @@
|
||||
impl Php {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(
|
||||
self.extensions.len() <= MAX_PHP_EXTENSIONS,
|
||||
"Web Service PHP allows at most 16 extensions"
|
||||
);
|
||||
self.extensions = normalize_extensions(
|
||||
std::mem::take(&mut self.extensions),
|
||||
"Web Service PHP extension",
|
||||
)?;
|
||||
self.endpoint = self.endpoint.trim().to_owned();
|
||||
ensure!(
|
||||
self.connect_timeout_ms >= 50 && self.connect_timeout_ms <= 60_000,
|
||||
"Web Service PHP connect timeout must be 50..60000 ms"
|
||||
);
|
||||
ensure!(
|
||||
self.request_timeout_ms >= 100 && self.request_timeout_ms <= 3_600_000,
|
||||
"Web Service PHP request timeout must be 100..3600000 ms"
|
||||
);
|
||||
ensure!(
|
||||
(1..=4096).contains(&self.max_concurrent_requests),
|
||||
"Web Service PHP max concurrent requests must be 1..4096"
|
||||
);
|
||||
ensure!(
|
||||
self.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024,
|
||||
"Web Service PHP request body limit must be 0..8589934592 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.max_response_header_bytes >= 4096
|
||||
&& self.max_response_header_bytes <= 1024 * 1024,
|
||||
"Web Service PHP response header limit must be 4096..1048576 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.stderr_log_bytes <= 64 * 1024,
|
||||
"Web Service PHP stderr log limit must be 0..65536 bytes"
|
||||
);
|
||||
self.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.custom_params))?;
|
||||
self.php_values = normalize_php_values(std::mem::take(&mut self.php_values))?;
|
||||
self.php_admin_values = normalize_php_values(std::mem::take(&mut self.php_admin_values))?;
|
||||
ensure!(!self.php_values.keys().any(|key| self.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
|
||||
let php_custom_bytes = self
|
||||
.custom_params
|
||||
.iter()
|
||||
.chain(self.php_values.iter())
|
||||
.chain(self.php_admin_values.iter())
|
||||
.map(|(name, value)| name.len() + value.len() + 2)
|
||||
.sum::<usize>();
|
||||
ensure!(
|
||||
php_custom_bytes <= 128 * 1024,
|
||||
"Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB"
|
||||
);
|
||||
if self.enabled {
|
||||
ensure!(!self.extensions.is_empty(), "PHP-FPM needs executable extensions");
|
||||
FastCgiEndpoint::parse(&self.endpoint)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PhpFpmProfile {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid PHP-FPM profile id");
|
||||
self.name = self.name.trim().to_owned();
|
||||
ensure!(!self.name.is_empty() && self.name.len() <= MAX_SERVICE_NAME_BYTES
|
||||
&& !self.name.chars().any(char::is_control), "PHP-FPM profile name is invalid");
|
||||
self.php.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid Web Service id");
|
||||
@@ -206,56 +275,10 @@ impl Service {
|
||||
"Web Service download extension",
|
||||
)?;
|
||||
|
||||
ensure!(
|
||||
self.php.extensions.len() <= MAX_PHP_EXTENSIONS,
|
||||
"Web Service PHP allows at most 16 extensions"
|
||||
);
|
||||
self.php.extensions = normalize_extensions(
|
||||
std::mem::take(&mut self.php.extensions),
|
||||
"Web Service PHP extension",
|
||||
)?;
|
||||
self.php.endpoint = self.php.endpoint.trim().to_owned();
|
||||
ensure!(
|
||||
self.php.connect_timeout_ms >= 50 && self.php.connect_timeout_ms <= 60_000,
|
||||
"Web Service PHP connect timeout must be 50..60000 ms"
|
||||
);
|
||||
ensure!(
|
||||
self.php.request_timeout_ms >= 100 && self.php.request_timeout_ms <= 3_600_000,
|
||||
"Web Service PHP request timeout must be 100..3600000 ms"
|
||||
);
|
||||
ensure!(
|
||||
(1..=4096).contains(&self.php.max_concurrent_requests),
|
||||
"Web Service PHP max concurrent requests must be 1..4096"
|
||||
);
|
||||
ensure!(
|
||||
self.php.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024,
|
||||
"Web Service PHP request body limit must be 0..8589934592 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.php.max_response_header_bytes >= 4096
|
||||
&& self.php.max_response_header_bytes <= 1024 * 1024,
|
||||
"Web Service PHP response header limit must be 4096..1048576 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.php.stderr_log_bytes <= 64 * 1024,
|
||||
"Web Service PHP stderr log limit must be 0..65536 bytes"
|
||||
);
|
||||
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!(
|
||||
php_custom_bytes <= 128 * 1024,
|
||||
"Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB"
|
||||
);
|
||||
self.php.validate()?;
|
||||
if let Some(id) = &self.php_profile_id {
|
||||
ensure!(crate::config::valid_id(id), "invalid PHP-FPM profile reference");
|
||||
}
|
||||
htaccess::validate(&mut self.htaccess)?;
|
||||
|
||||
if self.php.enabled {
|
||||
|
||||
@@ -309,6 +309,16 @@ impl Default for Php {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable PHP-FPM configuration. A Web Service can reference one profile or
|
||||
/// keep its local `php` settings. Profiles are part of the revisioned Config.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PhpFpmProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub php: Php,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Hardening {
|
||||
@@ -352,6 +362,10 @@ pub struct Service {
|
||||
pub download_extensions: Vec<String>,
|
||||
/// Symlink handling. The conservative default is `deny`.
|
||||
pub symlink_policy: SymlinkPolicy,
|
||||
/// Optional reference to a shared FPM profile; `php` stays the standalone
|
||||
/// configuration and is preserved when attaching/detaching a profile.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub php_profile_id: Option<String>,
|
||||
/// Optional FastCGI/PHP-FPM execution for configured script extensions.
|
||||
pub php: Php,
|
||||
/// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time.
|
||||
@@ -379,6 +393,7 @@ impl Default for Service {
|
||||
compression: crate::compression::Overrides::default(),
|
||||
download_extensions: Vec::new(),
|
||||
symlink_policy: SymlinkPolicy::Deny,
|
||||
php_profile_id: None,
|
||||
php: Php::default(),
|
||||
htaccess: HtaccessConfig::default(),
|
||||
hardening: Hardening::default(),
|
||||
|
||||
@@ -201,6 +201,9 @@ pub(super) fn fastcgi_params(
|
||||
})
|
||||
.or_insert_with(|| value.to_owned());
|
||||
}
|
||||
// HTTP/2 and HTTP/3 use :authority and may not carry a literal Host
|
||||
// header. WordPress and other CGI applications require HTTP_HOST.
|
||||
params.entry("HTTP_HOST".into()).or_insert_with(|| context.server_name.clone());
|
||||
// Trusted operator-defined parameters cannot override request-derived/core CGI names.
|
||||
for (name, value) in &php.custom_params {
|
||||
params.insert(name.clone(), value.clone());
|
||||
|
||||
@@ -16,7 +16,7 @@ mod security;
|
||||
mod serve;
|
||||
|
||||
pub use config::{
|
||||
CacheRule, CompiledService, Hardening, Php, Precompressed, RewriteRule, Service, SymlinkPolicy,
|
||||
CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, Service, SymlinkPolicy,
|
||||
};
|
||||
pub use htaccess::Config as HtaccessConfig;
|
||||
pub use request::RequestContext;
|
||||
|
||||
@@ -285,3 +285,77 @@ fn php_admin_values_are_operator_only_and_serialized() {
|
||||
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
|
||||
assert!(configured.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "host-fallback".into(),
|
||||
name: "Host fallback".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let uri: Uri = "/wp-admin/index.php".parse().unwrap();
|
||||
for version in [Version::HTTP_2, Version::HTTP_3] {
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/wp-admin/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version,
|
||||
},
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
|
||||
assert!(encoded.windows(b"example.test".len()).any(|w| w == b"example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_http_host_keeps_explicit_host_and_port() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "host-port".into(),
|
||||
name: "Host port".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(http::header::HOST, HeaderValue::from_static("example.test:8443"));
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&"/index.php".parse().unwrap(),
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
|
||||
assert!(encoded.windows(b"example.test:8443".len()).any(|w| w == b"example.test:8443"));
|
||||
}
|
||||
Reference in new issue
Block a user