This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

-1202
View File
File diff suppressed because it is too large. Load diff
+419
View File
@@ -0,0 +1,419 @@
"use strict";
const $ = (id) => document.getElementById(id);
let language = null;
try { language = localStorage.getItem("rp-language"); } catch { }
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
const ADMIN_THEMES = new Set(["dark", "light"]);
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
function applyAdminTheme(value, cache = true) {
const theme = normalizeAdminTheme(value);
document.documentElement.dataset.theme = theme;
const select = $("ui-theme"); if (select) select.value = theme;
if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } }
return theme;
}
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
async function loadAdminPreferences(force = false) {
if (!authenticated) return adminPreferences;
if (adminPreferences && !force) return adminPreferences;
if (adminPreferencesLoading) return adminPreferencesLoading;
const epoch = authEpoch;
adminPreferencesLoading = (async () => {
const value = await api("/api/preferences");
if (!authenticated || epoch !== authEpoch) return adminPreferences;
adminPreferences = { ...value, theme: applyAdminTheme(value?.theme) };
return adminPreferences;
})();
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
}
function updateAdminPreferences(patch) {
const epoch = authEpoch;
const task = adminPreferencesWrite.then(async () => {
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
const current = await loadAdminPreferences();
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
const next = { ...current, ...patch }; next.theme = normalizeAdminTheme(next.theme);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
adminPreferences = { ...saved, theme: applyAdminTheme(saved?.theme) };
return adminPreferences;
});
adminPreferencesWrite = task.catch(() => undefined);
return task;
}
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null;
let lastApiLatencyMs = null;
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
// Language content is discovered by build.rs from langs/*.json, never compiled
// into a two-language switch here. Locale metadata also drives Intl and RTL.
let languagePacks = new Map(), defaultLanguage = null;
function activePack() { return languagePacks.get(language) || languagePacks.get(defaultLanguage); }
function locale() { try { return Intl.getCanonicalLocales(activePack()?.meta.locale || "und")[0]; } catch { return "und"; } }
function t(key) { const pack = activePack()?.translations; const fallback = languagePacks.get(defaultLanguage)?.translations; return (pack && Object.hasOwn(pack, key) ? pack[key] : fallback?.[key]) ?? key; }
async function loadLanguages() {
const bundle = await api("/assets/langs.json");
if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("i18n.invalid_bundle");
languagePacks = new Map(bundle.languages.map(pack => [pack.id, pack])); defaultLanguage = bundle.default;
if (!languagePacks.has(defaultLanguage)) throw new Error("i18n.missing_fallback");
const candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(v => v.replaceAll("_", "-").toLowerCase());
const packs = [...languagePacks.values()];
const selected = candidates.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean);
language = selected?.id || defaultLanguage;
$("language").replaceChildren(...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
$("language").value = language;
}
let latestStatus = null, certificateInventory = new Map();
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
function translate() {
clearTimeout(toastTimer); hideToast();
document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr";
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
$("language").value = language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
function raiseToast() {
const node = $("toast"); if (node.hidden) return;
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
// A popover outside a modal is still inert. Keep it inside the active dialog,
// then lift it to the top layer so its backdrop cannot blur or cover it.
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
if (node.parentElement !== parent) parent.append(node);
if (typeof node.showPopover === "function") node.showPopover();
}
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
function clearFieldError(target) {
if (!target) return; target.classList?.remove("field-invalid"); target.removeAttribute?.("aria-invalid");
}
function clearFormFieldErrors(form) { form?.querySelectorAll(".field-invalid,[aria-invalid=true]").forEach(clearFieldError); }
function showFieldError(error, form = null) {
let target = error?.fieldTarget; if (typeof target === "string") target = $(target);
if (!target || (form && !form.contains(target))) return false;
const focusTarget = target.matches?.("input,select,textarea,button") ? target : target.querySelector?.("input,select,textarea,button");
target.classList?.add("field-invalid"); target.setAttribute?.("aria-invalid", "true");
const clear = () => clearFieldError(target); target.addEventListener?.("input", clear, { once: true }); target.addEventListener?.("change", clear, { once: true });
target.scrollIntoView?.({ block: "center", behavior: "smooth" }); focusTarget?.focus?.({ preventScroll: true });
return true;
}
async function withFormSubmitState(form, submitter, task) {
const button = submitter || form?.querySelector('button[type="submit"]');
const originalText = button?.textContent ?? "", i18nKey = button?.dataset?.i18n || "";
clearFormFieldErrors(form);
if (button) { button.disabled = true; button.classList.add("is-saving"); button.textContent = t("saving"); }
try { return await task(); }
catch (error) { showFieldError(error, form); throw error; }
finally { if (button) { button.disabled = false; button.classList.remove("is-saving"); button.textContent = i18nKey ? t(i18nKey) : originalText; } }
}
function appConfirm(message, options = {}) {
const title = options.title || t("confirmTitle"), confirmLabel = options.confirmLabel || t("continueAction"), cancelLabel = options.cancelLabel || t("cancelAction");
return new Promise(resolve => {
const dialog = el("dialog", undefined, "app-confirm-dialog"); dialog.setAttribute("aria-modal", "true");
const card = el("div", undefined, "card app-confirm-card"), heading = el("div", undefined, "card-heading"), headingText = el("div");
headingText.append(el("span", options.danger ? t("warningLabel") : t("confirmationLabel"), `small-label${options.danger ? " status-error" : ""}`), el("h2", title));
heading.append(headingText); card.append(heading, el("p", String(message ?? ""), "app-confirm-message"));
const actions = el("div", undefined, "form-actions app-confirm-actions"), cancel = el("button", cancelLabel, "quiet"), accept = el("button", confirmLabel, options.danger ? "outline app-confirm-danger" : "primary");
cancel.type = "button"; accept.type = "button"; actions.append(cancel, accept); card.append(actions); dialog.append(card); document.body.append(dialog);
let settled = false;
const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); };
cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true));
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); });
dialog.addEventListener("close", () => finish(false));
dialog.showModal(); cancel.focus();
});
}
function apiArea(path) {
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
if (path === "/api/config") return "configuration";
if (path.startsWith("/api/traffic")) return "traffic";
if (path.startsWith("/api/cache")) return "cache";
if (path.startsWith("/api/tunnels")) return "overview";
if (path.startsWith("/api/transform")) return "transform";
if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls";
if (path.startsWith("/api/certificate-api")) return "settings";
if (path.startsWith("/api/docker")) return "dockerVhosts";
if (path.startsWith("/api/logs")) return "logging";
if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security";
return null;
}
function apiErrorMessage(path, response, value) {
const code = typeof value?.error === "string" ? value.error : "";
const translated = code ? t(code) : "";
const detail = response.status === 409 ? t("stale") : (translated && translated !== code ? translated : (code || `HTTP ${response.status}`));
const area = apiArea(path);
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
}
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
async function api(path, options = {}) {
const started = performance.now(), trackLatency = path.startsWith("/api/");
try {
const response = await fetch(path, { ...options, cache: "no-store", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1", ...(csrf ? { "X-CSRF-Token": csrf } : {}), ...options.headers } });
const text = await response.text(); let value; try { value = JSON.parse(text); } catch { value = { error: text }; }
if (!response.ok) { if (response.status === 401 && authenticated) lock(); const error = new Error(apiErrorMessage(path, response, value)); error.status = response.status; error.code = value.error; throw error; } return value;
} finally {
if (trackLatency) { lastApiLatencyMs = performance.now() - started; if (latestStatus) renderFooter(latestStatus); }
}
}
function setPanel(name, navigate = true) {
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
currentPanel = name;
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
}
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function uptime(value) {
let seconds = Math.max(0, Math.floor(Number(value) || 0));
if (seconds < 60) return `${seconds}s`;
const units = [[365 * 86400, "y"], [86400, "d"], [3600, "h"], [60, "m"]], parts = [];
for (const [size, suffix] of units) {
const count = Math.floor(seconds / size);
if (count) { parts.push(`${count}${suffix}`); seconds %= size; }
if (parts.length === 2) break;
}
return parts.join(" ");
}
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
function renderStatus(s) {
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
$("metric-ws").textContent = m.websocket_active || 0;
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
$("metric-ws-h3").textContent = m.websocket_h3_active || 0;
$("metric-ws-total").textContent = (m.websocket_total || 0).toLocaleString(locale());
$("metric-h2").textContent = m.http2_requests || 0; $("metric-h2-active").textContent = m.http2_active_connections || 0; $("metric-tls-errors").textContent = m.tls_handshake_errors || 0;
$("metric-requests").textContent = m.requests.toLocaleString(locale()); $("metric-active").textContent = m.active; $("metric-bytes").textContent = bytes(m.bytes_in + m.bytes_out); $("metric-bytes-down").textContent = bytes(m.bytes_out); $("metric-bytes-up").textContent = bytes(m.bytes_in); $("metric-errors").textContent = m.errors;
const openConnections = Math.max(0, Number(m.active_connections) || 0), activeExchanges = Math.max(0, Number(m.active) || 0);
connectionOpenSamples.push(openConnections); connectionOpenSamples = connectionOpenSamples.slice(-30);
connectionExchangeSamples.push(activeExchanges); connectionExchangeSamples = connectionExchangeSamples.slice(-30);
const connectionPeak = Math.max(1, ...connectionOpenSamples, ...connectionExchangeSamples);
const connectionPoints = values => values.map((value, index) => `${index * 300 / Math.max(1, values.length - 1)},${95 - value / connectionPeak * 85}`).join(" ");
$("connection-open-sparkline").setAttribute("points", connectionPoints(connectionOpenSamples));
$("connection-exchange-sparkline").setAttribute("points", connectionPoints(connectionExchangeSamples));
$("connection-current").textContent = fmt(openConnections); $("connection-open-value").textContent = fmt(openConnections); $("connection-exchange-value").textContent = fmt(activeExchanges); $("connection-backends").textContent = fmt(s.backends);
const now = performance.now();
const transformed = s.transform_metrics || {};
const counters = { total: Number(m.requests) || 0, h1: Number(m.http1_requests) || 0, h2: Number(m.http2_requests) || 0, h3: Number(m.http3_requests) || 0, transform: (Number(transformed.applied) || 0) + (Number(transformed.cache_hits) || 0) };
const rates = { total: 0, h1: 0, h2: 0, h3: 0, transform: 0 };
if (previous) {
const elapsed = Math.max(.001, (now - previous.time) / 1000);
for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed;
}
previous = { time: now, ...counters };
samples.push(rates.total); samples = samples.slice(-30);
for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); }
websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-30);
websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-30);
websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-30);
transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30);
const peak = Math.max(1, ...samples, ...transformSamples);
const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" ");
$("sparkline").setAttribute("points", points(samples));
$("sparkline-h1").setAttribute("points", points(protocolSamples.h1));
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
$("sparkline-transform").setAttribute("points", points(transformSamples));
$("request-rate").textContent = `${rates.total.toFixed(1)} req/s`;
$("rate-total").textContent = `${rates.total.toFixed(1)}/s`; $("rate-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("rate-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("rate-h3").textContent = `${rates.h3.toFixed(1)}/s`; $("rate-transform").textContent = `${rates.transform.toFixed(1)}/s`;
$("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/s`;
$("metric-http-proto-h1").textContent = `${rates.h1.toFixed(1)}/s`;
$("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`;
$("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`;
$("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5));
$("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 300, 60, 5));
$("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5));
const wsTotalActive = (Number(m.websocket_h1_active) || 0) + (Number(m.websocket_h2_active) || 0) + (Number(m.websocket_h3_active) || 0);
$("metric-ws-proto-total").textContent = wsTotalActive.toLocaleString(locale());
$("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());
$("ws-protocol-h1").setAttribute("points", sparklinePoints(websocketProtocolSamples.h1, 300, 60, 5));
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
}
function renderFooter(s) {
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
const footer = $("footer-status");
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
const update = s.software_update;
if (update?.update_available && update.latest_version) {
footer.append(document.createTextNode(" / "));
const label = `${t("updateAvailable")}: ${update.latest_version}`;
if (update.repository_url) {
const link = el("a", label, "footer-update");
link.href = update.repository_url;
link.target = "_blank";
link.rel = "noopener noreferrer";
footer.append(link);
} else {
footer.append(document.createTextNode(label));
}
}
}
let lastLogs = null;
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } }
function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; }
function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; }
async function refreshCertificateInventory() {
if (!authenticated) return;
const epoch = authEpoch;
try {
const value = await api("/api/certificates");
if (!authenticated || epoch !== authEpoch) return;
certificateInventory = new Map((value.certificates || []).map(cert => [cert.id, cert]));
} catch (e) {
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
}
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
function normalizeAclRuleText(value) {
return String(value ?? "").normalize("NFKC")
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ");
}
function parseRules(text, target = null) {
return normalizeAclRuleText(text).split(/\r?\n/).map((value, index) => ({ line: value.trim(), number: index + 1 })).filter(item => item.line).map(item => {
const match = item.line.match(/^(allow|deny)\s+(\S+)$/i);
if (!match) throw fieldError(target, `${t("ruleSyntaxLine")} ${item.number}: ${item.line}`);
return { action: match[1].toLowerCase(), network: match[2] };
});
}
function renderConfig() {
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyWebServer?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
}
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
async function saveSection(section, value) { return saveRequest(`/api/config/${section}`, { expected_revision: envelope?.revision, value }); }
function syncAuthForm() { $("setup-fields").hidden = !setupRequired; $("confirm-field").hidden = !setupRequired; $("setup-token").required = setupRequired; $("password-confirm").required = setupRequired; $("password").minLength = setupRequired ? 12 : 1; $("password").autocomplete = setupRequired ? "new-password" : "current-password"; $("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); $("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); $("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; }
function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + new Date(sessionInfo.expires_at * 1000).toLocaleString(locale()) : ""; }
function authReturnDestination() {
if (typeof location === "undefined") return "/";
if (location.pathname === "/logging") return "/settings?tab=logging";
const allowed = new Set([...Object.values(PANEL_PATHS), "/configuration"]);
const path = allowed.has(location.pathname) ? location.pathname : "/";
if (path !== "/settings") return path;
const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab");
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
}
function lock(notify = true) {
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
renderSession(); syncAuthForm();
// Replace the authenticated document with the minimal public login document.
// Keep the exact panel/settings tab so re-authentication resumes in place.
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
let tunnelRefreshTimer = null;
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
async function loadTunnels() {
if (!authenticated) return;
const epoch = authEpoch;
try {
const data = await api("/api/tunnels");
if (!authenticated || epoch !== authEpoch) return;
const rows = (data.tunnels || []).map(item => {
const tr = el("tr");
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
const td = el("td", String(value)); td.title = String(value); tr.append(td);
}
const actions = el("td");
const kill = el("button", t("tunnelKill"), "quiet"); kill.type = "button";
kill.addEventListener("click", async () => {
if (!(await appConfirm(t("tunnelKillConfirm"), { danger: true }))) return;
kill.disabled = true; tunnelMessage();
try {
const reason = $("tunnel-kill-reason")?.value.trim() || t("tunnelKillReasonDefault");
await api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`, { method: "POST", body: JSON.stringify({ reason }) });
tunnelMessage(t("tunnelKilled"));
await loadTunnels();
} catch (error) { tunnelMessage(error.message, true); }
finally { kill.disabled = false; }
});
actions.append(kill); tr.append(actions); return tr;
});
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
} catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); }
}
function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; }
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
$("ui-theme").addEventListener("change", async () => {
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
applyAdminTheme(nextTheme); select.disabled = true;
try { await updateAdminPreferences({ theme: nextTheme }); }
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
finally { select.disabled = false; }
});
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel)));
window.addEventListener("popstate", () => setPanel(panelFromLocation(), false));
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
$("reload-audit").addEventListener("click", () => void loadAudit());
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); });
$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); });
$("tunnels-reload").addEventListener("click", () => void loadTunnels());
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
async function revalidateSession() {
const epoch = authEpoch;
try {
const info = await api("/api/auth/session");
if (epoch !== authEpoch) return;
if (!authenticated) { await activate(info); return; }
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
}
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
function showStartupFailure(error) {
// index.html is served only after the server has accepted the browser session.
// A module/configuration error must therefore never masquerade as a logout.
$("login").hidden = true;
$("workspace").hidden = false;
$("logout").hidden = !authenticated;
document.documentElement.classList.remove("app-loading");
toast(error?.message || String(error), true);
}
+381
View File
@@ -0,0 +1,381 @@
(async () => {
try {
// Revalidate the session first. The anonymous document has its own login.js;
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
} catch (e) {
if (e.status === 401) {
location.replace(`${location.pathname}${location.search}`);
return;
}
showStartupFailure(e);
}
})();
function renderCertificates() {
if (!envelope) return;
const settings = envelope.config.tls || {};
const entries = (settings.certificates || []).map(cert => {
const inventory = certificateInventory.get(cert.id);
const coverage = (inventory?.covered_hosts?.length ? inventory.covered_hosts : (cert.covered_hosts || []));
const bindings = cert.hosts || [];
const used = [];
for (const vhost of envelope.config.vhosts || []) if (vhost.certificate_id === cert.id) used.push(`vhost:${vhost.id}`);
for (const redirect of envelope.config.redirects || []) if (redirect.certificate_id === cert.id) used.push(`redirect:${redirect.id}`);
for (const host of envelope.config.not_found_hosts || []) if (host.certificate_id === cert.id) used.push(`404:${host.id}`);
if (envelope.config.administration?.certificate_id === cert.id) used.push(t("administration"));
if (settings.default_certificate === cert.id) used.push(t("defaultLabel"));
const managed = !!cert.issuer, inUse = used.length > 0;
const box = el("div", undefined, "entity certificate-card");
const head = el("div", undefined, "certificate-card-head");
const identity = el("div", undefined, "certificate-card-identity");
identity.append(el("span", cert.id, "entity-title"));
const statePills = el("div", undefined, "vhost-policy-pills certificate-state-pills");
if (managed) statePills.append(el("small", `${t("certificateManaged")} · ${cert.issuer.provider} / ${cert.issuer.environment}`, "pill"));
if (settings.default_certificate === cert.id) statePills.append(el("small", t("defaultLabel"), "pill"));
if (inventory?.expiry_status === "expired") statePills.append(el("small", t("certificateExpired"), "pill status-error"));
else if (inventory?.expiry_status === "warning") statePills.append(el("small", t("certificateExpiresSoon"), "pill status-error"));
if (statePills.children.length) identity.append(statePills);
head.append(identity);
box.append(head);
const coverageBlock = el("div", undefined, "certificate-coverage");
coverageBlock.append(el("span", t("certificateCovers"), "small-label"));
const domainPills = el("div", undefined, "vhost-policy-pills certificate-domain-pills");
if (coverage.length) {
for (const host of coverage) domainPills.append(el("small", host, "pill"));
} else {
domainPills.append(el("small", t("certificateCoverageUnknown"), "certificate-coverage-unknown"));
}
coverageBlock.append(domainPills);
box.append(coverageBlock);
const details = el("details", undefined, "certificate-details");
details.append(el("summary", t("certificateDetails")));
const detailBody = el("div", undefined, "certificate-details-body");
const stamp = value => Number(value) ? new Date(Number(value) * 1000).toLocaleString(locale()) : "-";
detailBody.append(
el("p", `${t("certificateBindings")}: ${bindings.join(", ") || "-"}`, "entity-details"),
el("p", `${t("certificateFiles")}: ${cert.cert_file} / ${cert.key_file}`, "entity-details"),
el("p", `${t("certAssigned")}: ${used.join(", ") || t("certUnassigned")}`, "entity-details"),
el("p", `${t("certificateIssuerName")}: ${inventory?.certificate_issuer || "-"}`, "entity-details"),
el("p", `${t("certificateValidFrom")}: ${stamp(inventory?.not_before_unix)}`, "entity-details"),
el("p", `${t("renewalExpires")}: ${stamp(inventory?.not_after_unix)}`, "entity-details"),
el("p", `${t("certificateDaysRemaining")}: ${inventory?.days_remaining ?? "-"}`, "entity-details"),
el("p", `${t("certificateRenewalStatus")}: ${inventory?.renewal_status || "-"}`, "entity-details"),
el("p", `${t("renewalLast")}: ${stamp(inventory?.last_renewal_attempt)}`, "entity-details"),
el("p", `${t("certificateRenewalError")}: ${inventory?.last_renewal_error ? t(inventory.last_renewal_error) : "-"}`, "entity-details"),
);
details.append(detailBody);
box.append(details);
const buttons = el("div", undefined, "entity-actions");
if (!managed) {
buttons.append(action(t("reloadCertificate"), async () => {
if (busy) return;
try {
busy = true;
const result = await api(`/api/certificates/${encodeURIComponent(cert.id)}/reload`, { method: "POST", body: "{}" });
await refreshCertificateInventory();
renderCertificates();
toast(`${t("reloadCertificate")}: ${result.reloaded ? "OK" : "-"}`);
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); } finally { busy = false; }
}));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
box.append(buttons);
return box;
});
$("certificate-list").replaceChildren(...entries);
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
}
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("reload-certificates").addEventListener("click", async () => {
if (!envelope || busy) return;
try {
busy = true;
const result = await api("/api/certificates/reload", { method: "POST", body: "{}" });
await refreshCertificateInventory();
renderCertificates();
toast(`${t("reloadCertificates")}: ${result.reloaded ? "OK" : "-"}`);
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); }
finally { busy = false; }
});
$("reset-certificate").addEventListener("click", () => $("certificate-form").reset());
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null,
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
const hiddenNetworkInterfaces = (() => {
try {
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
return new Set(Array.isArray(parsed) ? parsed.filter(value => typeof value === "string") : []);
} catch { return new Set(); }
})();
function saveHiddenNetworkInterfaces() {
try { localStorage.setItem("rp-hidden-network-interfaces", JSON.stringify([...hiddenNetworkInterfaces].sort())); } catch { }
}
function syncSettingsDirtyIndicators() {
const dirtyTabs = new Set();
for (const id of settingsDirty) {
const page = $(id)?.closest?.("[data-settings-page]"); if (page?.dataset.settingsPage) dirtyTabs.add(page.dataset.settingsPage);
}
document.querySelectorAll("[data-settings-tab]").forEach(button => {
const dirty = dirtyTabs.has(button.dataset.settingsTab); button.classList.toggle("has-unsaved", dirty);
if (dirty) button.title = t("unsavedChanges"); else button.removeAttribute("title");
});
window.rustProxyIntegrations?.syncHttp2Availability?.();
window.rustProxyIntegrations?.syncHttp3Availability?.();
}
function setSettingsDirty(id, dirty = true) { if (dirty) settingsDirty.add(id); else settingsDirty.delete(id); syncSettingsDirtyIndicators(); }
function clearSettingsDirty() { settingsDirty.clear(); syncSettingsDirtyIndicators(); }
const fmt = (value, digits = 0) => value == null ? t("unavailable") : Number(value).toLocaleString(locale(), { maximumFractionDigits: digits });
const fmtBytes = value => value == null ? t("unavailable") : bytes(value);
function setOptionalMetric(id, supported, text) {
const node = $(id); if (!node) return;
const row = node.closest("div"); if (row) row.hidden = !supported;
if (supported) node.textContent = text;
}
function knownPair(left, right, format, zeroRight = null) {
if (left == null && right == null) return null;
if (left == null) return format(right);
if (right == null) return format(left);
const rightText = right === 0 && zeroRight ? zeroRight : format(right);
return `${format(left)} / ${rightText}`;
}
function occupancyText(used, limit) {
if (used == null) return "-";
const usedNumber = Number(used), limitNumber = Number(limit);
if (!Number.isFinite(limitNumber) || limitNumber <= 0) return fmtBytes(usedNumber);
return `${fmtBytes(usedNumber)} / ${fmtBytes(limitNumber)} · ${fmt(usedNumber / limitNumber * 100, 1)}%`;
}
function sparklinePoints(values, width = 300, height = 60, padding = 5, peakOverride = null) {
const peak = Math.max(1, peakOverride ?? Math.max(0, ...values));
return values.map((value, index) => `${index * width / Math.max(1, values.length - 1)},${height - padding - Math.max(0, value) / peak * (height - padding * 2)}`).join(" ");
}
function svgNode(tag, attrs = {}) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node;
}
function renderNetworkInterfaceCharts(interfaces, appendSample) {
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : [];
const names = new Set(current.map(item => item.name));
for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name);
if (appendSample) {
for (const item of current) {
if (!item?.name) continue;
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
const rx = Number(item.rx_bytes_per_second), tx = Number(item.tx_bytes_per_second);
if (Number.isFinite(rx)) { history.rx.push(rx); history.rx = history.rx.slice(-60); }
if (Number.isFinite(tx)) { history.tx.push(tx); history.tx = history.tx.slice(-60); }
networkInterfaceSamples.set(item.name, history);
}
}
const target = $("network-interface-charts");
if (!target) return;
target.replaceChildren();
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name));
$("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length);
if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; }
for (const item of visible) {
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
const peak = Math.max(1, ...history.rx, ...history.tx);
const row = el("div", undefined, "network-interface-row");
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine");
const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
hide.classList.add("network-interface-hide");
const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide);
head.append(el("b", item.name || "-", "mono"), actions);
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` });
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" }));
row.append(head, svg); target.append(row);
}
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
if (hidden.length) {
const hiddenRow = el("div", undefined, "network-interface-hidden");
hiddenRow.append(el("span", t("hiddenInterfaces"), "fine"));
for (const item of hidden) {
const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
hiddenRow.append(show);
}
target.append(hiddenRow);
} else if (!visible.length) {
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
}
}
function renderDiagnostics(status, appendSample = true) {
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
$("metric-h3").textContent = fmt(m.http3_requests);
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
$("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`;
$("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null;
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
$("process-summary").hidden = d.process_threads == null;
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
const systemCpuSupported = d.cpu_percent != null;
setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : "");
$("system-chart").hidden = !systemCpuSupported;
const sourceSupported = !!d.source && d.source !== "unsupported";
$("system-source").hidden = !sourceSupported;
if (sourceSupported) $("system-source").textContent = t("realSample");
if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
$("memory-meter").hidden = systemMemory == null;
if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value");
const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value));
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds));
$("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled");
$("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / ");
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
$("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`;
$("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`;
const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value));
const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value));
$("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true
? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}`
: t("notEnabled");
$("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`;
if (appendSample) {
if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); }
if (c.transient_bytes != null) { cacheTransientSamples.push(Number(c.transient_bytes)); cacheTransientSamples = cacheTransientSamples.slice(-60); }
if (c.fill_bytes_active != null) { cacheFillSamples.push(Number(c.fill_bytes_active)); cacheFillSamples = cacheFillSamples.slice(-60); }
if (c.cold_queue_bytes != null) { cacheColdQueueSamples.push(Number(c.cold_queue_bytes)); cacheColdQueueSamples = cacheColdQueueSamples.slice(-60); }
const coldIoBytes = (Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0);
if (c.cold_write_bytes_active != null || c.cold_read_bytes_active != null) { cacheColdIoSamples.push(coldIoBytes); cacheColdIoSamples = cacheColdIoSamples.slice(-60); }
if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.slice(-60); }
const currentCounters = [c.hits, c.misses, c.bypasses].map(Number);
if (currentCounters.every(Number.isFinite)) {
const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0];
cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]);
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
lastCacheCounters = currentCounters;
}
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
}
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
$("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-";
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
$("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes);
const transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples)
: Math.max(1, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples);
$("cache-transient-sparkline").setAttribute("points", sparklinePoints(cacheTransientSamples, 300, 60, 5, transientPeak));
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
$("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes);
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
$("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled");
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", d.platform || "");
setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
setOptionalMetric("process-fds", fds != null, fds);
setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads));
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory);
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
const memory = status.memory_accounting || {};
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
setOptionalMetric("process-rss-file", rssFile != null, rssFile);
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
setOptionalMetric("process-data-swap", dataSwap != null, dataSwap);
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
$("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`;
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
$("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes);
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null;
updateCacheMemorySizing();
const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null;
setOptionalMetric("system-network", networkRateSupported, networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "");
setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
if (appendSample) {
if (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) {
networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second));
networkRxSamples = networkRxSamples.slice(-60); networkTxSamples = networkTxSamples.slice(-60);
}
if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-60); }
}
const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak));
$("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "";
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
$("latency-chart-value").textContent = m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`;
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null;
$("system-sample-age").hidden = age == null;
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
const l = status.listeners || {};
$("listener-pending").hidden = !l.restart_required;
$("listener-pending").textContent = t("restartPending");
$("listener-active").textContent = `${t("activeListeners")}: ${l.active_endpoints?.map(e => `${e.tls ? "HTTPS" : "HTTP"} ${e.address}${e.http3 ? " + H3/UDP" : ""}`).join(" | ") || `HTTP ${status.proxy_listen} / HTTPS ${status.tls_listen || t("notEnabled")}`}`;
}
function renderHeaderRules(direction, rules) {
const target = $(`${direction}-header-rules`); target.replaceChildren();
for (const rule of rules) appendHeaderRule(direction, rule, false);
}
function appendHeaderRule(direction, rule = { op: "set", name: "", value: "" }, mark = true) {
const target = $(`${direction}-header-rules`); if (target.children.length >= 32) { toast(t("headerLimit"), true); return; }
const row = el("div", undefined, "header-rule");
const op = el("select"); op.className = "header-op"; op.setAttribute("aria-label", t("headerOperation"));
for (const value of ["set", "append", "remove"]) { const option = el("option", t(`header_${value}`)); option.value = value; op.append(option); } op.value = rule.op;
const name = el("input"); name.className = "header-name"; name.value = rule.name; name.required = true; name.maxLength = 128; name.placeholder = t("headerName"); name.setAttribute("aria-label", t("headerName")); name.autocomplete = "off"; name.spellcheck = false;
const value = el("input"); value.className = "header-value"; value.value = rule.value ?? ""; value.maxLength = 8192; value.placeholder = t("headerValue"); value.setAttribute("aria-label", t("headerValue")); value.autocomplete = "off"; value.spellcheck = false;
function sync() { value.disabled = op.value === "remove"; } sync(); op.addEventListener("change", sync);
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("headers-form"); });
row.append(op, name, value, remove); target.append(row); if (mark) setSettingsDirty("headers-form");
}
function collectHeaderRules(direction) {
return [...$(`${direction}-header-rules`).children].map(row => {
const op = row.querySelector(".header-op").value, name = row.querySelector(".header-name").value.trim(), value = row.querySelector(".header-value").value;
return op === "remove" ? { op, name } : { op, name, value };
});
}
+402
View File
@@ -0,0 +1,402 @@
function renderSettings() {
if (!envelope) return; const config = envelope.config; window.rustProxySettings?.render();
if (!settingsDirty.has("listeners-form")) window.rustProxyIntegrations?.renderListeners();
if (!settingsDirty.has("http2-form")) window.rustProxyIntegrations?.renderHttp2Settings();
if (!settingsDirty.has("http3-form")) window.rustProxyIntegrations?.renderHttp3Settings();
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.1";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
if (!settingsDirty.has("cache-form")) {
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
$("cache-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false;
$("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300;
$("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80;
$("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold";
$("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300;
$("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576;
$("cache-cold-gc-interval").value = c.cold_gc_interval_seconds ?? 60; $("cache-cold-gc-max-delete").value = c.cold_gc_max_delete ?? 256;
$("cache-cold-on-idle").checked = c.cold_on_idle !== false; $("cache-cold-on-age").checked = c.cold_on_age === true; $("cache-cold-on-lru").checked = c.cold_on_lru !== false; $("cache-cold-on-shutdown").checked = c.cold_on_shutdown !== false; $("cache-cold-max-hot").value = c.cold_max_hot_seconds ?? 3600;
$("cache-ttl").value = c.max_ttl_seconds ?? 86400; $("cache-default-ttl").value = c.default_ttl_seconds ?? 0;
$("cache-collapse-enabled").checked = c.collapse_enabled !== false; $("cache-collapse-timeout").value = c.collapse_wait_timeout_ms ?? 1500; $("cache-collapse-fallback").value = c.collapse_timeout_fallback || "origin";
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
syncColdCachePolicy();
syncAdvancedCachePolicy();
updateCacheMemorySizing();
}
if (!settingsDirty.has("transform-form")) {
const tr = config.transform || {};
setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null);
$("transform-enabled").checked = !!tr.enabled;
$("transform-javascript").checked = tr.javascript !== false;
$("transform-css").checked = tr.css !== false;
$("transform-html").checked = !!tr.html;
$("transform-svg").checked = !!tr.svg;
$("transform-json").checked = !!tr.json;
$("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n");
$("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024;
$("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576;
$("transform-max-inflight").value = tr.max_inflight ?? 32;
updateTransformResourceSizing();
}
if (!settingsDirty.has("logging-form")) {
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
$("log-capture-user-agent").checked = l.capture_user_agent !== false;
$("log-capture-country").checked = l.capture_country === true;
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
$("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2);
$("log-format-preset").value = detectLoggingPreset(format.fields || {});
}
if (!settingsDirty.has("error-log-form")) {
const errorLog = config.error_log || {};
$("error-log-enabled").checked = !!errorLog.enabled;
$("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log";
$("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON";
syncErrorLogMode();
}
const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file;
$("logging-status").textContent = sink ? `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}` : "";
$("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.queue_capacity)}` : "";
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
}
function effectiveCacheMemory() {
const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes);
const candidates = [host, cgroup].filter(value => Number.isFinite(value) && value > 0);
if (!candidates.length) return null;
const bytes = Math.min(...candidates);
return { bytes, cgroupLimited: Number.isFinite(cgroup) && cgroup > 0 && (!Number.isFinite(host) || cgroup < host) };
}
function cacheMemoryHeadroom() {
const d = latestStatus?.system || {};
const hostTotal = Number(d.memory_total_bytes), hostUsed = Number(d.memory_used_bytes);
const cgroupMax = Number(d.cgroup_memory_max_bytes), cgroupCurrent = Number(d.cgroup_memory_current_bytes);
const candidates = [];
if (Number.isFinite(hostTotal) && hostTotal > 0 && Number.isFinite(hostUsed) && hostUsed >= 0) candidates.push(Math.max(0, hostTotal - hostUsed));
if (Number.isFinite(cgroupMax) && cgroupMax > 0 && Number.isFinite(cgroupCurrent) && cgroupCurrent >= 0) candidates.push(Math.max(0, cgroupMax - cgroupCurrent));
return candidates.length ? Math.min(...candidates) : null;
}
function cachePresetValues(kind, bytes) {
const totalMiB = bytes / 1048576, profile = {
conservative: { ratio: .125, objectMiB: 8, eviction: 32 },
recommended: { ratio: .20, objectMiB: 16, eviction: 64 },
performance: { ratio: .30, objectMiB: 32, eviction: 128 }
}[kind] || { ratio: .20, objectMiB: 16, eviction: 64 };
const round16 = value => Math.max(16, Math.floor(value / 16) * 16);
const hot = Math.max(64, Math.min(round16(totalMiB * profile.ratio), round16(totalMiB * .5)));
const transient = Math.max(16, round16(hot * .25));
const queue = Math.max(8, Math.floor((transient * .5) / 8) * 8);
const objectMiB = Math.max(1, Math.min(profile.objectMiB, Math.floor(hot / 16)));
const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16)));
return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction };
}
function cacheMemoryPresetName(kind) {
return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom");
}
function setCacheMemoryPresetSelection(kind) {
selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null;
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => {
const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("cache-memory-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`;
}
function setCacheColdPresetSelection(kind) {
selectedCacheColdPreset = kind === "recommended" ? "recommended" : null;
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => {
const active = button.dataset.cacheColdPreset === selectedCacheColdPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("cache-cold-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`;
}
function updateCacheMemorySizing() {
const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning");
if (!badge || !warning) return;
const buttons = document.querySelectorAll("[data-cache-memory-preset]");
if (!detected) {
badge.textContent = t("cacheMemoryNotDetected"); buttons.forEach(button => { button.disabled = true; });
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetUnavailable"); warning.hidden = true; return;
}
if (detected.bytes < 256 * 1048576) {
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
buttons.forEach(button => { button.disabled = true; });
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetTooSmall");
warning.hidden = false; warning.textContent = t("cacheMemoryPresetTooSmall"); return;
}
buttons.forEach(button => { button.disabled = false; });
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
const recommended = cachePresetValues("recommended", detected.bytes);
$("cache-memory-preset-summary").textContent = `${t("cacheMemoryRecommendedFor")} HOT ${recommended.hot} MiB · ${t("cachePresetTransientShort")} ${recommended.transient} MiB · ${t("cachePresetObjectShort")} ${recommended.objectKiB / 1024} MiB · ${t("cachePresetQueueShort")} ${recommended.queue} MiB`;
const hot = Number($("cache-memory")?.value) * 1048576, transient = Number($("cache-transient-memory")?.value) * 1048576;
const queue = Number($("cache-cold-queue-bytes")?.value) * 1048576;
const object = Number($("cache-object")?.value) * 1024;
const high = Number.isFinite(hot + transient) && hot + transient > detected.bytes * .35;
const invalidQueue = Number.isFinite(queue) && Number.isFinite(transient) && queue > transient;
const objectOverTransient = Number.isFinite(object) && Number.isFinite(transient) && object > transient;
const headroom = cacheMemoryHeadroom();
const currentCache = (Number(latestStatus?.cache?.hot_allocated_bytes ?? latestStatus?.cache?.resident_bytes) || 0) + (Number(latestStatus?.cache?.transient_bytes) || 0);
const additionalBudget = Math.max(0, hot + transient - currentCache);
const lowHeadroom = headroom != null && Number.isFinite(additionalBudget) && additionalBudget > headroom * .75;
warning.hidden = !(high || invalidQueue || objectOverTransient || lowHeadroom);
if (invalidQueue) warning.textContent = t("cacheQueueOverTransientWarning");
else if (objectOverTransient) warning.textContent = t("cacheObjectOverTransientWarning");
else if (lowHeadroom) warning.textContent = `${t("cacheMemoryHeadroomWarning")} ${fmtBytes(additionalBudget)} / ${fmtBytes(headroom)}.`;
else if (high) warning.textContent = `${t("cacheMemoryHighWarning")} ${fmtBytes(hot + transient)} / ${fmtBytes(detected.bytes)}.`;
}
function applyCacheMemoryPreset(kind) {
const detected = effectiveCacheMemory(); if (!detected || detected.bytes < 256 * 1048576) return;
const preset = cachePresetValues(kind, detected.bytes);
$("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient;
$("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries;
$("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue;
setCacheMemoryPresetSelection(kind);
setSettingsDirty("cache-form"); updateCacheMemorySizing();
}
function applyRecommendedColdCachePreset() {
const transient = Number($("cache-transient-memory").value);
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
$("cache-cold-enabled").checked = true;
if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold";
$("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false;
$("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true;
$("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600;
$("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256;
$("cache-cold-queue-bytes").value = Math.min(queue, transient || queue);
setCacheColdPresetSelection("recommended");
syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing();
}
function transformPresetValues(kind) {
return {
conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 },
recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 },
performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 },
uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 }
}[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 };
}
function transformPresetName(kind) {
return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom");
}
function setTransformPresetSelection(kind) {
selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null;
document.querySelectorAll("[data-transform-preset]").forEach(button => {
const active = button.dataset.transformPreset === selectedTransformPreset;
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("transform-preset-selected");
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`;
}
function updateTransformResourceSizing() {
const warning = $("transform-memory-warning"); if (!warning) return;
const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value);
const detected = effectiveCacheMemory();
const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024;
const worstEncodedEstimate = bodyBytes * 3;
const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate;
const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5;
warning.hidden = !(budgetCannotFitMax || highHostShare);
if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning");
else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`;
const summary = $("transform-preset-summary");
if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`;
}
function applyTransformPreset(kind) {
const preset = transformPresetValues(kind);
$("transform-memory-budget").value = preset.memoryMiB;
$("transform-max-body").value = preset.bodyKiB;
$("transform-max-inflight").value = preset.inflight;
setTransformPresetSelection(kind);
setSettingsDirty("transform-form"); updateTransformResourceSizing();
}
function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; }
function syncLoggingFileMode() {
const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked;
$("log-file-per-vhost").disabled = !enabled;
$("log-file-path").disabled = !enabled || perVhost;
$("log-file-directory").disabled = !enabled || !perVhost;
}
function syncErrorLogMode() {
const disabled = !$("error-log-enabled").checked;
$("error-log-path").disabled = disabled;
$("error-log-format").disabled = disabled;
}
function syncColdCachePolicy() {
const enabled = $("cache-cold-enabled").checked;
for (const id of ["cache-cold-path", "cache-cold-bytes", "cache-cold-entries", "cache-cold-queue-bytes", "cache-cold-gc-interval", "cache-cold-gc-max-delete", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown"]) $(id).disabled = !enabled;
$("cache-cold-after").disabled = !enabled || !$("cache-cold-on-idle").checked;
$("cache-cold-max-hot").disabled = !enabled || !$("cache-cold-on-age").checked;
}
function syncAdvancedCachePolicy() {
const collapse = $("cache-collapse-enabled").checked;
$("cache-collapse-timeout").disabled = !collapse;
$("cache-collapse-fallback").disabled = !collapse;
const proactive = $("cache-proactive-enabled").checked;
for (const id of ["cache-proactive-before", "cache-proactive-percent", "cache-proactive-inflight"]) $(id).disabled = !proactive;
}
function bindSettingsForm(id, apply, section, selectValue = next => next[section]) {
const form = $(id); for (const event of ["input", "change"]) form.addEventListener(event, () => setSettingsDirty(id));
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const next = structuredClone(envelope.config); apply(next);
if (await saveSection(section, selectValue(next))) { setSettingsDirty(id, false); renderSettings(); }
});
} catch (error) { toast(error.message, true); }
});
}
bindSettingsForm("listeners-form", next => { next.listeners = window.rustProxyIntegrations.collectListeners(); }, "listeners");
bindSettingsForm("http2-form", next => {
if (settingsDirty.has("listeners-form")) throw new Error(t("http2ListenersDirty"));
next.listeners = window.rustProxyIntegrations.collectHttp2Listeners();
}, "listeners", next => next.listeners);
bindSettingsForm("http3-form", next => {
if (settingsDirty.has("listeners-form")) throw new Error(t("http3ListenersDirty"));
next.listeners = window.rustProxyIntegrations.collectHttp3Listeners();
}, "listeners", next => next.listeners);
bindSettingsForm("response-form", next => {
next.server_header = { mode: $("server-mode").value, value: $("server-value").value };
next.diagnostic_headers = { request_id: $("expose-request-id").checked };
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
enabled: $("transform-enabled").checked,
preset: selectedTransformPreset,
javascript: $("transform-javascript").checked,
css: $("transform-css").checked,
html: $("transform-html").checked,
svg: $("transform-svg").checked,
json: $("transform-json").checked,
extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean),
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024),
memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576),
max_inflight: Number($("transform-max-inflight").value)
};
}, "transform");
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_PRESETS = {
standard: {},
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
};
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
function selectedLoggingMapping() {
const rows = [...$("logging-field-rows").querySelectorAll("tr")], mapping = {};
for (const row of rows) {
const enabled = row.querySelector("input[type=checkbox]").checked;
const source = row.dataset.source;
const output = row.querySelector("input[type=text]").value.trim();
if (enabled) {
if (!output) throw new Error(t("loggingOutputRequired"));
if (Object.hasOwn(mapping, output)) throw new Error(t("loggingOutputDuplicate"));
mapping[output] = source;
}
}
if (Object.keys(mapping).length === LOGGING_SOURCE_FIELDS.length && LOGGING_SOURCE_FIELDS.every(name => mapping[name] === name)) return {};
return mapping;
}
function renderLoggingPreview() {
let mapping;
try { mapping = selectedLoggingMapping(); } catch (error) { $("logging-format-preview").textContent = error.message; return; }
let staticFields = {}; try { staticFields = parseJsonObjectValue($("log-format-static").value, $("log-format-static")); } catch { }
const source = Object.keys(mapping).length ? mapping : Object.fromEntries(LOGGING_SOURCE_FIELDS.map(name => [name, name]));
const preview = { ...staticFields };
for (const [output, name] of Object.entries(source)) preview[output] = LOGGING_PREVIEW_VALUES[name];
$("logging-format-preview").textContent = JSON.stringify(preview, null, 2);
}
function openLoggingBuilder() {
let mapping; try { mapping = parseJsonObjectValue($("log-format-fields").value, $("log-format-fields")); } catch (error) { toast(error.message, true); return; }
const identity = !Object.keys(mapping).length;
const bySource = new Map();
for (const [output, source] of Object.entries(mapping)) if (!bySource.has(source)) bySource.set(source, output);
const rows = LOGGING_SOURCE_FIELDS.map(source => {
const row = el("tr"); row.dataset.source = source;
const checkCell = el("td"), sourceCell = el("td", source), outputCell = el("td");
const check = document.createElement("input"); check.type = "checkbox"; check.checked = identity || bySource.has(source);
const output = document.createElement("input"); output.type = "text"; output.maxLength = 128; output.value = bySource.get(source) || source; output.disabled = !check.checked;
check.addEventListener("change", () => { output.disabled = !check.checked; renderLoggingPreview(); });
output.addEventListener("input", renderLoggingPreview);
checkCell.append(check); outputCell.append(output); row.append(checkCell, sourceCell, outputCell); return row;
});
$("logging-field-rows").replaceChildren(...rows); renderLoggingPreview();
const dialog = $("logging-format-dialog"); if (!dialog.open) dialog.showModal();
}
function applyLoggingPreset(name) {
if (name === "custom") return;
$("log-format-fields").value = JSON.stringify(LOGGING_PRESETS[name], null, 2);
setSettingsDirty("logging-form");
}
const selectLoggingSection = next => ({ logging: next.logging, error_log: next.error_log });
bindSettingsForm("logging-form", next => {
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
next.logging = {
capture_user_agent: $("log-capture-user-agent").checked,
capture_country: $("log-capture-country").checked,
file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() },
logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() },
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") }
};
}, "logging", selectLoggingSection);
bindSettingsForm("error-log-form", next => {
next.error_log = {
enabled: $("error-log-enabled").checked,
path: $("error-log-path").value.trim(),
format: $("error-log-format").value
};
}, "logging", selectLoggingSection);
bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding");
bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers");
$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value));
$("error-log-enabled").addEventListener("change", syncErrorLogMode);
$("log-format-open").addEventListener("click", openLoggingBuilder);
$("logging-format-close").addEventListener("click", () => $("logging-format-dialog").close());
$("logging-format-cancel").addEventListener("click", () => $("logging-format-dialog").close());
$("logging-format-apply").addEventListener("click", () => { try { const mapping = selectedLoggingMapping(); $("log-format-fields").value = JSON.stringify(mapping, null, 2); $("log-format-preset").value = detectLoggingPreset(mapping); setSettingsDirty("logging-form"); $("logging-format-dialog").close(); } catch (error) { toast(error.message, true); } });
$("server-mode").addEventListener("change", syncServerMode);
$("log-file-enabled").addEventListener("change", syncLoggingFileMode);
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); });
$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); });
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) {
for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null));
}
for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); });
for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy);
syncColdCachePolicy();
syncAdvancedCachePolicy();
$("purge-cache").addEventListener("click", async () => { if (!(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("purge-cache"); button.disabled = true; try { const result = await api("/api/cache/purge", { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } });
$("add-response-header").addEventListener("click", () => appendHeaderRule("response"));
$("add-request-header").addEventListener("click", () => appendHeaderRule("request"));
window.addEventListener("beforeunload", event => { if (settingsDirty.size) { event.preventDefault(); event.returnValue = ""; } });
+44 -28
View File
@@ -967,34 +967,50 @@
<label class="check cache-live-toggle"><input checked="" id="traffic-live" type="checkbox" />
<span data-i18n="cacheLive">Live (3 s)</span></label></div>
</div>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs" role="tablist">
<button aria-selected="true" class="active" data-i18n="trafficTabOverview" data-traffic-tab="overview" role="tab" type="button">Overview</button>
<button aria-selected="false" data-i18n="trafficTabClients" data-traffic-tab="clients" role="tab" type="button">Clients</button>
<button aria-selected="false" data-i18n="trafficTabRequests" data-traffic-tab="requests" role="tab" type="button">Requests</button>
<button aria-selected="false" data-i18n="trafficTabDelivery" data-traffic-tab="delivery" role="tab" type="button">Delivery</button>
</nav>
<div data-traffic-page="overview">
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
</div>
<article class="card">
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
<p class="fine" id="traffic-capture-status"></p>
</article>
<div class="traffic-chart-grid traffic-overview-charts">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
</div>
</div>
<article class="card">
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
<p class="fine" id="traffic-capture-status"></p>
</article>
<div class="traffic-chart-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-user-agents" role="img"><span id="traffic-chart-user-agents-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-user-agents-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-client-ips" role="img"><span id="traffic-chart-client-ips-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-client-ips-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
<div data-traffic-page="clients" hidden>
<div class="traffic-chart-grid traffic-clients-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div><p class="empty" id="traffic-chart-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-bar-chart" id="traffic-chart-user-agents" role="img"></div><p class="empty" id="traffic-chart-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-bar-chart" id="traffic-chart-client-ips" role="img"></div><p class="empty" id="traffic-chart-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
</div>
</div>
<div class="logging-grid">
<article class="card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-countries"></tbody></table></div><p class="empty" id="traffic-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th>User-Agent</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-user-agents"></tbody></table></div><p class="empty" id="traffic-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficClientIp">Client IP</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-client-ips"></tbody></table></div><p class="empty" id="traffic-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-paths"></tbody></table></div><p class="empty" id="traffic-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-routes"></tbody></table></div><p class="empty" id="traffic-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-methods"></tbody></table></div><p class="empty" id="traffic-methods-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-statuses"></tbody></table></div><p class="empty" id="traffic-statuses-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-cache"></tbody></table></div><p class="empty" id="traffic-cache-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<div data-traffic-page="requests" hidden>
<div class="traffic-chart-grid traffic-request-grid">
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="traffic-bar-chart" id="traffic-chart-paths" role="img"></div><p class="empty" id="traffic-chart-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="traffic-bar-chart" id="traffic-chart-routes" role="img"></div><p class="empty" id="traffic-chart-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-methods-detail" role="img"><span id="traffic-chart-methods-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-detail-legend"></ol></div></article>
</div>
</div>
<div data-traffic-page="delivery" hidden>
<div class="traffic-chart-grid traffic-delivery-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-statuses-detail" role="img"><span id="traffic-chart-statuses-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-detail-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-cache-detail" role="img"><span id="traffic-chart-cache-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-detail-legend"></ol></div></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficVolume">Traffic volume</h3><div class="traffic-bar-chart" id="traffic-chart-volume" role="img"></div></article>
</div>
</div>
</section>
<section class="panel" hidden="" id="panel-cache">
@@ -1456,7 +1472,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.0"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.1"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2203,7 +2219,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
+62
View File
@@ -4637,6 +4637,68 @@ dialog .window-heading {
flex: 0 0 auto
}
#panel-traffic .traffic-tabs {
margin: 12px 0 14px
}
#panel-traffic [data-traffic-page] {
min-width: 0
}
.traffic-wide-chart {
grid-column: span 2
}
.traffic-bar-chart {
display: grid;
gap: 8px;
min-width: 0
}
.traffic-bar-row {
display: grid;
grid-template-columns: minmax(120px, 1.5fr) minmax(120px, 3fr) minmax(72px, auto);
gap: 10px;
align-items: center;
min-width: 0
}
.traffic-bar-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 11px var(--mono)
}
.traffic-bar-track {
height: 10px;
border-radius: 999px;
background: var(--panel2);
box-shadow: inset 0 0 0 1px var(--line);
overflow: hidden
}
.traffic-bar-fill {
display: block;
height: 100%;
min-width: 2px;
border-radius: inherit;
background: var(--accent)
}
.traffic-bar-value {
text-align: right;
font: 700 10px var(--mono)
}
@media(max-width:1100px) {
.traffic-wide-chart { grid-column: span 1 }
}
@media(max-width:650px) {
.traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); gap: 7px }
}
.logging-grid label {
min-width: 0
}
+75 -83
View File
@@ -1,135 +1,127 @@
"use strict";
(() => {
let loading = false, timer = null, lastData = null;
let loading = false, timer = null, lastData = null, activeTab = "overview";
const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
function selectTab(name) {
activeTab = name;
document.querySelectorAll("[data-traffic-tab]").forEach(button => {
const active = button.dataset.trafficTab === name;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
document.querySelectorAll("[data-traffic-page]").forEach(page => { page.hidden = page.dataset.trafficPage !== name; });
}
function configure(data) {
const selected = scope(), options = [el("option", t("logAllVhosts"))];
options[0].value = "*";
for (const vhost of data.vhosts || []) {
const option = el("option", `${vhost.id} / ${(vhost.hosts || []).join(", ")}`);
option.value = vhost.id;
options.push(option);
option.value = vhost.id; options.push(option);
}
$("traffic-scope").replaceChildren(...options);
$("traffic-scope").value = options.some(option => option.value === selected) ? selected : "*";
}
function renderTop(id, entries, enabled = true) {
const body = $(`traffic-${id}`), empty = $(`traffic-${id}-empty`);
const values = enabled ? (entries || []) : [];
body.replaceChildren(...values.map(item => {
const row = el("tr");
const value = el("td", item.value || "-"); value.title = item.value || "-";
row.append(value, el("td", number(item.count)));
return row;
}));
empty.hidden = values.length > 0;
function chartEntries(entries, enabled = true) {
return (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
}
function renderDonut(id, entries, enabled = true) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), totalNode = $(`traffic-chart-${id}-total`);
if (!chart || !legend || !totalNode) return;
const values = (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled);
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
totalNode.textContent = number(total);
totalNode.textContent = number(total); if (empty) empty.hidden = total > 0;
if (!total) {
chart.style.background = "var(--panel2)";
chart.setAttribute("aria-label", t("trafficNoData"));
legend.replaceChildren(el("li", t("trafficNoData"), "fine"));
return;
chart.style.background = "var(--panel2)"; chart.setAttribute("aria-label", t("trafficNoData"));
legend.replaceChildren(el("li", t("trafficNoData"), "fine")); return;
}
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
if (values.length > shown.length) {
shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
}
if (values.length > shown.length) shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
let cursor = 0;
const segments = shown.map((item, index) => {
const start = cursor / total * 100;
cursor += item.count;
const end = cursor / total * 100;
chart.style.background = `conic-gradient(${shown.map((item, index) => {
const start = cursor / total * 100; cursor += item.count; const end = cursor / total * 100;
return `${chartPalette[index % chartPalette.length]} ${start.toFixed(4)}% ${end.toFixed(4)}%`;
});
chart.style.background = `conic-gradient(${segments.join(", ")})`;
}).join(", ")})`;
const heading = chart.closest(".traffic-chart-card")?.querySelector("h3")?.textContent || t("trafficDimensionsTitle");
chart.setAttribute("aria-label", `${heading}: ${shown.map(item => `${item.value} ${Math.round(item.count / total * 100)}%`).join(", ")}`);
legend.replaceChildren(...shown.map((item, index) => {
const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch");
swatch.style.background = chartPalette[index % chartPalette.length];
const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-";
label.append(swatch, text);
row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`));
return row;
label.append(swatch, text); row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); return row;
}));
}
function renderBars(id, entries, enabled = true, formatter = number) {
const chart = $(`traffic-chart-${id}`); if (!chart) return;
const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled).slice(0, 12);
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0), peak = Math.max(1, ...values.map(item => Number(item.count) || 0));
if (empty) empty.hidden = values.length > 0;
chart.replaceChildren(...values.map(item => {
const count = Number(item.count) || 0, row = el("div", undefined, "traffic-bar-row");
const label = el("span", item.value || "-", "traffic-bar-label"); label.title = item.value || "-";
const track = el("span", undefined, "traffic-bar-track"), fill = el("i", undefined, "traffic-bar-fill"); fill.style.width = `${Math.max(1.5, count / peak * 100)}%`; track.append(fill);
const value = el("span", formatter(count), "traffic-bar-value"); value.title = total ? `${(count / total * 100).toFixed(1)}%` : "";
row.append(label, track, value); return row;
}));
chart.setAttribute("aria-label", values.length ? values.map(item => `${item.value}: ${formatter(Number(item.count) || 0)}`).join(", ") : t("trafficNoData"));
}
function render(data) {
lastData = data;
configure(data);
lastData = data; configure(data);
const analytics = data.analytics || {}, stats = analytics.data || {}, settings = data.settings || {};
$("traffic-requests").textContent = number(stats.requests);
$("traffic-errors").textContent = number(stats.errors);
$("traffic-requests").textContent = number(stats.requests); $("traffic-errors").textContent = number(stats.errors);
const requests = Number(stats.requests) || 0, errors = Number(stats.errors) || 0;
$("traffic-error-ratio").textContent = `${requests ? (errors / requests * 100).toFixed(2) : "0.00"}%`;
$("traffic-response-bytes").textContent = fmtBytes(Number(stats.response_bytes) || 0);
$("traffic-request-bytes").textContent = `${t("trafficRequestBytes")}: ${fmtBytes(Number(stats.request_bytes) || 0)}`;
$("traffic-latency").textContent = stats.mean_duration_ms == null ? "-" : `${fmt(stats.mean_duration_ms, 2)} ms`;
$("traffic-dropped").textContent = `${t("trafficDroppedSamples")}: ${number(analytics.queue_dropped)}`;
const capture = [];
capture.push(settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled"));
const capture = [settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled")];
capture.push(settings.capture_country ? (data.geoip?.loaded ? t("trafficCountryEnabled") : t("trafficCountryNoDb")) : t("trafficCountryDisabled"));
$("traffic-capture-status").textContent = capture.join(" · ");
renderTop("countries", stats.countries, !!settings.capture_country);
renderTop("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderTop("client-ips", stats.client_ips);
renderTop("paths", stats.paths);
renderTop("routes", stats.routes);
renderTop("methods", stats.methods);
renderTop("statuses", stats.statuses);
renderTop("cache", stats.cache);
renderDonut("countries", stats.countries, !!settings.capture_country);
renderDonut("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderDonut("client-ips", stats.client_ips);
renderDonut("methods", stats.methods);
renderDonut("statuses", stats.statuses);
renderDonut("cache", stats.cache);
renderBars("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderBars("client-ips", stats.client_ips);
renderBars("paths", stats.paths); renderBars("routes", stats.routes);
renderDonut("methods", stats.methods); renderDonut("methods-detail", stats.methods);
renderDonut("statuses", stats.statuses); renderDonut("statuses-detail", stats.statuses);
renderDonut("cache", stats.cache); renderDonut("cache-detail", stats.cache);
renderBars("volume", [
{ value: t("trafficRequestBytes"), count: Number(stats.request_bytes) || 0 },
{ value: t("trafficResponseBytes"), count: Number(stats.response_bytes) || 0 },
], true, fmtBytes);
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
}
async function refresh() {
if (!authenticated || loading) return;
loading = true;
try {
const selected = scope();
const data = await api(`/api/traffic?vhost=${encodeURIComponent(selected)}`);
render(data);
} catch (error) {
$("traffic-panel-state").textContent = error.message;
} finally { loading = false; }
if (!authenticated || loading) return; loading = true;
try { render(await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`)); }
catch (error) { $("traffic-panel-state").textContent = error.message; }
finally { loading = false; }
}
function reset() {
lastData = null;
if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
for (const id of ["countries", "user-agents", "client-ips", "paths", "routes", "methods", "statuses", "cache"]) {
$(`traffic-${id}`)?.replaceChildren();
}
for (const id of ["countries", "user-agents", "client-ips", "methods", "statuses", "cache"]) {
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
if (chart) chart.style.background = "var(--panel2)";
legend?.replaceChildren();
if (total) total.textContent = "-";
if (chart) chart.style.background = "var(--panel2)"; legend?.replaceChildren(); if (total) total.textContent = "-";
}
selectTab("overview");
}
function startTimer() {
if (timer) return;
timer = setInterval(() => {
if (!authenticated || !$("traffic-live")?.checked || $("panel-traffic")?.hidden) return;
void refresh();
}, 3000);
}
$("traffic-refresh")?.addEventListener("click", () => void refresh());
$("traffic-scope")?.addEventListener("change", () => void refresh());
$("traffic-settings")?.addEventListener("click", () => {
setPanel("settings");
window.rustProxySettings?.select("logging");
});
startTimer();
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData) };
document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab)));
$("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh());
$("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); });
timer = setInterval(() => { if (authenticated && $("traffic-live")?.checked && !$("panel-traffic")?.hidden) void refresh(); }, 3000);
selectTab(activeTab);
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab };
})();
-981
View File
@@ -1,981 +0,0 @@
"use strict";
// Vhost UI: language content comes from langs/*.json, never a locale switch.
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
(() => {
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
const dialog = $("vhost-dialog");
const form = $("vhost-form"), container = $("vhost-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const field = (parent, name, key, value, type = "text", options = {}) => {
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
input.name = name;
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
else if (type !== "textarea") input.type = type;
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
if (type === "textarea") input.spellcheck = false;
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
parent.append(label); return input;
};
const group = (key, advanced = false) => {
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
const str = (name, scope) => read(name, scope).value.trim();
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
const yes = (name, scope) => read(name, scope).checked;
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const nullable = (name, scope) => str(name, scope) || null;
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const fillWebServices = (select, chosen = "") => {
const first = el("option", t("webServiceSelect")); first.value = "";
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
select.value = chosen;
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
return {
enabled: yes("vh-transform-enabled"),
javascript: tri("vh-transform-javascript"),
css: tri("vh-transform-css"),
html: tri("vh-transform-html"),
svg: tri("vh-transform-svg"),
json: tri("vh-transform-json"),
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
};
}
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
function validateHeaders(value) {
const fail = () => { throw new Error(t("headersInvalid")); };
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
for (const direction of ["request", "response"]) {
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
}
} return value;
}
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
const holders = {}, adders = {}, emptyStates = {};
const renumber = direction => {
const rows = holders[direction];
[...rows.children].forEach((row, index) => {
const title = row.querySelector(".vh-header-rule-title");
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
});
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
};
const sync = () => {
const result = {};
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
return op === "remove" ? { op, name } : { op, name, value };
});
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
};
for (const direction of ["request", "response"]) {
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
const heading = el("div", undefined, "location-header-direction-head");
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
block.append(heading);
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
const add = (value = { op: "set", name: "", value: "" }) => {
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
const row = el("article", undefined, "vh-header-row");
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
const fields = el("div", undefined, "vh-header-rule-fields");
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
row.append(fields);
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
};
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
}
target.addEventListener("input", () => {
try {
const value = validateHeaders(JSON.parse(target.value));
for (const direction of ["request", "response"]) {
holders[direction].replaceChildren();
for (const rule of value[direction] || []) adders[direction](rule);
renumber(direction);
}
target.setCustomValidity(""); onChange();
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
});
note(parent, "locationHeadersHelp");
return { read: () => validateHeaders(JSON.parse(target.value)) };
}
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
function administrationListenCandidates() {
return [
envelope?.config.administration?.listen,
latestStatus?.administration?.locked_listen,
latestStatus?.administration?.desired?.listen,
latestStatus?.administration?.active?.listen,
latestStatus?.admin_listen,
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
}
function adminListenerBackendCandidate(raw) {
const splitSocket = value => {
const textValue = String(value || "").trim();
if (!textValue) return null;
if (textValue.startsWith("[")) {
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
}
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
};
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
if (!adminListeners.length) return null;
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
return adminListeners.some(admin => {
if (port !== admin.port) return false;
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
});
}
function memberRow(parent, value = { id: `origin-${parent.children.length + 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 }) {
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
const controls = {};
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
note(riskGate, "vhLocalListenerRiskHelp");
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
actions.append(button("vhDebugOriginPreset", async () => {
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
controls["member-pool"].checked = true; controls["member-private"].checked = true;
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
}));
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
const syncRisk = (clearWhenHidden = false) => {
const show = adminListenerBackendCandidate(url.value);
riskGate.hidden = show !== true;
if (clearWhenHidden && show === false) localRisk.checked = false;
};
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
row.syncLocalRisk = () => syncRisk(false);
syncIdentity(); syncRisk(false); parent.append(row);
}
function countryRateEditor(parent, values = []) {
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
const add = (value = {}) => {
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
const row = el("div", undefined, "vh-row vh-grid");
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
};
for (const value of values) add(value);
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
return () => [...rows.children].map(row => ({
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
}));
}
function debugOriginIssues(members) {
return members.filter(member => {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
});
}
function aclEditor(parent, initial, onChange = () => { }) {
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
}
function openLocationHelp(topic = "locations") {
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
if (!dialog || !title || !body) return;
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
const section = (headingKey, keys, cls = "") => {
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
if (headingKey) box.append(text("h3", headingKey));
for (const key of keys) box.append(el("p", t(key)));
return box;
};
if (topic === "headers") {
body.replaceChildren(
el("p", t("headersDocsIntro"), "location-doc-intro"),
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
);
} else {
body.replaceChildren(
el("p", t("locationDocsIntro"), "location-doc-intro"),
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
);
}
dialog.showModal();
}
function locationsEditor(parent, values) {
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
acl: r.readAcl(), headers: r.readHeaders(),
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
};
});
}
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
function add(value = {}) {
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
const row = el("details", undefined, "vh-location"); row.open = true;
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
const preview = el("section", undefined, "vh-wide location-preview");
preview.append(text("h4", "locationPreviewTitle"));
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
}
preview.append(previewGrid); body.append(preview);
const actions = el("div", undefined, "vh-wide location-actions");
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
if (isReturn) {
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else {
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
const replacement = read("location-rewrite-value", row).value.trim();
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
}
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
try {
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
} catch { previewValues.headers.textContent = t("headersInvalid"); }
};
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
rows.append(row); syncUi();
}
// The main vhost backend is an internal fallback, not a Custom location.
// An empty location list must stay empty so the editor cannot accidentally
// delete or recreate the main route as user configuration.
for (const value of values) add(value);
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
sync(); return readRows;
}
function basicAuthEditor(parent, settings = {}) {
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
function add(user = {}) {
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = []) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
const values = [];
const normalize = raw => {
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
};
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(g, "vhWebsocketTransportHelp");
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
note(g, "listenerDefaultPortsHint");
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
note(g, "vhForceSslHelp");
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
note(g, "vhForwardClientIpHelp");
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
const syncFrontendTransports = () => {
const capabilities = bindingControl.capabilities();
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
if (!capabilities.http2) h2Enabled.checked = false;
if (!capabilities.http3) h3Enabled.checked = false;
const h2Available = capabilities.http2 && h2Enabled.checked;
const h3Available = capabilities.http3 && h3Enabled.checked;
websocketMode.disabled = !websocketEnabled.checked;
websocketLegacy.disabled = !websocketEnabled.checked;
const optionH2 = websocketMode.querySelector('option[value="h2"]');
const optionH3 = websocketMode.querySelector('option[value="h3"]');
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
optionH2.disabled = !h2Available;
optionH3.disabled = !h3Available;
optionH2H3.disabled = !h2Available || !h3Available;
const selected = websocketMode.value;
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
}
};
websocketEnabled.addEventListener("change", syncFrontendTransports);
bindingControl.onChange(syncFrontendTransports);
h2Enabled.addEventListener("change", syncFrontendTransports);
h3Enabled.addEventListener("change", syncFrontendTransports);
syncFrontendTransports();
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
g = group("vhBackendDiagnostics");
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
note(g, "vhBackendDiagnosticsHelp");
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
g = group("vhProxyTimeouts");
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
note(g, "vhProxyTimeoutsHelp");
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(g, "vhRetryHelp");
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
note(g, "vhPassiveHelp");
const passiveFields = [
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
];
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
const passive5xxWarning = note(g, "vhPassive5xxWarning");
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
const syncPassive = () => {
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
};
passiveEnabled.addEventListener("change", syncPassive);
passive5xx.addEventListener("change", syncPassive);
passiveSlow.addEventListener("change", syncPassive);
syncPassive();
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {};
g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
g = group("vhSsl");
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
aclBody.append(text("h3", "vhIpAccess"));
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
g = group("vhCache"); const cache = value.cache || {};
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
cacheOptions.hidden = !enabled;
};
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
g = group("vhTransform"); const transform = value.transform || { enabled: false };
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncTransform = () => {
transformOptions.hidden = !transformEnabled.checked;
transformExtensions.disabled = !customTransformExtensions.checked;
};
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
note(transformOptions, "vhTransformHelp");
g = group("vhHeaders"); headerEditors(g, value.headers || {});
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
const bindingSelection = readBindings();
const mainSourceType = str("source-type");
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
if (!endpoint || endpoint.enabled === false) continue;
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
tlsSelected = true;
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
}
}
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
}
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
const selectedUsesHttp3 = bindingSelection.some(id => {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
return !!endpoint?.binding?.http3;
});
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
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"));
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
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,
load_balancing: {
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
passive_health: {
enabled: yes("passive-enabled"),
count_connect_errors: yes("passive-connect"),
count_timeouts: yes("passive-timeouts"),
count_io_errors: yes("passive-io"),
count_http_5xx: yes("passive-5xx"),
count_slow_responses: yes("passive-slow"),
fall: str("passive-fall") === "" ? null : num("passive-fall"),
rise: str("passive-rise") === "" ? null : num("passive-rise")
},
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
},
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
};
}
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
const haystack = [
v.id,
...(v.frontend?.hosts || []),
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
v.source?.web_service_id,
...(v.routes || []).map(route => route.source?.web_service_id),
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
const cards = visibleVhosts.map(v => {
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
openButton.disabled = !browseHost || v.enabled === false;
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
config.listeners?.endpoints?.find(e => e.id === id) || { id }
);
const listeners = selectedEndpoints.map(endpoint => {
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
});
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
const protocols = [
"HTTP/1.1",
v.http2_enabled !== false && supportsH2 ? "H2" : null,
v.http3_enabled !== false && supportsH3 ? "H3" : null,
].filter(Boolean).join(" + ");
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
const detailItems = [
["vhListeners", listeners.join(" | ") || "-"],
["vhProtocols", protocols],
["tlsPolicy", tlsPolicy],
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
["vhBackendPool", v.source?.type === "web_server"
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
? `${t("webServer")} + ${(v.backends || []).length}`
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (hasProxySource) {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
if (v.websockets) {
const transports = ["H1"];
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
}
if (policy.children.length) box.append(policy);
return box;
});
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
if (latestStatus) renderStatus(latestStatus);
}
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
function backendTrafficRate(poolId, member, snapshot, now, seen) {
const key = `${poolId}\u0000${member.id}`; seen.add(key);
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
const previous = backendTrafficHistory.get(key);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
const rate = reported > 0 ? reported : observed;
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
return rate;
}
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
seen.add(id);
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
const previous = vhostTrafficHistory.get(id);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
return rate;
}
function renderStatus(status) {
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
for (const pool of status.backend_pools || []) {
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
for (const member of members) {
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
}
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
summaries.set(pool.id, summary || t("vhHealthEmpty"));
aggregate.set(pool.id, {
disabled: vhostDisabled,
healthMode: pool.health_mode,
up,
total: members.length,
active,
requests,
errors,
traffic,
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
});
}
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
for (const [id, vhost] of configuredVhosts) {
const existing = aggregate.get(id) || {};
const metrics = status.vhost_metrics?.[id];
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
const disabled = vhost.enabled === false;
aggregate.set(id, {
...existing,
disabled,
staticOnly: !hasProxySource,
healthMode: existing.healthMode,
up: existing.up ?? 0,
total: existing.total ?? 0,
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
for (const [id, data] of aggregate) {
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
history.pendingSum += 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; }
chartHistory.set(id, history);
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
}
const editingVhost = editing ? configuredVhosts.get(editing) : null;
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy || uploading) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const epoch = draftEpoch; uploading = true;
try { await basicEditor.prepare(); } finally { uploading = false; }
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
if (saved) { load(value, [], false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
load(saved ? structuredClone(saved) : defaults());
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
+402
View File
@@ -0,0 +1,402 @@
"use strict";
// Vhost UI: language content comes from langs/*.json, never a locale switch.
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
const dialog = $("vhost-dialog");
const form = $("vhost-form"), container = $("vhost-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const field = (parent, name, key, value, type = "text", options = {}) => {
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
input.name = name;
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
else if (type !== "textarea") input.type = type;
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
if (type === "textarea") input.spellcheck = false;
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
parent.append(label); return input;
};
const group = (key, advanced = false) => {
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
const str = (name, scope) => read(name, scope).value.trim();
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
const yes = (name, scope) => read(name, scope).checked;
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const nullable = (name, scope) => str(name, scope) || null;
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const fillWebServices = (select, chosen = "") => {
const first = el("option", t("webServiceSelect")); first.value = "";
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
select.value = chosen;
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
return {
enabled: yes("vh-transform-enabled"),
javascript: tri("vh-transform-javascript"),
css: tri("vh-transform-css"),
html: tri("vh-transform-html"),
svg: tri("vh-transform-svg"),
json: tri("vh-transform-json"),
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
};
}
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
function validateHeaders(value) {
const fail = () => { throw new Error(t("headersInvalid")); };
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
for (const direction of ["request", "response"]) {
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
}
} return value;
}
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
const holders = {}, adders = {}, emptyStates = {};
const renumber = direction => {
const rows = holders[direction];
[...rows.children].forEach((row, index) => {
const title = row.querySelector(".vh-header-rule-title");
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
});
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
};
const sync = () => {
const result = {};
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
return op === "remove" ? { op, name } : { op, name, value };
});
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
};
for (const direction of ["request", "response"]) {
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
const heading = el("div", undefined, "location-header-direction-head");
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
block.append(heading);
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
const add = (value = { op: "set", name: "", value: "" }) => {
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
const row = el("article", undefined, "vh-header-row");
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
const fields = el("div", undefined, "vh-header-rule-fields");
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
row.append(fields);
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
};
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
}
target.addEventListener("input", () => {
try {
const value = validateHeaders(JSON.parse(target.value));
for (const direction of ["request", "response"]) {
holders[direction].replaceChildren();
for (const rule of value[direction] || []) adders[direction](rule);
renumber(direction);
}
target.setCustomValidity(""); onChange();
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
});
note(parent, "locationHeadersHelp");
return { read: () => validateHeaders(JSON.parse(target.value)) };
}
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
function administrationListenCandidates() {
return [
envelope?.config.administration?.listen,
latestStatus?.administration?.locked_listen,
latestStatus?.administration?.desired?.listen,
latestStatus?.administration?.active?.listen,
latestStatus?.admin_listen,
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
}
function adminListenerBackendCandidate(raw) {
const splitSocket = value => {
const textValue = String(value || "").trim();
if (!textValue) return null;
if (textValue.startsWith("[")) {
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
}
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
};
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
if (!adminListeners.length) return null;
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
return adminListeners.some(admin => {
if (port !== admin.port) return false;
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
});
}
function memberRow(parent, value = { id: `origin-${parent.children.length + 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 }) {
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
const controls = {};
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
note(riskGate, "vhLocalListenerRiskHelp");
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
actions.append(button("vhDebugOriginPreset", async () => {
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
controls["member-pool"].checked = true; controls["member-private"].checked = true;
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
}));
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
const syncRisk = (clearWhenHidden = false) => {
const show = adminListenerBackendCandidate(url.value);
riskGate.hidden = show !== true;
if (clearWhenHidden && show === false) localRisk.checked = false;
};
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
row.syncLocalRisk = () => syncRisk(false);
syncIdentity(); syncRisk(false); parent.append(row);
}
function countryRateEditor(parent, values = []) {
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
const add = (value = {}) => {
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
const row = el("div", undefined, "vh-row vh-grid");
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
};
for (const value of values) add(value);
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
return () => [...rows.children].map(row => ({
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
}));
}
function debugOriginIssues(members) {
return members.filter(member => {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
});
}
function aclEditor(parent, initial, onChange = () => { }) {
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
}
function openLocationHelp(topic = "locations") {
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
if (!dialog || !title || !body) return;
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
const section = (headingKey, keys, cls = "") => {
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
if (headingKey) box.append(text("h3", headingKey));
for (const key of keys) box.append(el("p", t(key)));
return box;
};
if (topic === "headers") {
body.replaceChildren(
el("p", t("headersDocsIntro"), "location-doc-intro"),
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
);
} else {
body.replaceChildren(
el("p", t("locationDocsIntro"), "location-doc-intro"),
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
);
}
dialog.showModal();
}
function locationsEditor(parent, values) {
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
acl: r.readAcl(), headers: r.readHeaders(),
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
};
});
}
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
function add(value = {}) {
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
const row = el("details", undefined, "vh-location"); row.open = true;
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
const preview = el("section", undefined, "vh-wide location-preview");
preview.append(text("h4", "locationPreviewTitle"));
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
}
preview.append(previewGrid); body.append(preview);
const actions = el("div", undefined, "vh-wide location-actions");
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
if (isReturn) {
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else {
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
const replacement = read("location-rewrite-value", row).value.trim();
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
}
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
try {
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
} catch { previewValues.headers.textContent = t("headersInvalid"); }
};
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
rows.append(row); syncUi();
}
// The main vhost backend is an internal fallback, not a Custom location.
// An empty location list must stay empty so the editor cannot accidentally
// delete or recreate the main route as user configuration.
for (const value of values) add(value);
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
sync(); return readRows;
}
+333
View File
@@ -0,0 +1,333 @@
function basicAuthEditor(parent, settings = {}) {
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
function add(user = {}) {
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = []) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
const values = [];
const normalize = raw => {
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
};
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(g, "vhWebsocketTransportHelp");
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
note(g, "listenerDefaultPortsHint");
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
note(g, "vhForceSslHelp");
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
note(g, "vhForwardClientIpHelp");
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
const syncFrontendTransports = () => {
const capabilities = bindingControl.capabilities();
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
if (!capabilities.http2) h2Enabled.checked = false;
if (!capabilities.http3) h3Enabled.checked = false;
const h2Available = capabilities.http2 && h2Enabled.checked;
const h3Available = capabilities.http3 && h3Enabled.checked;
websocketMode.disabled = !websocketEnabled.checked;
websocketLegacy.disabled = !websocketEnabled.checked;
const optionH2 = websocketMode.querySelector('option[value="h2"]');
const optionH3 = websocketMode.querySelector('option[value="h3"]');
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
optionH2.disabled = !h2Available;
optionH3.disabled = !h3Available;
optionH2H3.disabled = !h2Available || !h3Available;
const selected = websocketMode.value;
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
}
};
websocketEnabled.addEventListener("change", syncFrontendTransports);
bindingControl.onChange(syncFrontendTransports);
h2Enabled.addEventListener("change", syncFrontendTransports);
h3Enabled.addEventListener("change", syncFrontendTransports);
syncFrontendTransports();
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
g = group("vhBackendDiagnostics");
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
note(g, "vhBackendDiagnosticsHelp");
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
g = group("vhProxyTimeouts");
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
note(g, "vhProxyTimeoutsHelp");
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(g, "vhRetryHelp");
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
note(g, "vhPassiveHelp");
const passiveFields = [
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
];
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
const passive5xxWarning = note(g, "vhPassive5xxWarning");
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
const syncPassive = () => {
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
};
passiveEnabled.addEventListener("change", syncPassive);
passive5xx.addEventListener("change", syncPassive);
passiveSlow.addEventListener("change", syncPassive);
syncPassive();
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {};
g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
g = group("vhSsl");
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
aclBody.append(text("h3", "vhIpAccess"));
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
g = group("vhCache"); const cache = value.cache || {};
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
cacheOptions.hidden = !enabled;
};
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
g = group("vhTransform"); const transform = value.transform || { enabled: false };
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncTransform = () => {
transformOptions.hidden = !transformEnabled.checked;
transformExtensions.disabled = !customTransformExtensions.checked;
};
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
note(transformOptions, "vhTransformHelp");
g = group("vhHeaders"); headerEditors(g, value.headers || {});
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
const bindingSelection = readBindings();
const mainSourceType = str("source-type");
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
if (!endpoint || endpoint.enabled === false) continue;
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
tlsSelected = true;
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
}
}
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
}
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
const selectedUsesHttp3 = bindingSelection.some(id => {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
return !!endpoint?.binding?.http3;
});
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
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"));
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
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,
load_balancing: {
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
passive_health: {
enabled: yes("passive-enabled"),
count_connect_errors: yes("passive-connect"),
count_timeouts: yes("passive-timeouts"),
count_io_errors: yes("passive-io"),
count_http_5xx: yes("passive-5xx"),
count_slow_responses: yes("passive-slow"),
fall: str("passive-fall") === "" ? null : num("passive-fall"),
rise: str("passive-rise") === "" ? null : num("passive-rise")
},
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
},
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
};
}
+244
View File
@@ -0,0 +1,244 @@
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
const haystack = [
v.id,
...(v.frontend?.hosts || []),
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
v.source?.web_service_id,
...(v.routes || []).map(route => route.source?.web_service_id),
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
const cards = visibleVhosts.map(v => {
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
openButton.disabled = !browseHost || v.enabled === false;
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
config.listeners?.endpoints?.find(e => e.id === id) || { id }
);
const listeners = selectedEndpoints.map(endpoint => {
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
});
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
const protocols = [
"HTTP/1.1",
v.http2_enabled !== false && supportsH2 ? "H2" : null,
v.http3_enabled !== false && supportsH3 ? "H3" : null,
].filter(Boolean).join(" + ");
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
const detailItems = [
["vhListeners", listeners.join(" | ") || "-"],
["vhProtocols", protocols],
["tlsPolicy", tlsPolicy],
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
["vhBackendPool", v.source?.type === "web_server"
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
? `${t("webServer")} + ${(v.backends || []).length}`
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (hasProxySource) {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
if (v.websockets) {
const transports = ["H1"];
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
}
if (policy.children.length) box.append(policy);
return box;
});
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
if (latestStatus) renderStatus(latestStatus);
}
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
function backendTrafficRate(poolId, member, snapshot, now, seen) {
const key = `${poolId}\u0000${member.id}`; seen.add(key);
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
const previous = backendTrafficHistory.get(key);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
const rate = reported > 0 ? reported : observed;
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
return rate;
}
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
seen.add(id);
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
const previous = vhostTrafficHistory.get(id);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
return rate;
}
function renderStatus(status) {
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
for (const pool of status.backend_pools || []) {
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
for (const member of members) {
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
}
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
summaries.set(pool.id, summary || t("vhHealthEmpty"));
aggregate.set(pool.id, {
disabled: vhostDisabled,
healthMode: pool.health_mode,
up,
total: members.length,
active,
requests,
errors,
traffic,
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
});
}
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
for (const [id, vhost] of configuredVhosts) {
const existing = aggregate.get(id) || {};
const metrics = status.vhost_metrics?.[id];
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
const disabled = vhost.enabled === false;
aggregate.set(id, {
...existing,
disabled,
staticOnly: !hasProxySource,
healthMode: existing.healthMode,
up: existing.up ?? 0,
total: existing.total ?? 0,
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
for (const [id, data] of aggregate) {
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
history.pendingSum += 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; }
chartHistory.set(id, history);
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
}
const editingVhost = editing ? configuredVhosts.get(editing) : null;
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy || uploading) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const epoch = draftEpoch; uploading = true;
try { await basicEditor.prepare(); } finally { uploading = false; }
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
if (saved) { load(value, [], false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
load(saved ? structuredClone(saved) : defaults());
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);