438 lines
43 KiB
JavaScript
438 lines
43 KiB
JavaScript
"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 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 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");
|
|
clearInvalidOriginRecovery();
|
|
if (epoch !== authEpoch) return;
|
|
if (!authenticated) { await activate(info); return; }
|
|
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
|
|
} catch (error) { if (recoverInvalidOrigin(error)) return; 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);
|
|
}
|