"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;