Files
RustProxy/src/admin/web/app/core.js
T
2026-11-08 11:16:41 +01:00

784 lines
67 KiB
JavaScript

"use strict";
const $ = (id) => document.getElementById(id);
let language = null, languagePreference = null;
try { languagePreference = localStorage.getItem("rp-language"); language = languagePreference; } catch { }
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
const ORIGIN_RECOVERY_KEY = "rp-origin-recovery-v1";
function recoverInvalidOrigin(error) {
if (error?.code !== "auth.invalid_origin" || typeof location === "undefined") return false;
const marker = `${location.origin}${location.pathname}${location.search}`;
try {
if (sessionStorage.getItem(ORIGIN_RECOVERY_KEY) === marker) return false;
sessionStorage.setItem(ORIGIN_RECOVERY_KEY, marker);
} catch {
if (recoverInvalidOrigin.attempted) return false;
recoverInvalidOrigin.attempted = true;
}
location.reload();
return true;
}
function clearInvalidOriginRecovery() {
try { sessionStorage.removeItem(ORIGIN_RECOVERY_KEY); } catch { recoverInvalidOrigin.attempted = false; }
}
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", filelogs: "/logs", 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, configStorage = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
let lastApiLatencyMs = null, sessionCheckPromise = null;
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
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;
const automatic = el("option", t("languageAutomatic")); automatic.value = "auto"; automatic.dataset.i18n = "languageAutomatic";
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
$("language").value = languagePreference === "auto" ? "auto" : language;
}
let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
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)));
window.rustProxyAclProfiles?.refreshTranslations?.();
$("language").value = languagePreference === "auto" ? "auto" : language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
window.rustProxyDrafts?.refresh();
window.rustProxyPreferences?.refreshAppearanceUi?.();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
window.rustProxyIcons?.refresh?.();
}
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;
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", "geoip": "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", "global-security": "settings" })[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/file-logs")) return "filelogs";
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";
const previousPanel = currentPanel; if (name !== "overview" && previousPanel === "overview") window.rustProxyInlineLayout?.cancel?.();
currentPanel = name;
$("overview-refresh").hidden = name !== "overview";
$("overview-samples-toolbar").hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview" || (id === "overview-layout-unlock" && !!window.rustProxyInlineLayout?.isEditing?.());
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 === "web-server" && latestStatus) window.rustProxyWebServer?.renderLive?.(latestStatus, false);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
window.rustProxyRefresh?.setPanel(name);
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus) { window.rustProxyStreamDashboard?.render?.(latestStatus, null, false); window.rustProxyBackendDashboard?.render?.(latestStatus, null, false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); window.rustProxyWebOverview?.render?.(latestStatus, false); }
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
}
function bytes(value) { if (value < 1024) return `${Math.round(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 appHealthChecks(status) {
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
if (status.listeners?.restart_required) {
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
} else {
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
}
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
const down = members.filter(member => member.status === "down");
const checking = members.filter(member => member.status === "checking");
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
if (unavailablePools.length) {
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
checks.push({ level: "error", label: t("appHealthBackends"), detail });
} else if (down.length || checking.length) {
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
} else {
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
}
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
if (loggingDropped || loggingErrors) {
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
} else {
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
}
return checks;
}
function appHealthLevel(checks) {
if (!checks.length) return "checking";
if (checks.some(check => check.level === "error")) return "error";
if (checks.some(check => check.level === "warning")) return "warning";
return "ok";
}
function setAppHealthClass(node, level) {
if (!node) return;
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
node.classList.add(`is-${level}`);
}
function renderAppHealth(status = latestStatus) {
const checks = appHealthChecks(status), level = appHealthLevel(checks);
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
const updated = $("app-health-updated");
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
const list = $("app-health-checks");
if (list) list.replaceChildren(...checks.map(check => {
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
}));
}
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
const label = input?.closest("label"); if (!label) return () => { };
label.classList.add("vh-enable-control", "vh-wide");
let hint = label.querySelector(".vh-enable-hint");
if (!hint) { hint = el("small", undefined, "vh-enable-hint"); label.append(hint); }
hint.dataset.i18n = hintKey; hint.textContent = t(hintKey);
let state = label.querySelector(".vh-enable-state");
if (!state) { state = el("strong", undefined, "vh-enable-state"); label.append(state); }
const sync = () => {
const key = input.checked ? "vhEnabled" : "vhDisabled";
label.classList.toggle("is-disabled", !input.checked); label.classList.toggle("is-enabled", input.checked);
state.dataset.i18n = key; state.textContent = t(key);
};
input.addEventListener("change", sync); sync(); return sync;
}
window.rustProxyEnableControl = enhanceEnabledControl;
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
function redrawOverviewHistoryCharts() {
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));
window.rustProxyChartScale.axes("connection-chart", connectionPeak, "count", connectionOpenSamples.length,
{ top: 10 / 100, bottom: 95 / 100 });
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));
window.rustProxyChartScale.axes("traffic-chart", peak, "rate", samples.length,
{ top: 15 / 130, bottom: 120 / 130 });
for (const key of ["h1", "h2", "h3"]) {
$("http-protocol-" + key).setAttribute("points", sparklinePoints(protocolSamples[key], 300, 60, 5));
$("ws-protocol-" + key).setAttribute("points", sparklinePoints(websocketProtocolSamples[key], 300, 60, 5));
}
const httpValues = Object.values(protocolSamples), websocketValues = Object.values(websocketProtocolSamples);
window.rustProxyChartScale.axes("http-protocol-chart", Math.max(1, ...httpValues.flat()), "rate",
protocolSamples.h1.length, { seriesScales: Object.entries(protocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
window.rustProxyChartScale.axes("ws-protocol-chart", Math.max(1, ...websocketValues.flat()), "count",
websocketProtocolSamples.h1.length, { seriesScales: Object.entries(websocketProtocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
}
window.rustProxyOverviewSamples.onChange(() => {
for (const history of [samples, transformSamples, connectionOpenSamples, connectionExchangeSamples,
...Object.values(protocolSamples), ...Object.values(websocketProtocolSamples)]) {
window.rustProxyOverviewSamples.trim(history);
}
redrawOverviewHistoryCharts();
});
// Safari has no CSS scroll anchoring: keep the element under the viewport centre in place
// when a live refresh changes the height of content above it.
const nativeScrollAnchoring = typeof CSS !== "undefined" && CSS.supports?.("overflow-anchor", "auto");
function preserveScrollAnchor(render) {
if (nativeScrollAnchoring || window.scrollY <= 0) return render();
const probe = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
const anchor = probe?.closest?.("article, tr, .mini-live-chart, .network-interface-row") || probe;
const before = anchor?.isConnected ? anchor.getBoundingClientRect().top : null;
const result = render();
if (before !== null && anchor.isConnected) {
const shift = anchor.getBoundingClientRect().top - before;
if (Math.abs(shift) >= 1) window.scrollBy(0, shift);
}
return result;
}
function renderStatus(s, systemSample = false) { return preserveScrollAnchor(() => renderStatusNow(s, systemSample)); }
function renderStatusNow(s, systemSample = false) {
const firstStatus = latestStatus === null;
latestStatus = {
...(latestStatus || {}), ...s,
streams: s.streams ? { ...(latestStatus?.streams || {}), ...s.streams } : latestStatus?.streams,
listeners: s.listeners ? { ...(latestStatus?.listeners || {}), ...s.listeners } : latestStatus?.listeners,
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
};
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); 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(-window.rustProxyOverviewSamples.getLimit());
connectionExchangeSamples.push(activeExchanges); connectionExchangeSamples = connectionExchangeSamples.slice(-window.rustProxyOverviewSamples.getLimit());
$("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 keys = ["total", "h1", "h2", "h3", "transform"]; const rateSample = rustProxyRateSampling.difference(previous, keys.map(key => counters[key]), now, rustProxyRateSampling.cadence("overview_seconds"));
const pausedRate = previous !== null && rateSample.deltas === null; previous = rateSample.next;
const rates = Object.fromEntries(keys.map((key, i) => [key, rateSample.deltas ? rateSample.deltas[i] / rateSample.seconds : 0]));
if (!pausedRate) {
samples.push(rates.total); samples = samples.slice(-window.rustProxyOverviewSamples.getLimit());
for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-window.rustProxyOverviewSamples.getLimit()); }
}
websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-window.rustProxyOverviewSamples.getLimit());
websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-window.rustProxyOverviewSamples.getLimit());
websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-window.rustProxyOverviewSamples.getLimit());
if (!pausedRate) { transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
redrawOverviewHistoryCharts();
const rateText = key => pausedRate ? "-" : `${rates[key].toFixed(1)}/s`;
$("request-rate").textContent = pausedRate ? "- req/s" : `${rates.total.toFixed(1)} req/s`;
$("rate-total").textContent = rateText("total"); $("rate-h1").textContent = rateText("h1"); $("rate-h2").textContent = rateText("h2"); $("rate-h3").textContent = rateText("h3"); $("rate-transform").textContent = rateText("transform");
$("metric-http-proto-total").textContent = rateText("total"); $("metric-http-proto-h1").textContent = rateText("h1");
$("metric-http-proto-h2").textContent = rateText("h2"); $("metric-http-proto-h3").textContent = rateText("h3");
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());
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyDocker?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s); window.rustProxyWebOverview?.render(s); window.rustProxyWebServer?.renderLive?.(s);
}
function renderFooter(s) {
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
const footer = $("footer-status");
footer.replaceChildren(document.createTextNode(apiLatency));
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) return;
if (force) return rustProxyRefresh.manual("status-full");
return rustProxyRefresh.manual("status-fallback");
}
function renderLiveStatus(value) { renderStatus(value); }
function stopLive() {
if (liveEvents) { liveEvents.close(); liveEvents = null; }
liveConnected = false; liveFallback = false; liveInterval = null;
}
function startLive() {
if (!authenticated || document.hidden || typeof EventSource === "undefined") return;
const seconds = window.rustProxyRefresh?.current().overview_seconds || 3;
if (liveEvents && liveInterval === seconds) return;
stopLive();
liveInterval = seconds;
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`);
liveEvents = stream;
stream.addEventListener("snapshot", event => {
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
try {
const payload = JSON.parse(event.data);
if (!payload.status) throw new Error("Invalid SSE payload");
liveConnected = true; liveFallback = false;
renderLiveStatus(payload.status);
if (payload.logs) renderLogs(payload.logs);
window.rustProxyRefresh?.sync();
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
});
stream.onerror = () => {
if (epoch !== authEpoch || stream !== liveEvents) return;
liveConnected = false; liveFallback = true;
window.rustProxyRefresh?.sync();
};
}
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();
}
}
function renderConfigStorage() {
const mode = $("config-storage-mode"), location = $("config-storage-location");
const description = $("config-storage-description"), history = $("config-storage-history");
if (!configStorage) {
mode.textContent = t("configStorageLoading");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
if (configStorage.error) {
mode.textContent = t("configStorageUnavailable");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
const file = configStorage.mode === "file";
mode.textContent = t(file ? "configStorageFile" : "configStorageSqlite");
const source = file ? configStorage.config_path : configStorage.database_path;
location.textContent = source ? `${t("configStoragePath")}: ${source}` : "";
description.textContent = t(file ? "configStorageFileHelp" : "configStorageSqliteHelp");
history.textContent = file && configStorage.database_path
? `${t("configStorageHistory")}: ${configStorage.database_path}` : "";
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; configStorage = value.storage || { error: true }; renderConfigStorage(); 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") + formatDateTime(sessionInfo.expires_at * 1000) : ""; }
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) {
window.rustProxyInlineLayout?.cancel?.();
window.rustProxyRefresh?.stop();
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; $("logs-menu").hidden = true; $("app-health-slot").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(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-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(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { 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 = ""); });
appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) {
pendingAuthDestination = null; authMessage("");
for (const id of ["api-docs", "logs-menu", "logs-open", "file-logs-open"]) $(id).hidden = false;
const epoch = ++authEpoch;
sessionInfo = info; csrf = info.csrf_token; authenticated = true;
for (const id of ["password", "password-confirm", "setup-token"]) $(id).value = "";
$("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false;
renderSession();
document.documentElement.classList.remove("app-loading");
document.documentElement.classList.remove("app-startup-failed");
rustProxyRefresh.start();
const preferences = await loadAdminPreferences();
if (!authenticated || epoch !== authEpoch) return false;
window.rustProxyLogs?.applyPreferences(preferences);
window.rustProxyFileLogs?.applyPreferences(preferences);
window.rustProxyVhosts?.applyPreferences(preferences);
window.rustProxyDocker?.applyPreferences(preferences);
window.rustProxyRedirects?.applyPreferences(preferences);
window.rustProxyNotFound?.applyPreferences(preferences);
window.rustProxyStreams?.applyPreferences(preferences);
for (let attempt = 0; attempt < 3 && authenticated && epoch === authEpoch; attempt++) {
try { await loadConfig(); break; }
catch (error) {
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
if (attempt === 2) { showStartupFailure(error); return false; }
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 400));
}
}
if (!authenticated || epoch !== authEpoch) return false;
if (window.rustProxySettings?.current?.() === "security") {
void window.rustProxyApiTokens?.refresh?.();
window.rustProxySettings?.scrollToApiTokens?.();
}
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
return true;
}
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 [formatDateTime(item.time * 1000), 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); } }
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
let tunnelsLoading = false;
async function loadTunnels(context = null) {
if (!authenticated || tunnelsLoading) return;
tunnelsLoading = true;
const epoch = authEpoch;
try {
const data = await api("/api/tunnels", context?.signal ? { signal: context.signal } : {});
if (!authenticated || epoch !== authEpoch || !$("tunnels-dialog")?.open || (context?.isCurrent && !context.isCurrent())) 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 (error?.name !== "AbortError" && authenticated && epoch === authEpoch && $("tunnels-dialog")?.open) tunnelMessage(error.message, true); }
finally { tunnelsLoading = false; }
}
function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
$("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); } });
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-close").addEventListener("click", () => $("about-dialog").close());
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
function setMobileMenu(open) {
if (!mobileMenuToggle || !dashboardSidebar) return;
dashboardSidebar.classList.toggle("mobile-nav-open", open);
mobileMenuToggle.setAttribute("aria-expanded", String(open));
}
mobileMenuToggle?.addEventListener("click", event => { event.stopPropagation(); setMobileMenu(!dashboardSidebar.classList.contains("mobile-nav-open")); });
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => { setPanel(button.dataset.panel); setMobileMenu(false); }));
document.querySelector('.brand[href="/"]')?.addEventListener("click", event => {
if (!authenticated || event.defaultPrevented || event.button !== 0 ||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
setPanel("overview");
setMobileMenu(false);
});
document.addEventListener("click", event => { if (dashboardSidebar?.classList.contains("mobile-nav-open") && !dashboardSidebar.contains(event.target)) setMobileMenu(false); });
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(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); } });
// Global HTTP controls are a single atomic API section. A header-only save preserves
// the current block rules; a block-only save preserves the current security headers.
const globalLines = id => [...new Set($(id).value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean))];
const globalPatternLines = id => [...new Set($(id).value.split(/\r?\n/).map(v => v.trim()).filter(Boolean))];
function globalSecurityPayload(security, ua) {
return { ...security, user_agent_blocking: ua };
}
$("global-security-form")?.addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const previous = envelope.config.global_security || {}, rate = previous.rate_limit || {};
const countryMode = $("global-country-mode").value;
const countries = globalLines("global-country-list").map(v => v.toUpperCase());
if (countryMode !== "off" && !countries.length) throw fieldError("global-country-list", t("globalCountriesRequired"));
if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError("global-country-list", t("globalCountriesInvalid"));
const methodMode = $("global-method-mode").value;
const methods = [...new Set($("global-method-list").value.split(/[\s,]+/).map(v => v.trim().toUpperCase()).filter(Boolean))];
if (methodMode !== "off" && !methods.length) throw fieldError("global-method-list", t("globalMethodsRequired"));
const contentTypes = globalLines("global-content-type-list").map(v => v.toLowerCase());
if ($("global-content-type-enabled").checked && !contentTypes.length) throw fieldError("global-content-type-list", t("globalContentTypesRequired"));
const acl = $("global-ip-enabled").checked ? { default: $("global-ip-default").value, rules: parseRules($("global-ip-rules").value, $("global-ip-rules")) } : null;
const ua = { enabled: $("global-ua-block-enabled").checked, patterns: globalPatternLines("global-ua-block-patterns") };
const security = {
...previous,
country_access: { mode: countryMode, countries },
acl,
method_policy: { mode: methodMode, methods },
content_type_policy: { enabled: $("global-content-type-enabled").checked, blocked: contentTypes },
rate_limit: { ...rate, enabled: $("global-rate-enabled").checked,
requests: Number($("global-rate-requests").value),
window_seconds: Number($("global-rate-window").value),
burst: Number($("global-rate-burst").value) },
};
if (await saveSection("global-security", globalSecurityPayload(security, ua))) {
setSettingsDirty("global-security-form", false); renderSettings();
}
});
} catch (e) { toast(e.message, true); }
});
$("global-security-headers-form")?.addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const previous = envelope.config.global_security || {};
const optional = id => $(id).value.trim() || null;
const security = { ...previous, security_headers: {
hsts: { enabled: $("global-hsts-enabled").checked,
max_age_seconds: Number($("global-hsts-age").value),
include_subdomains: $("global-hsts-enabled").checked && $("global-hsts-sub").checked,
preload: $("global-hsts-enabled").checked && $("global-hsts-preload").checked },
nosniff: $("global-nosniff").checked, frame_options: $("global-frame").value,
referrer_policy: optional("global-referrer"),
content_security_policy: optional("global-csp"),
permissions_policy: optional("global-permissions")
}};
if (await saveSection("global-security", globalSecurityPayload(security, previous.user_agent_blocking || { enabled: false, patterns: [] }))) {
setSettingsDirty("global-security-headers-form", false); renderSettings();
}
});
} 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 rustProxyRefresh.manual("tunnels"); startTunnelRefresh(); });
$("tunnels-close").addEventListener("click", () => { $("tunnels-dialog").close(); stopTunnelRefresh(); });
$("tunnels-reload").addEventListener("click", () => void rustProxyRefresh.manual("tunnels"));
$("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); });
// Auto-generated identifiers: a new entry's ID follows its name (or first host) as a unique slug until the user edits the ID by hand.
const rustProxyAutoId = (() => {
const FOLD = { "ł": "l", "đ": "d", "ø": "o", "ß": "ss", "æ": "ae", "œ": "oe", "þ": "th", "ð": "d" };
const states = new WeakMap();
function slugify(value, max = 64) {
let slug = String(value ?? "").trim().toLowerCase().replace(/^\*\./, "").replace(/[łđøßæœþð]/g, ch => FOLD[ch]).normalize("NFKD").replace(/[̀-ͯ]/g, "");
slug = slug.replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-_]+/, "").slice(0, max).replace(/[-_]+$/, "");
return slug;
}
function unique(base, taken) {
if (!base) return "";
const used = new Set([...taken].map(value => String(value).toLowerCase()));
if (!used.has(base)) return base;
for (let n = 2; n < 10000; n++) { const suffix = `-${n}`, candidate = base.slice(0, 64 - suffix.length).replace(/[-_]+$/, "") + suffix; if (!used.has(candidate)) return candidate; }
return base;
}
const locked = input => input.readOnly || input.disabled;
function suggestion(state) { return unique(slugify(state.text()), state.taken()); }
// The ID is shown as a plain value with a "Change" link; the input only appears after the user asks for it (or when validation needs it).
function paint(input, state) {
const isLocked = locked(input), wanted = suggestion(state), value = input.value.trim(), compact = !state.open || isLocked;
state.holder.classList.toggle("is-compact", compact);
state.value.textContent = value || t("autoIdPending");
state.value.classList.toggle("is-pending", !value);
state.badge.hidden = !state.auto || !value; state.badge.textContent = t("autoIdBadge");
state.change.hidden = isLocked; state.change.textContent = t("autoIdChange");
state.row.hidden = !compact;
state.hint.hidden = compact || !state.auto; state.hint.textContent = t("autoIdHint");
state.reset.hidden = compact || state.auto || state.resettable === false || !wanted || wanted === value; state.reset.textContent = t("autoIdReset");
}
function regenerate(input, state) { if (!state.auto || locked(input)) return; input.value = suggestion(state); input.setCustomValidity?.(""); paint(input, state); }
// options: { elements: inputs whose changes drive the ID, text: () => source string, taken: () => existing IDs }
function attach(input, options) {
let state = states.get(input);
if (!state) {
state = { auto: true, open: false, holder: input.closest("label") || input.parentElement, hint: document.createElement("small"), reset: document.createElement("button"), row: document.createElement("div"), value: document.createElement("code"), badge: document.createElement("span"), change: document.createElement("button") };
state.holder.classList.add("auto-id-field");
state.hint.className = "fine auto-id-hint"; state.reset.type = "button"; state.reset.className = "quiet auto-id-reset";
state.row.className = "auto-id-row"; state.value.className = "auto-id-value"; state.badge.className = "auto-id-badge"; state.change.type = "button"; state.change.className = "quiet auto-id-change";
state.row.append(state.value, state.badge, state.change); state.holder.append(state.row, state.hint, state.reset);
input.addEventListener("input", event => { if (!event.isTrusted) return; state.auto = input.value.trim() === ""; if (state.auto) regenerate(input, state); else paint(input, state); });
input.addEventListener("invalid", () => { if (!state.open) { state.open = true; paint(input, state); } });
input.addEventListener("blur", () => { if (input.value.trim() && input.validity.valid) { state.open = false; paint(input, state); } });
state.change.addEventListener("click", () => { state.open = true; paint(input, state); input.focus(); input.select?.(); });
state.reset.addEventListener("mousedown", event => event.preventDefault());
state.reset.addEventListener("click", () => { state.auto = true; regenerate(input, state); input.dispatchEvent(new Event("input", { bubbles: true })); });
states.set(input, state);
for (const source of options.elements || []) source.addEventListener("input", () => { const current = states.get(input); if (current) regenerate(input, current); });
}
state.text = options.text || (() => (options.elements || []).map(node => node.value).find(value => value.trim()) || "");
state.taken = options.taken || (() => []); state.resettable = options.resettable;
state.auto = !locked(input) && input.value.trim() === ""; state.open = false;
paint(input, state);
return { refresh() { const current = states.get(input); if (current) regenerate(input, current); } };
}
return { slugify, unique, attach };
})();
window.rustProxyAutoId = rustProxyAutoId;
// Automatic certificate choice: for a new entry, pick the stored certificate that covers every host name (exact names beat wildcards); the user can still choose another one.
const rustProxyAutoCertificate = (() => {
const states = new WeakMap();
const covers = (pattern, host) => {
pattern = String(pattern).toLowerCase(); host = String(host).toLowerCase();
if (pattern === host) return true;
if (host.startsWith("*.") || !pattern.startsWith("*.")) return false;
const suffix = pattern.slice(1);
return host.length > suffix.length && host.endsWith(suffix) && !host.slice(0, -suffix.length).includes(".");
};
function suggest(hosts, certificates) {
const names = [...new Set((hosts || []).map(host => String(host).trim().toLowerCase()).filter(Boolean))];
if (!names.length) return "";
let best = "", bestScore = -Infinity;
for (const cert of certificates || []) {
const coverage = certificateInventory.get(cert.id)?.covered_hosts?.length ? certificateInventory.get(cert.id).covered_hosts : (cert.covered_hosts || []);
let score = 0, all = true;
for (const host of names) {
if (coverage.some(pattern => String(pattern).toLowerCase() === host)) score += 2;
else if (coverage.some(pattern => covers(pattern, host))) score += 1;
else { all = false; break; }
}
if (!all) continue;
score -= coverage.length / 1000;
if (score > bestScore) { best = cert.id; bestScore = score; }
}
return best;
}
// options: { hosts: () => host names, certificates: () => certificate objects, auto: boolean, anchor: element after which the hint is placed }
function attach(select, options) {
let state = states.get(select);
if (!state) {
state = { hint: document.createElement("small") };
state.hint.className = "fine auto-cert-hint"; state.hint.hidden = true;
(options.anchor || select).after(state.hint);
select.addEventListener("change", event => { if (!event.isTrusted) return; const current = states.get(select); current.auto = false; current.hint.hidden = true; });
states.set(select, state);
}
state.options = options; state.auto = options.auto !== false && !select.value; state.hint.hidden = true;
const refresh = hosts => {
const current = states.get(select);
if (!current.auto) return;
const list = hosts || current.options.hosts();
const best = suggest(list, current.options.certificates());
if (select.value !== best) { select.value = best; select.dispatchEvent(new Event("change", { bubbles: true })); }
const certificates = current.options.certificates(), splitCoverage = !best && list.length > 1 && list.every(host => certificates.some(cert => suggest([host], [cert])));
current.hint.hidden = !best && !splitCoverage; current.hint.textContent = best ? t("autoCertHint").replace("{id}", best) : (splitCoverage ? t("autoCertSniHint") : "");
};
return { refresh };
}
return { attach, suggest, covers };
})();
window.rustProxyAutoCertificate = rustProxyAutoCertificate;