This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 13:11:33 +02:00
1 parent f87eec7287
commit dd8c195600
38 files changed
+5491 -4682

No files matched your search

+4
View File
@@ -89,6 +89,10 @@ async fn update_section(
config.web_services =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"php-fpm-profiles" => {
config.php_fpm_profiles =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"redirects" => {
config.redirects =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
+39 -1
View File
@@ -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"
}
}
}
}
},
+4
View File
@@ -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 {
+20 -2
View File
@@ -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>
+112
View File
@@ -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 };
};
+18 -4
View File
@@ -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);