"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, 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(); 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(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs); 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; 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", "user-agent-blocking": "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/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"; 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 `${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 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)); 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)); 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)); } $("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(); }); function renderStatus(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"); 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.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} / ${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) return; if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]); 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}`); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return; try { const payload = JSON.parse(event.data); if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload"); liveConnected = true; liveFallback = false; renderLiveStatus(payload.status); 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(); } } 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); const uaPolicy = config.user_agent_blocking || {}; if ($("global-ua-block-enabled")) $("global-ua-block-enabled").checked = uaPolicy.enabled === true; if ($("global-ua-block-patterns")) $("global-ua-block-patterns").value = (uaPolicy.patterns || []).join("\n"); } 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; 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; 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 = ""); }); 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; 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); } }); $("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-ua-block-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const patterns = [...new Set($("global-ua-block-patterns").value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]; await saveSection("user-agent-blocking", { enabled: $("global-ua-block-enabled").checked, patterns }); }); } 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); });