v0.34.6
This commit is contained in:
1 parent
e037190c14
commit
5cb3b37627
37 files changed
+1031
-138
No files matched your search
+23
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.5",
|
||||
"version": "0.34.6",
|
||||
"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. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -503,6 +503,27 @@
|
||||
"method_policy": {
|
||||
"$ref": "#/components/schemas/MethodPolicy"
|
||||
},
|
||||
"content_type_policy": {
|
||||
"type": "object",
|
||||
"description": "Ordinary Vhost request Content-Type denylist. Matching ignores case and MIME parameters; evaluated before Web Server/PHP and origin handling. Disabled by default. Missing Content-Type is allowed; ambiguous/invalid Content-Type is rejected when enabled.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"blocked": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"description": "MIME type/subtype or type/* (e.g. multipart/form-data, application/*)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -13662,4 +13683,4 @@
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ fn status_payload(state: &App) -> Value {
|
||||
"rate_limit",
|
||||
"country_access",
|
||||
"method_policy",
|
||||
"content_type_policy",
|
||||
"location_access_policy",
|
||||
"configuration_history",
|
||||
"cloudflare_client_ip",
|
||||
|
||||
@@ -57,7 +57,7 @@ function translate() {
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
@@ -267,7 +267,7 @@ function renderStatus(s, systemSample = false) {
|
||||
$("metric-ws-proto-h1").textContent = (Number(m.websocket_h1_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h2").textContent = (Number(m.websocket_h2_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h3").textContent = (Number(m.websocket_h3_active) || 0).toLocaleString(locale());
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s); window.rustProxyWebOverview?.render(s); window.rustProxyWebServer?.renderLive?.(s);
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyDocker?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s); window.rustProxyWebOverview?.render(s); window.rustProxyWebServer?.renderLive?.(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
|
||||
+121
-12
@@ -2,6 +2,7 @@
|
||||
(() => {
|
||||
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
|
||||
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
|
||||
const expandedMetrics = new Set(), metricHistory = new Map();
|
||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
|
||||
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
||||
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
|
||||
@@ -23,11 +24,11 @@
|
||||
function expandedHost(value, baseDomain) { const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); return host && host.includes(".") ? host : host ? `${host}.${baseDomain}` : ""; }
|
||||
function publicListener(agent) {
|
||||
const endpoints = envelope?.config?.listeners?.endpoints || [], wanted = new Set(agent?.listener_ids || []), candidates = endpoints.filter(item => item.enabled !== false && wanted.has(item.id));
|
||||
return candidates.find(item => item.tls) || candidates[0] || null;
|
||||
return candidates.find(item => item.binding?.tls === true) || candidates[0] || null;
|
||||
}
|
||||
function proxiedUrl(agent, hostname) {
|
||||
const host = expandedHost(hostname, agent.base_domain); if (!host) return "";
|
||||
const listener = publicListener(agent), secure = !!listener?.tls, scheme = secure ? "https" : "http", address = String(listener?.address || "");
|
||||
const listener = publicListener(agent), secure = !!listener?.binding?.tls, scheme = secure ? "https" : "http", address = String(listener?.binding?.address || "");
|
||||
const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`;
|
||||
return `${scheme}://${host}${suffix}/`;
|
||||
}
|
||||
@@ -209,6 +210,83 @@
|
||||
const latency = Number(member.latency_ms || member.ewma_latency_ms || 0), metrics = [`${t("dockerHealthLatency")}: ${latency > 0 ? `${latency.toFixed(1)} ms` : "-"}`, `${t("dockerHealthResult")}: ${member.last_result || "-"}`, `${t("dockerHealthRequests")}: ${Number(member.requests || 0).toLocaleString()}`, `${t("dockerHealthErrors")}: ${Number(member.errors || 0).toLocaleString()}`, `${t("dockerHealthActive")}: ${Number(member.active || 0).toLocaleString()}`];
|
||||
wrap.append(el("span", metrics.join(" · "), "fine")); return wrap;
|
||||
}
|
||||
function syncMetricsToggle() {
|
||||
const control = $("docker-metrics-toggle-all"); if (!control) return;
|
||||
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
|
||||
const allOpen = sections.length > 0 && sections.every(section => section.open);
|
||||
const key = allOpen ? "dockerCollapseAllMetrics" : "dockerExpandAllMetrics";
|
||||
control.disabled = sections.length === 0; control.dataset.i18n = key; control.textContent = t(key);
|
||||
control.setAttribute("aria-expanded", String(allOpen));
|
||||
}
|
||||
function statisticsSection(agent, serviceKey) {
|
||||
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
|
||||
section.dataset.viewKey = viewKey(agent.id, serviceKey);
|
||||
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
|
||||
section.open = expandedMetrics.has(section.dataset.viewKey);
|
||||
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
||||
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
|
||||
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
|
||||
const item = el("div", undefined, "vhost-live-metric"), value = el("strong", "-");
|
||||
item.append(el("span", t(key)), value); fields[key] = value; grid.append(item);
|
||||
}
|
||||
section.append(grid);
|
||||
const chart = el("div", undefined, "vhost-chart"), head = el("div", undefined, "vhost-chart-head");
|
||||
const chartValue = el("span", "-", "fine"); head.append(el("span", t("vhLifetimeChart"), "small-label"), chartValue); chart.append(head);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("aria-label", t("vhLifetimeChart"));
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); line.setAttribute("class", "vhost-chart-line");
|
||||
svg.append(line); chart.append(svg); section.append(chart);
|
||||
section.dockerMetricView = { fields, chartValue, line };
|
||||
section.addEventListener("toggle", () => {
|
||||
if (section.open) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
syncMetricsToggle();
|
||||
});
|
||||
return section;
|
||||
}
|
||||
function renderDockerMetrics(status = latestStatus) {
|
||||
const observed = status?.vhost_metrics || {};
|
||||
const livePools = new Map((status?.backend_pools || []).map(pool => [pool.id, pool]));
|
||||
const now = performance.now();
|
||||
for (const route of poolRoutes) {
|
||||
const value = observed[route.pool_id]; if (!value) continue;
|
||||
const history = metricHistory.get(route.pool_id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
if (history.snapshot !== value) {
|
||||
const requests = Number(value.requests), transferred = Number(value.bytes_in) + Number(value.bytes_out);
|
||||
const sample = rustProxyRateSampling.difference(history.last, [requests, transferred], now, rustProxyRateSampling.cadence("overview_seconds"));
|
||||
history.paused = history.last !== null && sample.deltas === null;
|
||||
history.last = sample.next; history.rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0;
|
||||
history.traffic = sample.deltas ? sample.deltas[1] / sample.seconds : 0;
|
||||
if (!history.paused) {
|
||||
history.pendingSum += history.rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
}
|
||||
if (history.points.length >= 120) {
|
||||
const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2);
|
||||
history.points = compacted; history.bucketSize *= 2;
|
||||
}
|
||||
history.snapshot = value; history.metrics = value; metricHistory.set(route.pool_id, history);
|
||||
}
|
||||
}
|
||||
for (const section of document.querySelectorAll("#docker-discovery-list .docker-service-metrics")) {
|
||||
const [agentId, serviceKey] = [section.dataset.agentId, section.dataset.serviceKey];
|
||||
const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey);
|
||||
const history = route && metricHistory.get(route.pool_id), metrics = history?.metrics;
|
||||
const view = section.dockerMetricView, runtime = route && (livePools.get(route.pool_id) || poolIndex.get(route.pool_id)), members = runtime?.members || [];
|
||||
view.fields.status.textContent = !runtime || !members.length ? "-" : runtime.health_mode === "off" ? t("vhHealthDisabled") : `${members.filter(member => member.status === "up").length}/${members.length} ${t("vhUp")}`;
|
||||
view.fields.vhRequests.textContent = metrics ? fmt(metrics.requests) : "-";
|
||||
view.fields.vhActive.textContent = metrics ? fmt(metrics.active) : "-";
|
||||
view.fields.vhErrors.textContent = metrics ? fmt(metrics.errors) : "-";
|
||||
view.fields.vhLatency.textContent = Number(metrics?.ewma_latency_ms) > 0 ? `${fmt(metrics.ewma_latency_ms, 1)} ms` : "-";
|
||||
view.fields.vhTraffic.textContent = !history || history.paused ? "-" : `${rateBytes(history.traffic)}/s`;
|
||||
if (!history) { view.chartValue.textContent = "-"; view.line.setAttribute("points", ""); continue; }
|
||||
const data = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points;
|
||||
const peak = Math.max(0, ...data), scale = Math.max(1, peak);
|
||||
const points = data.map((value, index) => `${index * 320 / Math.max(1, data.length - 1)},${50 - value / scale * 44}`).join(" ");
|
||||
view.line.setAttribute("points", points);
|
||||
view.chartValue.textContent = `${history.paused ? "-" : history.rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${peak.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
function selectHealthMode(value) { const control = el("select", undefined, "docker-health-mode"); for (const [name, key] of [["http", "dockerHealthHttp"], ["tcp", "dockerHealthTcp"], ["off", "dockerHealthOff"]]) { const option = el("option", t(key)); option.value = name; control.append(option); } control.value = value; return control; }
|
||||
function numberInput(value, cls, min, max, optional = false) { const node = input(value ?? "", "number", cls); node.min = min; node.max = max; if (!optional) node.required = true; return node; }
|
||||
function syncHealthFields(details) { const http = details.querySelector(".docker-health-mode")?.value === "http"; details.querySelectorAll("[data-http-only]").forEach(node => node.hidden = !http); }
|
||||
@@ -280,7 +358,6 @@
|
||||
return {
|
||||
service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null,
|
||||
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, http2_enabled: row.querySelector(".docker-http2-enabled").checked, http3_enabled: row.querySelector(".docker-http3-enabled").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
|
||||
// Docker publications intentionally expose one compression switch only. Codec selection stays global.
|
||||
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
|
||||
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
|
||||
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
|
||||
@@ -312,18 +389,35 @@
|
||||
backend: String(assignment?.target_ip || agent.default_target_ip || inventory?.public_ip || "").toLowerCase(), scheme: String(assignment?.scheme || "http"), published: assignment && assignment.enabled !== false ? 0 : 1,
|
||||
};
|
||||
const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false;
|
||||
const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish")));
|
||||
publish.setAttribute("role", "switch");
|
||||
publish.setAttribute("aria-checked", String(publish.checked));
|
||||
const publishLabel = el("label", undefined, "docker-publish-field");
|
||||
publishLabel.append(el("span", t("dockerPublish"), "docker-publish-text"), publish);
|
||||
const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", ");
|
||||
meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`));
|
||||
const visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
|
||||
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
|
||||
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
|
||||
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; };
|
||||
syncOpenApp(legacyHostname(assignment));
|
||||
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top);
|
||||
const liveLogs = el("button", t("liveLogs"), "outline docker-open-logs"); liveLogs.type = "button";
|
||||
liveLogs.title = t("dockerLiveAccessLogsHelp");
|
||||
liveLogs.addEventListener("click", () => {
|
||||
const route = poolRoutes.find(item => item.agent_id === agent.id && item.service_key === key);
|
||||
if (publish.checked && route?.pool_id) window.rustProxyLogs?.open(route.pool_id);
|
||||
});
|
||||
const syncOpenActions = hostname => {
|
||||
const url = proxiedUrl(agent, hostname);
|
||||
openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || "";
|
||||
liveLogs.hidden = !publish.checked;
|
||||
liveLogs.disabled = !publish.checked || !poolRoutes.some(item => item.agent_id === agent.id && item.service_key === key && item.pool_id);
|
||||
publish.setAttribute("aria-checked", String(publish.checked));
|
||||
};
|
||||
syncOpenActions(legacyHostname(assignment));
|
||||
const quickActions = el("div", undefined, "docker-service-quick-actions"); quickActions.append(openApp, liveLogs);
|
||||
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), quickActions, visibility); top.append(publishLabel, meta, headActions); row.append(top);
|
||||
|
||||
const editor = el("details", undefined, "docker-service-editor"), editorSummary = el("summary"), editorTitle = el("span", t("dockerConfigureService")), editorHint = el("small", "", "fine docker-config-hint"); editorSummary.append(editorTitle, editorHint); editor.append(editorSummary);
|
||||
const route = el("div", undefined, "docker-route-grid"), host = input(legacyHostname(assignment) || service?.technical_name || "", "text", "docker-hostname"); host.maxLength = 253; host.placeholder = service?.technical_name || "app1";
|
||||
row.dockerSyncActions = () => syncOpenActions(host.value);
|
||||
let aliases; const aliasChanged = () => { updateHint?.(); row.dispatchEvent(new Event("input", { bubbles: true })); }; aliases = aliasEditor(assignment?.aliases || [], aliasChanged); row._readAliases = aliases.read;
|
||||
const target = el("select", undefined, "docker-target-ip"), auto = el("option", agent.default_target_ip ? `${t("dockerBackendIpAuto")} (${agent.default_target_ip})` : t("dockerBackendIpAuto")); auto.value = ""; target.append(auto); for (const [ip, label] of targetOptions(inventory, service, assignment, agent)) { const option = el("option", label); option.value = ip; target.append(option); } target.value = assignment?.target_ip || "";
|
||||
const scheme = el("select", undefined, "docker-scheme"); for (const name of ["http", "https"]) { const option = el("option", name.toUpperCase()); option.value = name; scheme.append(option); } scheme.value = assignment?.scheme || "http";
|
||||
@@ -346,7 +440,6 @@
|
||||
backendIpSettings.append(labeled("dockerBackendIp", target), certificateBar);
|
||||
|
||||
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
|
||||
// New publications default to H3 off; legacy assignments keep their saved behavior.
|
||||
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment ? assignment.http3_enabled !== false : false;
|
||||
const protocolBox = el("details", undefined, "docker-protocol-editor"), protocolSummary = el("summary");
|
||||
protocolSummary.append(el("span", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
|
||||
@@ -358,15 +451,17 @@
|
||||
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, backendIpSettings, labeled("dockerBackendScheme", scheme), switches);
|
||||
editor.append(route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
|
||||
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
|
||||
if (agent.enabled !== false && assignment && assignment.enabled !== false) row.append(statisticsSection(agent, key));
|
||||
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel} · H1${h2Enabled.checked ? "/H2" : ""}${h3Enabled.checked ? "/H3" : ""}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
|
||||
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
|
||||
for (const control of [h2Enabled, h3Enabled]) control.addEventListener("change", updateHint);
|
||||
host.addEventListener("input", () => syncOpenApp(host.value));
|
||||
host.addEventListener("input", () => syncOpenActions(host.value));
|
||||
row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); });
|
||||
publish.addEventListener("change", async () => {
|
||||
const wanted = publish.checked; publish.disabled = true; apply.disabled = true;
|
||||
syncOpenActions(host.value);
|
||||
try { const saved = await saveAssignment(agent.id, row, wanted); if (!saved) publish.checked = !wanted; } catch (error) { publish.checked = !wanted; toast(error.message, true); }
|
||||
finally { if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenApp(host.value); } }
|
||||
finally { publish.setAttribute("aria-checked", String(publish.checked)); if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenActions(host.value); } }
|
||||
});
|
||||
row.classList.toggle("is-unpublished", !publish.checked); return row;
|
||||
}
|
||||
@@ -390,6 +485,7 @@
|
||||
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
|
||||
}
|
||||
applyViewFilters();
|
||||
renderDockerMetrics();
|
||||
}
|
||||
function sortServiceRows(card) {
|
||||
const holder = card.querySelector(".docker-service-list"); if (!holder) return;
|
||||
@@ -414,9 +510,10 @@
|
||||
card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query));
|
||||
}
|
||||
const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); }
|
||||
syncMetricsToggle();
|
||||
}
|
||||
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
|
||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor:not(.docker-service-metrics)[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||
let dockerLoading = null, dockerForcePending = false;
|
||||
async function refresh(force = false, context = null) {
|
||||
if (!authenticated) return;
|
||||
@@ -442,13 +539,25 @@
|
||||
if (!authenticated || epoch !== authEpoch || (context?.isCurrent && !context.isCurrent())) return;
|
||||
observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || [];
|
||||
poolIndex = new Map(pools.map(pool => [pool.id, pool]));
|
||||
document.querySelectorAll("#docker-discovery-list .docker-service-row").forEach(row => row.dockerSyncActions?.());
|
||||
renderSettings(); if (force || canLiveRender()) renderDiscovery();
|
||||
else renderDockerMetrics();
|
||||
} catch (error) {
|
||||
if (force && error?.name !== "AbortError" && authenticated && epoch === authEpoch) toast(error.message, true);
|
||||
} finally { if (button) button.disabled = false; }
|
||||
}
|
||||
|
||||
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
|
||||
$("docker-metrics-toggle-all")?.addEventListener("click", () => {
|
||||
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
|
||||
const expand = !sections.every(section => section.open);
|
||||
for (const section of sections) {
|
||||
section.open = expand;
|
||||
if (expand) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
}
|
||||
syncMetricsToggle();
|
||||
});
|
||||
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); rustProxyRefresh.sync(); });
|
||||
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
|
||||
$("docker-search")?.addEventListener("input", applyViewFilters);
|
||||
@@ -475,5 +584,5 @@
|
||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||
run: async context => refresh(false, context)
|
||||
});
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); expandedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
})();
|
||||
@@ -622,7 +622,9 @@
|
||||
title="Refresh this view from RustProxy memory every 5 seconds"><input
|
||||
id="docker-live-refresh" type="checkbox" /><span data-i18n="dockerLiveView">Live
|
||||
view</span></label><button class="outline" data-i18n="dockerRefresh"
|
||||
id="docker-refresh" type="button">Refresh agents</button><button class="quiet"
|
||||
id="docker-refresh" type="button">Refresh agents</button><button class="outline"
|
||||
data-i18n="dockerExpandAllMetrics" id="docker-metrics-toggle-all" type="button"
|
||||
aria-expanded="false" disabled>Expand all statistics</button><button class="quiet"
|
||||
data-i18n="dockerAgentsSettings" id="docker-settings-open" type="button">Docker agents
|
||||
settings</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||
id="docker-show-hidden" type="button">Show hidden</button></div>
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
if (data) mergeExpanded(data);
|
||||
if (!dialog.open) return;
|
||||
const selector = $("log-vhost"), selected = selector.value, ids = new Set([...(envelope?.config.vhosts || []).map(v => v.id), ...source().entries.map(e => e.vhost).filter(Boolean)]);
|
||||
if (selected) ids.add(selected);
|
||||
const all = el("option", t("logAllVhosts")); all.value = ""; selector.replaceChildren(all, ...[...ids].sort().map(id => { const o = el("option", id); o.value = id; return o; })); if (ids.has(selected)) selector.value = selected;
|
||||
const entries = filtered(), rows = entries.map(e => {
|
||||
const row = el("tr"); const hints = [];
|
||||
@@ -209,9 +210,6 @@
|
||||
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
|
||||
})();
|
||||
|
||||
// Reusable ACL profiles. One profile is one access policy: the guided modes
|
||||
// make country and IP/CIDR exceptions read as a single OR expression, while
|
||||
// custom mode retains the full backend model for mixed allow/deny policies.
|
||||
(() => {
|
||||
let editing = null, dirty = false;
|
||||
const form = $("acl-profile-form");
|
||||
|
||||
+85
-2
@@ -5819,18 +5819,101 @@ dialog .window-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docker-service-head-actions .docker-runtime {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 230px;
|
||||
}
|
||||
|
||||
.docker-service-head-actions .docker-view-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.docker-publish-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docker-publish-field input[type=checkbox].docker-publish {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
position: relative;
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--field-line);
|
||||
border-radius: min(3px, var(--rp-radius-control, 3px));
|
||||
background: var(--field-bg);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px auto 2px 2px;
|
||||
width: 10px;
|
||||
background: var(--muted);
|
||||
border-radius: min(1px, var(--rp-radius-control, 1px));
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:checked {
|
||||
background: var(--field-bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:checked::before {
|
||||
transform: translateX(14px);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.docker-publish-field:has(.docker-publish:disabled) {
|
||||
opacity: .55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.docker-service-quick-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.docker-open-logs {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.docker-publish-field .docker-publish,
|
||||
.docker-publish-field .docker-publish::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.docker-service-editor {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -53,7 +53,7 @@ const defaults = () => ({
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, content_type_policy: { enabled: false, blocked: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
|
||||
@@ -260,6 +260,15 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
field(methodFields, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(methodFields, "vhMethodHelp");
|
||||
|
||||
const mimePolicy = value.content_type_policy || {};
|
||||
const mimeFields = subsection(g, "vhContentTypeSection");
|
||||
const mimeEnabled = field(mimeFields, "content-type-block-enabled", "vhContentTypeBlock", mimePolicy.enabled === true, "checkbox");
|
||||
const mimeList = field(mimeFields, "content-type-block-list", "vhContentTypeList", (mimePolicy.blocked || []).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
mimeList.placeholder = "multipart/form-data\napplication/*";
|
||||
const syncMime = () => { mimeList.disabled = !mimeEnabled.checked; };
|
||||
mimeEnabled.addEventListener("change", syncMime); syncMime();
|
||||
note(mimeFields, "vhContentTypeHelp");
|
||||
|
||||
const rateFields = subsection(g, "vhTrafficRateSection");
|
||||
field(rateFields, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(rateFields, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
@@ -486,12 +495,14 @@ function collect() {
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
if (yes("content-type-block-enabled") && !lines(str("content-type-block-list")).length) throw new Error(t("vhContentTypeRequired"));
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), allow_certificate_mismatch: yes("allow-certificate-mismatch"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: null,
|
||||
acl_profile_ids: window.rustProxyAclPicker.read(form.querySelector(".vh-acl-profile-choices")),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
content_type_policy: { enabled: yes("content-type-block-enabled"), blocked: lines(str("content-type-block-list")).map(value => value.toLowerCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
|
||||
@@ -253,6 +253,7 @@ pub struct CompiledFrontend {
|
||||
pub backend_timing_header: bool,
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub content_type_policy: crate::vhost::ContentTypePolicy,
|
||||
pub basic_auth: Option<crate::basic_auth::Compiled>,
|
||||
pub metrics: Arc<crate::metrics::VhostMetrics>,
|
||||
pub server_header: crate::settings::ServerHeader,
|
||||
|
||||
+51
-43
@@ -123,15 +123,19 @@ fn compile_routing(
|
||||
// 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
|
||||
|| host.backends.iter().any(|member| member.health_check.as_ref()
|
||||
.is_some_and(|health| health.mode == crate::balance::HealthMode::Http
|
||||
&& health.host.is_none())))
|
||||
|| 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
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|name| !name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err())
|
||||
.find(|name| {
|
||||
!name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err()
|
||||
})
|
||||
.cloned();
|
||||
}
|
||||
let mut pool = crate::balance::Pool::new(
|
||||
@@ -173,15 +177,17 @@ fn compile_routing(
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
||||
.transpose()?;
|
||||
let profile_policies: Vec<crate::acl::ProfileAccess> = crate::acl::profile_ids(
|
||||
host.acl_profile_id.as_deref(), &host.acl_profile_ids,
|
||||
).map(|id| {
|
||||
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?;
|
||||
Ok(crate::acl::ProfileAccess { acl, countries })
|
||||
}).collect::<Result<_>>()?;
|
||||
let (profile_acl, profile_country_access) = profile_policies.first()
|
||||
let profile_policies: Vec<crate::acl::ProfileAccess> =
|
||||
crate::acl::profile_ids(host.acl_profile_id.as_deref(), &host.acl_profile_ids)
|
||||
.map(|id| {
|
||||
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?;
|
||||
Ok(crate::acl::ProfileAccess { acl, countries })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let (profile_acl, profile_country_access) = profile_policies
|
||||
.first()
|
||||
.map(|entry| (entry.acl.clone(), entry.countries.clone()))
|
||||
.unwrap_or_else(|| (None, crate::rate_limit::CountryAccess::default()));
|
||||
let frontend = Arc::new(CompiledFrontend {
|
||||
@@ -205,6 +211,7 @@ fn compile_routing(
|
||||
backend_timing_header: host.backend_timing_header,
|
||||
rate_limit: host.rate_limit.clone(),
|
||||
method_policy: host.method_policy.clone(),
|
||||
content_type_policy: host.content_type_policy.clone(),
|
||||
basic_auth: host.basic_auth.compile()?,
|
||||
server_header: host
|
||||
.server_header
|
||||
@@ -360,41 +367,42 @@ fn compile_routing(
|
||||
}
|
||||
}
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) = if route.response.is_some() || route.redirect.is_some() {
|
||||
// Local return/redirect is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
let (source, route_pool, backend) =
|
||||
if route.response.is_some() || route.redirect.is_some() {
|
||||
// Local return/redirect is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} Reverse Proxy source has no backend pool",
|
||||
host.id,
|
||||
route.id
|
||||
)
|
||||
})?;
|
||||
let backend = if let Some(id) = route.backend_id.as_deref() {
|
||||
pool.members
|
||||
.iter()
|
||||
.find(|member| member.definition.id == id)
|
||||
.map(|member| member.backend.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
|
||||
} else {
|
||||
pool.members[0].backend.clone()
|
||||
};
|
||||
(
|
||||
CompiledSource::ReverseProxy,
|
||||
Some(pool.clone()),
|
||||
Some(backend),
|
||||
)
|
||||
})?;
|
||||
let backend = if let Some(id) = route.backend_id.as_deref() {
|
||||
pool.members
|
||||
.iter()
|
||||
.find(|member| member.definition.id == id)
|
||||
.map(|member| member.backend.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
|
||||
} else {
|
||||
pool.members[0].backend.clone()
|
||||
};
|
||||
(
|
||||
CompiledSource::ReverseProxy,
|
||||
Some(pool.clone()),
|
||||
Some(backend),
|
||||
)
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
};
|
||||
};
|
||||
let compiled = Arc::new(CompiledRoute {
|
||||
backend_id: route.backend_id.clone(),
|
||||
bindings: frontend.bindings.clone(),
|
||||
|
||||
+39
-15
@@ -88,10 +88,7 @@ async fn forward_request(
|
||||
.get::<Arc<crate::config::Snapshot>>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let internal_dispatch = request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.cloned();
|
||||
let internal_dispatch = request.extensions().get::<InternalFallbackRoute>().cloned();
|
||||
// Retain the original URI's shared backing bytes independently of `request`.
|
||||
// A normalized, already-safe path can then be borrowed for the entire
|
||||
// forwarding operation, even after the request URI/headers are rewritten.
|
||||
@@ -115,6 +112,14 @@ async fn forward_request(
|
||||
// rerunning the observation middleware.
|
||||
snapshot.frontend(&host, local.0, tls)
|
||||
};
|
||||
// Host-level denylist: before cache, custom locations, redirects, local
|
||||
// Web Server/PHP-FPM, or any origin connection. No request body is parsed.
|
||||
if frontend.is_some_and(|item| item.content_type_policy.blocks(request.headers())) {
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: request Content-Type denied by vhost policy\n",
|
||||
);
|
||||
}
|
||||
if snapshot.user_agent_blocking.blocks(request.headers())
|
||||
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
|
||||
{
|
||||
@@ -181,9 +186,9 @@ async fn forward_request(
|
||||
return response;
|
||||
}
|
||||
if !tls {
|
||||
if let Some(response) = frontend.and_then(|frontend| {
|
||||
force_ssl_redirect(frontend, &host, request.uri())
|
||||
}) {
|
||||
if let Some(response) =
|
||||
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -196,7 +201,9 @@ async fn forward_request(
|
||||
&original_host,
|
||||
request.uri(),
|
||||
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
|
||||
request.extensions().get::<Arc<crate::forwarding::PeerFormatting>>()
|
||||
request
|
||||
.extensions()
|
||||
.get::<Arc<crate::forwarding::PeerFormatting>>()
|
||||
.map(Arc::as_ref),
|
||||
host_already_validated,
|
||||
) {
|
||||
@@ -237,7 +244,10 @@ async fn forward_request(
|
||||
let mut country: Option<String> = None;
|
||||
if let Some(frontend) = frontend {
|
||||
if capture_country
|
||||
|| frontend.profile_policies.iter().any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend
|
||||
.profile_policies
|
||||
.iter()
|
||||
.any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend.rate_limit.needs_geoip()
|
||||
{
|
||||
country = cloudflare_country
|
||||
@@ -271,7 +281,10 @@ async fn forward_request(
|
||||
&frontend.id,
|
||||
context.client_ip,
|
||||
country.as_deref(),
|
||||
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()),
|
||||
request
|
||||
.headers()
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
&frontend.rate_limit,
|
||||
) {
|
||||
let mut response = error(
|
||||
@@ -295,7 +308,17 @@ async fn forward_request(
|
||||
);
|
||||
}
|
||||
if cache_http_invalidation_method(request.method()) {
|
||||
return cache_http_invalidation(&state, &snapshot, frontend, &host, request, context.client_ip, peer.0.ip(), context.scheme).await;
|
||||
return cache_http_invalidation(
|
||||
&state,
|
||||
&snapshot,
|
||||
frontend,
|
||||
&host,
|
||||
request,
|
||||
context.client_ip,
|
||||
peer.0.ip(),
|
||||
context.scheme,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some((_redirect, rule)) = redirect {
|
||||
let authority = original_host.to_str().unwrap_or(&host);
|
||||
@@ -434,10 +457,11 @@ async fn forward_request(
|
||||
)
|
||||
});
|
||||
};
|
||||
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let fallback_body =
|
||||
match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let (
|
||||
backend,
|
||||
backend_lease,
|
||||
|
||||
@@ -86,6 +86,45 @@ mod cache_fast_path_tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let mut config = web_config(&directory, true);
|
||||
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
|
||||
enabled: true,
|
||||
blocked: vec!["multipart/form-data".into(), "image/*".into()],
|
||||
};
|
||||
let fixture = Fixture::new(config).await;
|
||||
// Warm the cache before testing a header-denied cache hit.
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data; boundary=example")]).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "IMAGE/PNG")]).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.send(Method::POST, "/style.css", &[("content-type", "multipart/form-data; boundary=x")], Body::from("sample")).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "text/plain")]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "application/json, multipart/form-data")]).await.status, StatusCode::FORBIDDEN);
|
||||
fixture.change(|config| config.vhosts[0].content_type_policy.enabled = false).await;
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data")]).await.status, StatusCode::OK);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_type_policy_denies_before_reverse_proxy_upstream_request() {
|
||||
let origin = origin().await;
|
||||
let mut config = config_for(origin.addr, false);
|
||||
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
|
||||
enabled: true, blocked: vec!["multipart/form-data".into()],
|
||||
};
|
||||
let fixture = Fixture::new(config).await;
|
||||
let blocked = fixture.send(Method::POST, "/", &[("content-type", "Multipart/Form-Data; boundary=abc")], Body::from("--abc\r\n")).await;
|
||||
assert_eq!(blocked.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 0);
|
||||
let allowed = fixture.get("/", &[("content-type", "application/json")]).await;
|
||||
assert_eq!(allowed.status, StatusCode::OK);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
|
||||
@@ -150,6 +150,7 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
|
||||
view.diagnostic_headers = None;
|
||||
view.rate_limit = Default::default();
|
||||
view.method_policy = Default::default();
|
||||
view.content_type_policy = Default::default();
|
||||
view.basic_auth = Default::default();
|
||||
view.tinyauth = Default::default();
|
||||
view.acl = None;
|
||||
|
||||
@@ -91,6 +91,109 @@ impl MethodPolicy {
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
/// Per-vhost request Content-Type filtering. This inspects only headers and
|
||||
/// deliberately never reads the body before deciding whether to reject it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ContentTypePolicy {
|
||||
pub enabled: bool,
|
||||
/// Exact media types (`multipart/form-data`) or type families (`image/*`).
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
/// MIME media type tokens use the HTTP tchar alphabet, excluding `*` here;
|
||||
/// `type/*` is the only accepted wildcard form in configured patterns.
|
||||
fn mime_token(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 127
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'+-.^_`|~".contains(&byte))
|
||||
}
|
||||
|
||||
impl ContentTypePolicy {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(
|
||||
self.blocked.len() <= 64,
|
||||
"Content-Type policy allows at most 64 blocked media types"
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for value in &mut self.blocked {
|
||||
*value = value.trim().to_ascii_lowercase();
|
||||
let Some((kind, subtype)) = value.split_once('/') else {
|
||||
anyhow::bail!("invalid blocked Content-Type: expected type/subtype");
|
||||
};
|
||||
ensure!(
|
||||
mime_token(kind)
|
||||
&& (subtype == "*" || mime_token(subtype))
|
||||
&& value.len() <= 255,
|
||||
"invalid blocked Content-Type: use type/subtype or type/* without parameters"
|
||||
);
|
||||
ensure!(
|
||||
seen.insert(value.clone()),
|
||||
"duplicate blocked Content-Type in policy"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
!self.enabled || !self.blocked.is_empty(),
|
||||
"enabled Content-Type policy requires at least one blocked media type"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject matching media types before body parsing, upstream routing, and cache lookup.
|
||||
/// Multiple or malformed Content-Type values are ambiguous, so reject them
|
||||
/// when filtering is enabled rather than let the backend interpret them differently.
|
||||
/// An absent Content-Type header is not matched (the policy is not a body WAF).
|
||||
pub fn blocks(&self, headers: &http::HeaderMap) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
let mut values = headers.get_all(http::header::CONTENT_TYPE).iter();
|
||||
let Some(raw) = values.next() else {
|
||||
return false;
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return true;
|
||||
}
|
||||
let Ok(raw) = raw.to_str() else {
|
||||
return true;
|
||||
};
|
||||
// A comma outside a quoted parameter indicates a combined/ambiguous
|
||||
// header. RFC MIME parameters may legitimately contain quoted commas.
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for byte in raw.bytes() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if quoted && byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
quoted = !quoted;
|
||||
} else if byte == b',' && !quoted {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if quoted || escaped {
|
||||
return true;
|
||||
}
|
||||
let media_type = raw.split(';').next().unwrap_or("").trim();
|
||||
let Some((kind, subtype)) = media_type.split_once('/') else {
|
||||
return true;
|
||||
};
|
||||
if !mime_token(kind) || !mime_token(subtype) {
|
||||
return true;
|
||||
}
|
||||
self.blocked.iter().any(|blocked| {
|
||||
if let Some(prefix) = blocked.strip_suffix("/*") {
|
||||
kind.eq_ignore_ascii_case(prefix)
|
||||
} else {
|
||||
media_type.eq_ignore_ascii_case(blocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -248,6 +351,9 @@ pub struct Vhost {
|
||||
/// Optional allow/block policy for HTTP methods. Vhost and location policies stack.
|
||||
#[serde(default)]
|
||||
pub method_policy: MethodPolicy,
|
||||
/// Optional request Content-Type denylist (normal vhosts only in the panel).
|
||||
#[serde(default)]
|
||||
pub content_type_policy: ContentTypePolicy,
|
||||
#[serde(default)]
|
||||
pub basic_auth: crate::basic_auth::Settings,
|
||||
pub id: String,
|
||||
|
||||
@@ -82,6 +82,58 @@ mod default_listener_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_policy_defaults_off_and_normalizes_exact_and_wildcard_types() {
|
||||
let old: ContentTypePolicy = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(!old.enabled);
|
||||
assert!(!old.blocks(&http::HeaderMap::new()));
|
||||
|
||||
let mut policy: ContentTypePolicy = serde_json::from_value(json!({
|
||||
"enabled": true, "blocked": [" Multipart/Form-Data ", "IMAGE/*", "application/problem+json"]
|
||||
})).unwrap();
|
||||
policy.validate().unwrap();
|
||||
assert_eq!(policy.blocked, vec!["multipart/form-data", "image/*", "application/problem+json"]);
|
||||
let header = |value: &'static str| {
|
||||
let mut map = http::HeaderMap::new();
|
||||
map.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static(value));
|
||||
map
|
||||
};
|
||||
assert!(policy.blocks(&header("MULTIPART/FORM-DATA; boundary=abc123")));
|
||||
assert!(policy.blocks(&header("multipart/form-data;charset=utf-8")));
|
||||
assert!(policy.blocks(&header("multipart/form-data; boundary=\"a,b\"")));
|
||||
assert!(policy.blocks(&header("image/png")));
|
||||
assert!(policy.blocks(&header("application/problem+json")));
|
||||
assert!(!policy.blocks(&header("application/json; charset=utf-8")));
|
||||
assert!(!policy.blocks(&header("application/json; profile=\"a,b\"")));
|
||||
assert!(policy.blocks(&header("application/json; profile=unquoted,second")));
|
||||
assert!(policy.blocks(&header("application/json; profile=\"unterminated")));
|
||||
assert!(!policy.blocks(&header("text/plain")));
|
||||
assert!(!policy.blocks(&http::HeaderMap::new()));
|
||||
let mut double = header("application/json");
|
||||
double.append(http::header::CONTENT_TYPE, http::HeaderValue::from_static("multipart/form-data"));
|
||||
assert!(policy.blocks(&double));
|
||||
assert!(policy.blocks(&header("application/json, multipart/form-data")));
|
||||
assert!(policy.blocks(&header("application/json / text")));
|
||||
|
||||
policy.enabled = false;
|
||||
assert!(!policy.blocks(&header("multipart/form-data")));
|
||||
assert!(!policy.blocks(&double));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_policy_rejects_invalid_entries_and_empty_enabled_policy() {
|
||||
for entry in ["multipart", "*/form-data", "image/p*", "text/plain; charset=utf-8", "a/", "a//b", "image /png"] {
|
||||
let mut policy = ContentTypePolicy { enabled: true, blocked: vec![entry.into()] };
|
||||
assert!(policy.validate().is_err(), "accepted invalid pattern {entry}");
|
||||
}
|
||||
let mut empty = ContentTypePolicy { enabled: true, blocked: vec![] };
|
||||
assert!(empty.validate().is_err());
|
||||
let mut duplicate = ContentTypePolicy { enabled: true, blocked: vec!["TEXT/PLAIN".into(), " text/plain ".into()] };
|
||||
assert!(duplicate.validate().is_err());
|
||||
let mut many = ContentTypePolicy { enabled: true, blocked: vec!["text/plain".into(); 65] };
|
||||
assert!(many.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_policy_normalizes_and_enforces_allow_and_block_modes() {
|
||||
let mut allow: MethodPolicy = serde_json::from_value(json!({
|
||||
|
||||
@@ -99,6 +99,7 @@ impl Vhost {
|
||||
"certificate mismatch override requires an explicitly selected certificate"
|
||||
);
|
||||
self.method_policy.validate()?;
|
||||
self.content_type_policy.validate()?;
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty(),
|
||||
"vhost requires at least one named listener"
|
||||
|
||||
Reference in new issue
Block a user