"use strict"; // Shared, safe controls: HTML is edited as text, never injected into the admin DOM. (() => { const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; const option = (value, key) => { const node = label("option", key); node.value = value; return node; }; const codes = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; const STATUS_NAMES = { 200: "OK", 204: "No Content", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 410: "Gone", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" }; const statusLabel = code => `${code} ${STATUS_NAMES[code] || ""}`.trim(); function pageEditor(parent, key, value = null) { const row = el("div", undefined, "page-editor"), heading = label("strong", key, "page-label"); row.append(heading); const mode = el("select"), status = el("select"), html = el("textarea"), file = el("input"); mode.setAttribute("aria-label", t(key)); mode.className = "page-mode"; mode.append(option("", "pageInherit"), option("welcome", "pageWelcome"), option("builtin", "pageBuiltin"), option("empty", "pageEmpty"), option("html", "pageHtml")); mode.value = value?.kind || ""; status.className = "page-status"; status.setAttribute("aria-label", t("pageStatus")); status.append(option("", "pageOriginalStatus")); for (const code of [200, 204, 400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]) { const o = el("option", statusLabel(code)); o.value = code; status.append(o); } status.value = value?.status ?? ""; html.value = value?.html || ""; html.rows = 5; html.maxLength = 32768; html.spellcheck = false; html.className = "page-html"; html.setAttribute("aria-label", t("pageHtmlSource")); file.type = "file"; file.accept = ".html,.htm,text/html,text/plain"; file.setAttribute("aria-label", t("pageUploadHtml")); const source = el("div", undefined, "page-source"); source.append(label("span", "pageUploadHtml", "fine"), file, html); function sync() { status.disabled = !mode.value; source.hidden = mode.value !== "html"; if (status.value === "204" && mode.value !== "empty") status.value = ""; } for (const event of ["change", "input"]) { mode.addEventListener(event, sync); status.addEventListener(event, sync); } file.addEventListener("change", async () => { try { const chosen = file.files[0]; if (!chosen) return; if (chosen.size > 32768) throw new Error(t("pageTooLarge")); html.value = await chosen.text(); html.dispatchEvent(new Event("input", { bubbles: true })); } catch (error) { toast(error.message, true); } finally { file.value = ""; } }); row.append(mode, status, source); parent.append(row); sync(); return { row, read() { if (!mode.value) return null; const markup = mode.value === "html" ? html.value : ""; if (new TextEncoder().encode(markup).length > 32768) throw new Error(t("pageTooLarge")); return { kind: mode.value, status: status.value ? Number(status.value) : null, html: markup }; } }; } function errorsEditor(parent, values = {}) { const details = el("details", undefined, "pages-errors"); details.append(label("summary", "customErrorPages")); parent.append(details); const rows = new Map(); for (const code of codes) { const group = el("div"); details.append(group); const editor = pageEditor(group, "customErrorPages", values[code]); editor.row.querySelector("strong").removeAttribute("data-i18n"); editor.row.querySelector("strong").textContent = statusLabel(code); rows.set(code, editor); } return { read() { return Object.fromEntries([...rows].map(([code, editor]) => [code, editor.read()]).filter(([, page]) => page)); } }; } function vhostEditor(parent, values = {}) { const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback); const interception = el("section", undefined, "php-origin-errors"); const toggle = el("label", undefined, "check"), enabled = document.createElement("input"); enabled.type = "checkbox"; enabled.checked = (values.intercept_statuses || []).length > 0; toggle.append(enabled, label("span", "vhostInterceptOriginErrors")); const codesLabel = el("label"), codesCaption = label("span", "vhostInterceptOriginStatuses"), statuses = document.createElement("input"); statuses.type = "text"; statuses.maxLength = 64; statuses.value = (values.intercept_statuses?.length ? values.intercept_statuses : [404]).join(", "); statuses.placeholder = "404, 500, 502, 503, 504"; codesLabel.append(codesCaption, statuses); const hint = label("p", "vhostInterceptOriginErrorsHelp", "fine"); const sync = () => { statuses.disabled = !enabled.checked; }; enabled.addEventListener("change", sync); sync(); interception.append(toggle, codesLabel, hint); parent.append(interception); const errors = errorsEditor(parent, values.errors); parent.append(label("p", "pagesSafetyHint", "fine")); return { read: () => { const selectedTokens = enabled.checked ? statuses.value.split(/[\s,]+/).filter(Boolean) : []; const selected = selectedTokens.map(Number); if (enabled.checked && (!selected.length || selectedTokens.some(token => !/^\d{3}$/.test(token)) || selected.length > codes.length || new Set(selected).size !== selected.length || selected.some(code => !codes.includes(code)))) { throw new Error(t("vhostInterceptOriginInvalid")); } return { response: response.read(), fallback: fallback.read(), errors: errors.read(), intercept_statuses: selected }; } }; } let global = null; function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; } window.rustProxyPages = { render, vhostEditor }; bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages"); $("settings-security-open").addEventListener("click", () => setPanel("security")); $("settings-vhost-open").addEventListener("click", () => setPanel("vhosts")); if (envelope) render(); })(); (() => { const dialog = $("logs-dialog"); let paused = null, expanded = null, docked = false, pinning = false, preferencesLoaded = false, preferencesLoading = null, preferenceSaveRunning = false, preferenceSaveQueued = false; const filters = ["log-vhost", "log-method", "log-status", "log-outcome", "log-filter"]; const columns = [ { id: "time", label: "time", value: e => formatTime(e.time_ms) }, { id: "request", label: "request", value: e => `${e.method} ${e.path}` }, { id: "host", label: "logHost", value: e => e.host }, { id: "source_ip", label: "sourceIp", value: e => e.client_ip || "-" }, { id: "vhost", label: "vhVhost", value: e => e.vhost || "-" }, { id: "backend", label: "backend", value: e => e.backend || "-" }, { id: "status", label: "status", value: e => e.status }, { id: "duration", label: "duration", value: e => `${e.duration_ms} ms` }, { id: "cache", label: "cache", value: e => e.cache || "-" }, { id: "outcome", label: "outcome", value: e => t(e.outcome) }, { id: "transport", label: "logTransport", value: e => e.transport ? `${e.transport}${e.http_version ? ` / HTTP ${e.http_version}` : ""}` : "-" }, { id: "websocket", label: "logWebSocket", value: e => [e.websocket_bootstrap, e.websocket_phase, e.websocket_error, e.h3_reset_code].filter(Boolean).join(" / ") || "-" }, { id: "upstream_error", label: "logUpstreamError", value: e => e.upstream_error || "-" }, ]; const defaultColumns = () => new Set(columns.map(column => column.id)); let visibleColumns = defaultColumns(); function source() { return paused || expanded || lastLogs || { entries: [] }; } function mergeExpanded(data) { if (!expanded || paused || !data?.entries) return; const seen = new Set(); const entries = []; for (const entry of [...data.entries, ...(expanded.entries || [])]) { const key = entry.id ?? `${entry.time_ms}:${entry.method}:${entry.host}:${entry.path}:${entry.status}`; if (seen.has(key)) continue; seen.add(key); entries.push(entry); if (entries.length >= 4096) break; } expanded = { ...expanded, ...data, entries, capacity: 4096, window: 4096 }; } function syncExpandedButton() { const button = $("logs-load-history"); button.textContent = t(expanded ? "logExpandedBuffer" : "logExpandBuffer"); button.dataset.i18n = expanded ? "logExpandedBuffer" : "logExpandBuffer"; button.disabled = Boolean(expanded); } function filtered() { const query = $("log-filter").value.trim().toLowerCase(), outcome = $("log-outcome").value; return source().entries.filter(e => (!$("log-vhost").value || e.vhost === $("log-vhost").value) && (!$("log-method").value || e.method === $("log-method").value) && (!$("log-status").value || String(e.status).startsWith($("log-status").value)) && (outcome === "all" || (outcome === "errors" ? e.status >= 400 : e.cache === outcome)) && `${e.method} ${e.path} ${e.host} ${e.client_ip || ""} ${e.vhost || ""} ${e.backend || ""} ${e.transport || ""} ${e.http_version || ""} ${e.websocket_bootstrap || ""} ${e.websocket_phase || ""} ${e.websocket_error || ""} ${e.h3_reset_code || ""} ${e.upstream_error || ""}`.toLowerCase().includes(query)); } function syncHeaders() { dialog.querySelectorAll("th[data-log-column]").forEach(header => { header.hidden = !visibleColumns.has(header.dataset.logColumn); }); } function syncColumnPicker() { const picker = $("log-column-options"); picker.replaceChildren(...columns.map(column => { const label = el("label", undefined, "check log-column-option"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id; const text = el("span", t(column.label)); text.dataset.i18n = column.label; input.addEventListener("change", () => { if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("logColumnsRequired"), true); return; } if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id); syncHeaders(); render(); void savePreferences(); }); label.append(input, text); return label; })); } function applyPreferences(preferences) { if (!preferences) return; const selected = Array.isArray(preferences.live_log_columns) ? preferences.live_log_columns.filter(id => columns.some(column => column.id === id)) : []; visibleColumns = selected.length ? new Set(selected) : defaultColumns(); preferencesLoaded = true; syncColumnPicker(); syncHeaders(); render(); } async function loadPreferences(force = false) { if (!authenticated || (preferencesLoaded && !force)) return; if (preferencesLoading) return preferencesLoading; preferencesLoading = (async () => { try { applyPreferences(await window.rustProxyPreferences.load(force)); } catch (error) { if (error.status !== 401) toast(error.message, true); } finally { preferencesLoading = null; } })(); return preferencesLoading; } async function savePreferences() { if (!authenticated) return; preferenceSaveQueued = true; if (preferenceSaveRunning) return; preferenceSaveRunning = true; try { while (preferenceSaveQueued && authenticated) { preferenceSaveQueued = false; const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id); const preferences = await window.rustProxyPreferences.update({ live_log_columns: selected }); if (!authenticated) return; if (!preferenceSaveQueued) applyPreferences(preferences); } } catch (error) { if (error.status !== 401) toast(error.message, true); } finally { preferenceSaveRunning = false; } } function render(data = null) { if (data) mergeExpanded(data); if (!dialog.open) return; const selector = $("log-vhost"), selected = selector.value, ids = new Set([...(envelope?.config.vhosts || []).map(v => v.id), ...source().entries.map(e => e.vhost).filter(Boolean)]); const all = el("option", t("logAllVhosts")); all.value = ""; selector.replaceChildren(all, ...[...ids].sort().map(id => { const o = el("option", id); o.value = id; return o; })); if (ids.has(selected)) selector.value = selected; const entries = filtered(), rows = entries.map(e => { const row = el("tr"); const hints = []; if (e.cache_reason) hints.push(window.rustProxyCachePanel?.reason(e.cache_reason) || e.cache_reason); if (e.upstream_error) hints.push(`upstream: ${e.upstream_error}`); row.title = hints.join(" · "); for (const column of columns) { if (!visibleColumns.has(column.id)) continue; const value = column.value(e), cell = el("td", String(value)); cell.title = String(value); if (column.id === "status") cell.className = e.status < 400 ? "status-good" : "status-error"; if (column.id === "cache" && e.cache === "HIT") cell.className = "status-good"; row.append(cell); } return row; }); syncHeaders(); $("logs").replaceChildren(...rows); $("empty-logs").hidden = rows.length > 0; $("log-count").textContent = `${entries.length} / ${source().entries.length} | ${t(paused ? "logPaused" : "live")}`; $("logs-pause").textContent = t(paused ? "logResume" : "logPause"); $("logs-pin").textContent = t(docked ? "logUndock" : "logPin"); syncExpandedButton(); } function open(vhost) { if (!authenticated) return; if (!preferencesLoaded) void loadPreferences(); if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; } if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); } render(); } syncColumnPicker(); syncHeaders(); $("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close()); $("logs-load-history").addEventListener("click", async () => { const button = $("logs-load-history"); button.disabled = true; try { const snapshot = await api("/api/logs?limit=4096"); expanded = { ...snapshot, capacity: 4096, window: 4096 }; render(); toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0))); } catch (error) { toast(error.message, true); } finally { button.disabled = Boolean(expanded); } }); $("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); }); $("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); }); dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); }); for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render); $("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } }; })(); // Reusable ACL profiles. One profile is one access policy: the guided modes // make country and IP/CIDR exceptions read as a single OR expression, while // custom mode retains the full backend model for mixed allow/deny policies. (() => { let editing = null, dirty = false; const form = $("acl-profile-form"); const split = value => value.split(/[\s,]+/).map(v => v.trim()).filter(Boolean); const aclDefault = $("acl-profile-default"), countryMode = $("acl-profile-country-mode"), countriesInput = $("acl-profile-countries"); const countryEnabled = $("acl-profile-country-enabled"), countryField = $("acl-profile-country-field"), customControls = $("acl-profile-custom-controls"); const rules = $("acl-profile-rules"), ruleRows = $("acl-profile-rule-rows"), bulk = $("acl-profile-bulk"); const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules"); const previewDefault = $("acl-profile-preview-default"), previewFlow = $("acl-profile-preview-flow"), previewNote = $("acl-profile-preview-note"); const countryHelp = $("acl-profile-country-help"), countryLabel = $("acl-profile-country-label"), ipHelp = $("acl-profile-ip-help"); const modeInputs = [...form.querySelectorAll('input[name="acl-profile-mode"]')]; const i18n = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; const selectedMode = () => modeInputs.find(node => node.checked)?.value || "allow_exceptions"; const setMode = value => { for (const node of modeInputs) node.checked = node.value === value; }; const guidedAction = mode => mode === "allow_exceptions" ? "allow" : "deny"; const guidedDefault = mode => mode === "allow_exceptions" ? "deny" : "allow"; const guidedCountryMode = mode => mode === "allow_exceptions" ? "allow" : "block"; const activeCountryMode = () => selectedMode() === "custom" ? countryMode.value : (countryEnabled.checked ? guidedCountryMode(selectedMode()) : "off"); function normalizeNetwork(value) { const network = value.trim(); if (!network || network.includes("/")) return network; if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(network)) { const octets = network.split(".").map(Number); if (octets.every(value => value >= 0 && value <= 255)) return `${network}/32`; } if (network.includes(":")) return `${network}/128`; return network; } function addRule(rule = null, mark = true) { if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; } const mode = selectedMode(), fallbackAction = mode === "custom" ? "allow" : guidedAction(mode); const value = rule || { action: fallbackAction, network: "" }; const row = el("div", undefined, "acl-rule acl-profile-rule"); const actionLabel = el("label", undefined, "acl-profile-rule-action-field"); actionLabel.append(i18n("span", "aclAction")); const actionSelect = el("select"); actionSelect.className = "acl-profile-rule-action"; for (const actionValue of ["allow", "deny"]) { const option = el("option", t(actionValue === "allow" ? "aclAllow" : "vhDeny")); option.value = actionValue; actionSelect.append(option); } actionSelect.value = value.action || fallbackAction; actionLabel.append(actionSelect); const actionBadge = el("span", "", "acl-profile-rule-action-badge"); actionLabel.append(actionBadge); const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork")); const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8 or 203.0.113.5"; network.value = value.network || ""; networkLabel.append(network); network.addEventListener("blur", () => { const normalized = normalizeNetwork(network.value); if (normalized !== network.value) { network.value = normalized; syncRulesText(); renderPreview(); } }); const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncPolicy(); }); remove.classList.add("acl-profile-rule-remove"); row.append(actionLabel, networkLabel, remove); ruleRows.append(row); if (mark) { syncRulesText(); dirty = true; } syncRuleRows(); } function collectRuleRows() { return [...ruleRows.children].map(row => ({ action: row.querySelector(".acl-profile-rule-action").value, network: normalizeNetwork(row.querySelector(".acl-profile-rule-network").value), })); } function syncRulesText() { rules.value = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); } function renderRuleRows(values) { ruleRows.replaceChildren(); for (const rule of values || []) addRule(rule, false); syncRulesText(); syncRuleRows(); } function parseBulkRules(value, target) { if (selectedMode() === "custom") return parseRules(value, target); const expected = guidedAction(selectedMode()); return normalizeAclRuleText(value).split(/\r?\n/).map((line, index) => ({ line: line.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] || expected).toLowerCase(), network: normalizeNetwork(match[2]) }; }); } function importRules() { try { const parsed = parseBulkRules(rules.value, rules), mode = selectedMode(); if (mode !== "custom" && parsed.some(rule => rule.action !== guidedAction(mode))) setMode("custom"); renderRuleRows(parsed); dirty = true; syncPolicy(); toast(t("aclProfileRulesImported")); } catch (error) { toast(error.message, true); } } function inferMode(profile) { const cMode = profile.country_access?.mode || "off"; const defaultAction = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); const actions = (profile.acl?.rules || []).map(rule => rule.action); if (defaultAction === "deny" && actions.every(action => action === "allow") && ["off", "allow"].includes(cMode)) return "allow_exceptions"; if (defaultAction === "allow" && actions.every(action => action === "deny") && ["off", "block"].includes(cMode)) return "block_exceptions"; return "custom"; } function syncRuleRows(convert = false) { const mode = selectedMode(), custom = mode === "custom", expected = custom ? null : guidedAction(mode); for (const row of ruleRows.children) { const select = row.querySelector(".acl-profile-rule-action"), badge = row.querySelector(".acl-profile-rule-action-badge"); if (!custom && convert) select.value = expected; select.hidden = !custom; badge.hidden = custom; if (!custom) { badge.textContent = t(expected === "allow" ? "aclAllow" : "vhDeny"); badge.classList.toggle("is-allow", expected === "allow"); badge.classList.toggle("is-deny", expected === "deny"); } } syncRulesText(); } function conditionChip(key, value, kind) { const chip = el("span", undefined, `acl-policy-condition ${kind}`); chip.append(i18n("b", key), el("span", value)); return chip; } function renderPreview() { const mode = selectedMode(), custom = mode === "custom", defaultAction = custom ? aclDefault.value : guidedDefault(mode); previewDefault.textContent = t(defaultAction === "deny" ? "aclProfilePreviewDefaultDeny" : "aclProfilePreviewDefaultAllow"); previewDefault.className = defaultAction === "deny" ? "is-deny" : "is-allow"; const countries = [...new Set(split(countriesInput.value).map(value => value.toUpperCase()))]; const ipRules = collectRuleRows().filter(rule => rule.network); if (custom) { const modeValue = countryMode.value, items = []; items.push(conditionChip("aclProfilePreviewIpRules", String(ipRules.length), "neutral")); if (modeValue !== "off") items.push(conditionChip(modeValue === "allow" ? "aclProfilePreviewAllowedCountries" : "aclProfilePreviewBlockedCountries", countries.join(", ") || "—", modeValue === "allow" ? "allow" : "deny")); previewFlow.replaceChildren(...items); previewNote.textContent = t("aclProfilePreviewCustomNote"); return; } const allowMode = mode === "allow_exceptions", items = []; if (countryEnabled.checked) items.push(conditionChip(allowMode ? "aclProfilePreviewAllowedCountries" : "aclProfilePreviewBlockedCountries", countries.join(", ") || "—", allowMode ? "allow" : "deny")); if (ipRules.length) items.push(conditionChip(allowMode ? "aclProfilePreviewAllowedIps" : "aclProfilePreviewBlockedIps", ipRules.map(rule => rule.network).join(", "), allowMode ? "allow" : "deny")); if (!items.length) items.push(el("span", t("aclProfilePreviewNoExceptions"), "acl-policy-condition neutral")); const flow = []; items.forEach((item, index) => { if (index) flow.push(el("span", t("aclProfileOr"), "acl-policy-or")); flow.push(item); }); previewFlow.replaceChildren(...flow); previewNote.textContent = t(items.length === 1 && !countryEnabled.checked && !ipRules.length ? (allowMode ? "aclProfilePreviewBlocksAll" : "aclProfilePreviewAllowsAll") : (allowMode ? "aclProfilePreviewAllowNote" : "aclProfilePreviewBlockNote")); } function syncPolicy(convertRules = false) { const mode = selectedMode(), custom = mode === "custom"; customControls.hidden = !custom; if (!custom) { aclDefault.value = guidedDefault(mode); countryMode.value = countryEnabled.checked ? guidedCountryMode(mode) : "off"; countryLabel.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedCountries" : "aclProfileBlockedCountries"); countryLabel.dataset.i18n = mode === "allow_exceptions" ? "aclProfileAllowedCountries" : "aclProfileBlockedCountries"; countryHelp.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedCountriesHelp" : "aclProfileBlockedCountriesHelp"); ipHelp.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedIpsHelp" : "aclProfileBlockedIpsHelp"); } else { countryLabel.textContent = t("vhCountryList"); countryLabel.dataset.i18n = "vhCountryList"; countryHelp.textContent = t("aclProfileCustomCountryHelp"); ipHelp.textContent = t("aclProfileCustomIpHelp"); countryEnabled.checked = countryMode.value !== "off"; } countryField.classList.toggle("is-disabled", !countryEnabled.checked); countriesInput.disabled = !countryEnabled.checked; if (custom) { countriesInput.disabled = countryMode.value === "off"; countryEnabled.checked = countryMode.value !== "off"; countryEnabled.disabled = true; } else countryEnabled.disabled = false; bulk.querySelector("summary").textContent = t(custom ? "aclProfileBulkRules" : "aclProfileBulkExceptions"); rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5"; syncRuleRows(convertRules); renderPreview(); } function clear() { editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy(); } function edit(profile) { editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id; const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode; countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n"); setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy(); } function friendlySummary(profile) { const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || []; if (mode === "allow_exceptions" || mode === "block_exceptions") { const allowMode = mode === "allow_exceptions", parts = []; if (countries.length && profile.country_access?.mode !== "off") parts.push(`${t(allowMode ? "aclProfileCountriesShortAllow" : "aclProfileCountriesShortBlock")}: ${countries.join(", ")}`); if (rulesList.length) parts.push(`${t(allowMode ? "aclProfileIpsShortAllow" : "aclProfileIpsShortBlock")}: ${rulesList.map(rule => rule.network).join(", ")}`); return [`${t(allowMode ? "aclProfileModeAllowExceptions" : "aclProfileModeBlockExceptions")}`, parts.join(" · ") || t("aclProfilePreviewNoExceptions")]; } return [`${t("aclProfileModeCustom")}: ${t("aclDefault")} ${t(profile.acl?.default || "allow")}`, `${rulesList.length} ${t("aclProfileIpRulesShort")} · ${t("vhCountryMode")}: ${t(profile.country_access?.mode === "block" ? "vhCountryBlock" : profile.country_access?.mode === "allow" ? "vhCountryAllow" : "vhOff")}`]; } function refreshTranslations() { $("acl-profile-editing").textContent = t(editing ? "aclProfileEdit" : "aclProfileNew"); for (const row of ruleRows.children) { const select = row.querySelector(".acl-profile-rule-action"); if (select?.options?.length >= 2) { select.options[0].textContent = t("aclAllow"); select.options[1].textContent = t("vhDeny"); } const remove = row.querySelector(".acl-profile-rule-remove"); if (remove) remove.textContent = t("remove"); } syncPolicy(); render(); } function render() { if (!envelope) return; const profiles = envelope.config.acl_profiles || [], list = $("acl-profile-list"); const cards = profiles.map(profile => { const box = el("div", undefined, "entity acl-profile-card"); const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top); const pieces = friendlySummary(profile); const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id); for (const agent of envelope.config.docker_agents?.agents || []) { if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`); for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`); } if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`); box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details")); const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => { if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear(); })); box.append(actions); return box; }); list.replaceChildren(...(cards.length ? cards : [el("p", t("aclProfilesEmpty"), "empty")])); if (!dirty && editing) { const current = profiles.find(p => p.id === editing); if (current) edit(current); else clear(); } } form.addEventListener("input", event => { dirty = true; if (event.target !== rules) { syncRulesText(); syncPolicy(); } }); form.addEventListener("change", event => { dirty = true; if (event.target.matches('input[name="acl-profile-mode"]')) syncPolicy(true); else if (event.target === countryEnabled || event.target === countryMode || event.target === aclDefault) syncPolicy(); else { syncRulesText(); syncPolicy(); } }); ruleRows.addEventListener("input", () => { syncRulesText(); renderPreview(); }); ruleRows.addEventListener("change", () => { syncRulesText(); renderPreview(); }); addButton.addEventListener("click", () => { addRule(); syncPolicy(); }); importButton.addEventListener("click", importRules); form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const id = $("acl-profile-id").value.trim(), name = $("acl-profile-name").value.trim(), mode = selectedMode(); const countries = [...new Set(split(countriesInput.value).map(v => v.toUpperCase()))]; if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError(countriesInput, t("aclProfileCountryInvalid")); const cMode = activeCountryMode(); if (cMode !== "off" && !countries.length) throw fieldError(countriesInput, t("aclProfileCountryRequired")); const visualText = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); if (normalizeAclRuleText(rules.value).trim() !== normalizeAclRuleText(visualText).trim()) { const parsed = parseBulkRules(rules.value, rules); if (mode !== "custom" && parsed.some(rule => rule.action !== guidedAction(mode))) { setMode("custom"); syncPolicy(); } renderRuleRows(parsed); } const finalMode = selectedMode(), expectedAction = finalMode === "custom" ? null : guidedAction(finalMode); const aclRules = collectRuleRows().map(rule => ({ ...rule, action: expectedAction || rule.action })); if (aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); } for (const [row, rule] of [...ruleRows.children].map((row, index) => [row, aclRules[index]])) row.querySelector(".acl-profile-rule-network").value = rule.network; const finalDefault = finalMode === "custom" ? aclDefault.value : guidedDefault(finalMode); const finalCountryMode = finalMode === "custom" ? countryMode.value : (countryEnabled.checked ? guidedCountryMode(finalMode) : "off"); const profile = { id, name, acl: { default: finalDefault, rules: aclRules }, country_access: { mode: finalCountryMode, countries } }; const next = structuredClone(envelope.config); next.acl_profiles ||= []; const index = next.acl_profiles.findIndex(item => item.id === editing); if (index < 0) { if (next.acl_profiles.some(item => item.id === id)) throw fieldError($("acl-profile-id"), t("aclProfileDuplicate")); next.acl_profiles.push(profile); } else next.acl_profiles[index] = profile; if (await saveSection("acl-profiles", next.acl_profiles || [])) { editing = id; dirty = false; render(); } }); } catch (error) { toast(error.message, true); if (error.fieldTarget?.focus) error.fieldTarget.focus(); } }); $("acl-profile-new").addEventListener("click", clear); $("acl-profile-reset").addEventListener("click", clear); window.rustProxyAclProfiles = { render, reset: clear, refreshTranslations }; clear(); if (envelope) render(); })();