"use strict"; // Persistent tab DOM: switching tabs never discards unsaved form values. (() => { const buttons = [...document.querySelectorAll("[data-settings-tab]")]; const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners"; const requestedTab = typeof location !== "undefined" && location.pathname === "/logging" ? "logging" : (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null); const tabAliases = { http2: "connections" }; const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab; let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab; function syncLocation() { if (typeof location === "undefined" || !["/settings", "/configuration", "/logging"].includes(location.pathname) || !["http:", "https:"].includes(location.protocol)) return; const url = new URL(location.href); const alreadyCanonical = url.pathname === "/settings" && url.searchParams.get("tab") === active; if (alreadyCanonical) return; url.pathname = "/settings"; url.search = ""; url.searchParams.set("tab", active); history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: active }, "", `${url.pathname}${url.search}${url.hash}`); } function select(name, focus = false, persist = true) { if (!buttons.some(b => b.dataset.settingsTab === name)) return; active = name; for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); } document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name); if (persist) syncLocation(); } for (const b of buttons) { b.addEventListener("click", () => select(b.dataset.settingsTab)); b.addEventListener("keydown", event => { const index = buttons.indexOf(b); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % buttons.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + buttons.length - 1) % buttons.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = buttons.length - 1; else return; event.preventDefault(); select(buttons[next].dataset.settingsTab, true); }); } const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")]; function selectCacheTab(name, focus = false) { if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return; for (const button of cacheTabs) { const chosen = button.dataset.cacheSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); } document.querySelectorAll("[data-cache-settings-page]").forEach(page => page.hidden = page.dataset.cacheSettingsPage !== name); } for (const button of cacheTabs) { button.addEventListener("click", () => selectCacheTab(button.dataset.cacheSettingsTab)); button.addEventListener("keydown", event => { const index = cacheTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % cacheTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + cacheTabs.length - 1) % cacheTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = cacheTabs.length - 1; else return; event.preventDefault(); selectCacheTab(cacheTabs[next].dataset.cacheSettingsTab, true); }); } selectCacheTab("general"); const loggingTabs = [...document.querySelectorAll("[data-logging-settings-tab]")]; function selectLoggingTab(name, focus = false) { if (!loggingTabs.some(button => button.dataset.loggingSettingsTab === name)) return; for (const button of loggingTabs) { const chosen = button.dataset.loggingSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); } document.querySelectorAll("[data-logging-settings-page]").forEach(page => page.hidden = page.dataset.loggingSettingsPage !== name); const loggingForm = $("logging-form"), errorLogForm = $("error-log-form"); if (loggingForm) loggingForm.hidden = name === "errors"; if (errorLogForm) errorLogForm.hidden = name !== "errors"; } for (const button of loggingTabs) { button.addEventListener("click", () => selectLoggingTab(button.dataset.loggingSettingsTab)); button.addEventListener("keydown", event => { const index = loggingTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % loggingTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + loggingTabs.length - 1) % loggingTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = loggingTabs.length - 1; else return; event.preventDefault(); selectLoggingTab(loggingTabs[next].dataset.loggingSettingsTab, true); }); } selectLoggingTab("outputs"); const httpTabs = [...document.querySelectorAll("[data-http-settings-tab]")]; function selectHttpTab(name, focus = false) { if (!httpTabs.some(button => button.dataset.httpSettingsTab === name)) return; for (const button of httpTabs) { const chosen = button.dataset.httpSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); } document.querySelectorAll("[data-http-settings-page]").forEach(page => page.hidden = page.dataset.httpSettingsPage !== name); } for (const button of httpTabs) { button.addEventListener("click", () => selectHttpTab(button.dataset.httpSettingsTab)); button.addEventListener("keydown", event => { const index = httpTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % httpTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + httpTabs.length - 1) % httpTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = httpTabs.length - 1; else return; event.preventDefault(); selectHttpTab(httpTabs[next].dataset.httpSettingsTab, true); }); } selectHttpTab("http2"); let certificateApiState = null, certificateApiLoading = false; function certificateApiCredentials() { const values = certificateApiState?.credentials; if (Array.isArray(values)) return values; if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }]; return []; } function certificateApiExample() { const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main"; $("certificate-api-curl").textContent = `curl --fail --show-error --request POST \ --header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \ --header 'X-RustProxy-Certificate-Key: ${key}' \ --header 'Content-Type: application/json' \ --data @deploy.json \ ${location.origin}/api/certificates/deploy # Reload one manual/file-backed certificate without changing its configuration: curl --fail --show-error --request POST \ --header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \ --header 'X-RustProxy-Certificate-Key: ${key}' \ ${location.origin}/api/certificates/CERTIFICATE_ID/reload`; } function renderCertificateApi() { const state = certificateApiState, target = $("certificate-api-credentials"); if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; } const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32; $("certificate-api-status").textContent = credentials.length ? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max) : t("certificateApiDisabled"); $("certificate-api-revoke-all").disabled = !credentials.length; if (target) { target.replaceChildren(); for (const credential of credentials) { const row = el("div", undefined, "certificate-api-credential-row"); const info = el("div", undefined, "certificate-api-credential-info"); info.append(el("b", credential.key_id || "-", "mono")); if (credential.comment) info.append(el("span", credential.comment, "fine")); const lastUsed = credential.last_used_at ? new Date(Number(credential.last_used_at) * 1000).toLocaleString() : t("certificateApiNeverUsed"); info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine")); const remove = action(t("certificateApiRevoke"), async () => { if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return; remove.disabled = true; try { await api(`/api/certificate-api/${encodeURIComponent(credential.key_id)}`, { method: "DELETE" }); certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id); certificateApiState.configured = certificateApiState.credentials.length > 0; renderCertificateApi(); toast(t("certificateApiRevoked")); } catch (error) { toast(error.message, true); remove.disabled = false; } }); remove.classList.add("danger"); row.append(info, remove); target.append(row); } } certificateApiExample(); } async function loadCertificateApi(force = false) { if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return; certificateApiLoading = true; try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); } catch (error) { $("certificate-api-status").textContent = error.message; } finally { certificateApiLoading = false; } } const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true }; function example() { const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim(); $("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`; } function render() { if (!envelope) return; const p = { ...defaults, ...envelope.config.prometheus }; if (!settingsDirty.has("prometheus-form")) { $("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path; $("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token; for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key]; } const status = latestStatus?.prometheus; $("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example(); const admin = envelope.config.administration || {}, adminStatus = latestStatus?.administration; if (!settingsDirty.has("administration-form")) { $("admin-listen").value = adminStatus?.locked_listen || admin.listen || adminStatus?.desired?.listen || latestStatus?.admin_listen || "127.0.0.1:9090"; $("admin-tls").checked = !!admin.tls; const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate"); $("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current; $("admin-hosts").value = (admin.allowed_hosts || []).join("\n"); $("admin-session-days").value = Number(admin.session_days || 30); } const listenLocked = !!adminStatus?.listen_locked; $("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked; const syncAdminTls = () => { $("admin-certificate").disabled = !$("admin-tls").checked; }; syncAdminTls(); $("admin-status").textContent = adminStatus?.restart_required ? t("restartPending") : adminStatus?.active?.tls ? `${t("administrationActiveHttps")}: ${adminStatus.active.listen}` : `${t("administrationActiveHttp")}: ${adminStatus?.active?.listen || latestStatus?.admin_listen || "-"}`; const runtime = latestStatus?.runtime || {}; for (const [id, key] of [["runtime-platform", "platform"], ["runtime-statistics-source", "statistics_source"], ["runtime-data-directory", "data_directory"], ["runtime-database-path", "database_path"]]) { const node = $(id); if (node) node.textContent = runtime[key] || t("unavailable"); } const policy = envelope.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; if (!settingsDirty.has("tls-policy-form")) { $("tls-policy-12").checked = policy.tls12 !== false; $("tls-policy-13").checked = policy.tls13 !== false; $("tls-policy-ciphers").value = (policy.cipher_suites || []).join("\n"); } const available = latestStatus?.tls?.cipher_suites || []; const effective = latestStatus?.tls?.effective_cipher_suites || []; $("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : ""; $("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : ""; renderCertificateApi(); loadCertificateApi(); } bindSettingsForm("prometheus-form", next => { next.prometheus = { enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked, include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked }; }, "prometheus"); bindSettingsForm("administration-form", next => { const locked = !!latestStatus?.administration?.listen_locked; next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value) }; }, "administration"); bindSettingsForm("tls-policy-form", next => { if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired")); next.tls ||= {}; next.tls.policy = { tls12: $("tls-policy-12").checked, tls13: $("tls-policy-13").checked, cipher_suites: $("tls-policy-ciphers").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean) }; }, "tls"); $("certificate-api-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true; try { const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim(); const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment }) }); const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated")); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } }); $("certificate-api-revoke-all").addEventListener("click", async () => { if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return; const button = $("certificate-api-revoke-all"); button.disabled = true; try { await api("/api/certificate-api", { method: "DELETE" }); certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] }; $("certificate-api-secret-wrap").hidden = true; $("certificate-api-secret").textContent = ""; renderCertificateApi(); toast(t("certificateApiRevoked")); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } }); $("certificate-api-key-id").addEventListener("input", certificateApiExample); $("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; }); $("prometheus-form").addEventListener("input", example); $("prom-preview").addEventListener("click", async () => { const epoch = authEpoch, button = $("prom-preview"); button.disabled = true; try { const response = await fetch("/api/metrics", { credentials: "same-origin", headers: { "Accept": "text/plain" } }); if (!response.ok) throw new Error(`${t("prometheusPreviewFailed")} (${response.status})`); const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false; } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; } }); window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } }; select(active); render(); })();