"use strict"; (() => { let uploadBusy = false; const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; function field(parent, name, key, value, type = "text", options = {}) { const wrapper = el("label", undefined, type === "checkbox" ? "check" : "vh-field"); const input = el(type === "select" ? "select" : type === "textarea" ? "textarea" : "input"); input.name = name; if (type === "select") for (const [value, key] of options.values || []) { const option = label("option", key); option.value = value; input.append(option); } else if (type !== "textarea") input.type = type; if (type === "checkbox") input.checked = !!value; else input.value = value ?? ""; for (const key of ["min", "max", "maxLength", "required", "pattern", "rows", "step"]) if (Object.hasOwn(options, key)) input[key] = options[key]; if (type === "checkbox") wrapper.append(input, label("span", key)); else wrapper.append(label("span", key), input); parent.append(wrapper); return input; } const button = (parent, key, run) => { const node = label("button", key, "quiet"); node.type = "button"; node.addEventListener("click", run); parent.append(node); return node; }; function hostnames(parent, initial = [], options = {}) { const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(label("span", options.labelKey || "vhHosts")); const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t(options.addKey || "vhHostAdd")); const values = []; const notify = () => { if (typeof options.onChange === "function") options.onChange(); }; const normalize = raw => { let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value; if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid")); if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base; }; const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); notify(); }); chip.append(remove); box.insertBefore(chip, input); } }; const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); if (changed) notify(); }; const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; }; input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } }); input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } }); input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } }); box.append(input); wrapper.append(box, label("p", options.helpKey || "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of initial) { try { add(host, false); } catch { } } return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t(options.requiredKey || "vhHostRequired")); return [...values]; }; } function addressTagEditor(parent, initial = [], lockedValues = []) { const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(label("span", "listenerAddresses")); const box = el("div", undefined, "host-chip-editor listener-address-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 128; input.placeholder = "127.0.0.1:8080 / [::1]:8080"; input.setAttribute("aria-label", t("listenerAddressAdd")); const values = [], locked = new Set(lockedValues); const normalize = raw => { const value = raw.trim(); if (!value) return null; if (value.length > 128 || /[\s,]/.test(value)) throw fieldError(input, t("listenerAddressesInvalid")); return value; }; const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; const fixed = locked.has(value); remove.disabled = fixed; remove.title = fixed ? t("listenerStartupLocked") : ""; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { if (fixed) return; values.splice(values.indexOf(value), 1); sync(); setSettingsDirty("listeners-form"); }); chip.append(remove); box.insertBefore(chip, input); } }; const add = (raw, dirty = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; if (values.length >= 16) throw fieldError(input, t("listenerAddressesInvalid")); values.push(value); sync(); if (dirty) setSettingsDirty("listeners-form"); }; const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; }; input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } }); input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } }); input.addEventListener("paste", event => { const value = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(value.trim())) return; event.preventDefault(); try { consume(value); } catch (error) { toast(error.message, true); } }); box.append(input); wrapper.append(box, label("p", locked.size ? "listenerStartupLocked" : "listenerAddressesHelp", "fine")); parent.append(wrapper); for (const value of initial) { try { add(value, false); } catch { } } const read = () => { if (input.value.trim()) consume(input.value); return [...values]; }; read.input = input; return read; } const h2Defaults = { idle_timeout_seconds: 300, max_connection_lifetime_seconds: 0, drain_timeout_seconds: 10 }; const effectiveHttp2Settings = binding => ({ ...h2Defaults, ...(envelope?.config.http2 || {}), ...(binding?.http2_settings || {}) }); function http2SettingsControls(parent, binding) { const box = el("div", undefined, "h3-editor vh-wide"); parent.append(box); const grid = el("div", undefined, "vh-grid"); box.append(grid); const current = effectiveHttp2Settings(binding), inputs = {}; for (const [name, min, max, key] of [ ["idle_timeout_seconds", 1, 86400, "http2IdleTimeout"], ["max_connection_lifetime_seconds", 0, 2592000, "http2MaxLifetime"], ["drain_timeout_seconds", 1, 3600, "http2DrainTimeout"], ]) inputs[name] = field(grid, name, key, current[name], "number", { min, max, required: true }); box.append(label("p", "http2DrainHelp", "fine")); const sync = (blocked = false) => { for (const input of Object.values(inputs)) input.disabled = blocked; }; const read = () => { const value = {}; for (const [name, input] of Object.entries(inputs)) { value[name] = Number(input.value); if (!Number.isSafeInteger(value[name])) throw fieldError(input, t("vhJsonInvalid")); } return value; }; return { read, sync }; } const h3Defaults = { alt_svc_max_age: 3600, max_concurrent_connections: 512, max_concurrent_bidistream: 100, max_concurrent_unistream: 100, max_idle_timeout: 10, udp_receive_buffer_bytes: 4194304, udp_send_buffer_bytes: 4194304 }; function http3SettingsControls(parent, binding) { const box = el("div", undefined, "h3-editor vh-wide"); parent.append(box); const grid = el("div", undefined, "vh-grid"); box.append(grid); const inputs = {}; for (const [name, min, max] of [["alt_svc_max_age", 0, 604800], ["max_concurrent_connections", 1, 4096], ["max_concurrent_bidistream", 1, 1024], ["max_concurrent_unistream", 3, 1024], ["max_idle_timeout", 0, 86400]]) inputs[name] = field(grid, name, `h3_${name}`, binding.http3?.[name] ?? h3Defaults[name], "number", { min, max, required: true }); for (const direction of ["receive", "send"]) { const name = `udp_${direction}_buffer_bytes`; const mib = (binding.http3?.[name] ?? h3Defaults[name]) / 1048576; inputs[name] = field(grid, name, `h3_udp_${direction}_buffer_mib`, mib, "number", { min: 0, max: 16, step: "any", required: true }); } box.append(label("p", "h3TuningHelp", "fine")); const sync = (blocked = false) => { for (const input of Object.values(inputs)) input.disabled = blocked; }; sync(false); const read = () => { const value = {}; for (const [name, input] of Object.entries(inputs)) { let number = Number(input.value); if (!input.value.trim() || !Number.isFinite(number)) throw fieldError(input, t("vhJsonInvalid")); if (name.startsWith("udp_") && name.endsWith("_buffer_bytes")) { number = Math.round(number * 1048576); if (number !== 0 && (number < 65536 || number > 16777216)) throw fieldError(input, t("vhJsonInvalid")); } if (!Number.isSafeInteger(number)) throw fieldError(input, t("vhJsonInvalid")); value[name] = number; } return value; }; return { read, sync }; } function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) { if ($("named-listener-rows").children.length >= 64) { toast(t("listenerLimit"), true); return; } const row = el("fieldset", undefined, "named-listener-row"); row.append(label("legend", "listenerEndpoint")); const grid = el("div", undefined, "vh-grid"); row.append(grid); const b = item.binding || {}; const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); const name = field(grid, "listener-name", "listenerName", item.name, "text", { maxLength: 96 }); const scope = field(grid, "listener-scope", "listenerScope", item.scope || "internal", "select", { values: [["internal", "listenerInternal"], ["public", "listenerPublic"]] }); const currentAddresses = [b.address, ...(item.additional_addresses || [])].filter(Boolean); const startupAddress = b.tls ? latestStatus?.listeners?.locked_https : latestStatus?.listeners?.locked_http; const startupLocked = !!startupAddress && currentAddresses.includes(startupAddress); const readAddresses = addressTagEditor(grid, currentAddresses, startupLocked ? [startupAddress] : []); const enabled = field(grid, "listener-enabled", "vhEnabled", item.enabled !== false, "checkbox"); const tls = field(grid, "listener-tls", "vhTls", !!b.tls, "checkbox"); const h2 = field(grid, "listener-h2", "vhHttp2", b.tls && b.http2 !== false, "checkbox"); const h2c = field(grid, "listener-h2c", "listenerH2c", !b.tls && !!b.h2c, "checkbox"); const preservedHttp2 = effectiveHttp2Settings(b); const protocolActions = el("div", undefined, "listener-protocol-settings-actions vh-wide"); row.append(protocolActions); const h2Settings = button(protocolActions, "h2Configure", () => { window.rustProxySettings?.select("connections"); window.rustProxySettings?.selectHttpTab?.("http2"); document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" }); }); h2Settings.classList.add("listener-h2-settings-link"); const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null); const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox"); const h3Settings = button(protocolActions, "h3Configure", () => { window.rustProxySettings?.select("connections"); window.rustProxySettings?.selectHttpTab?.("http3"); document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" }); }); h3Settings.classList.add("listener-h3-settings-link"); const sync = () => { h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked; h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked)); h3Settings.hidden = !tls.checked || !h3.checked; }; for (const input of [tls, h2, h2c, h3]) input.addEventListener("change", sync); sync(); const removeButton = button(row, "remove", () => { if (startupLocked) return; const users = (envelope?.config.vhosts || []).filter(v => v.frontend.listener_ids?.includes(id.value.trim())); if (users.length) { toast(`${t("listenerInUse")}: ${users.map(v => v.id).join(", ")}`, true); return; } row.remove(); setSettingsDirty("listeners-form"); }); if (startupLocked) { row.classList.add("listener-startup-locked"); enabled.disabled = true; tls.disabled = true; removeButton.disabled = true; removeButton.title = t("listenerStartupLocked"); row.append(label("p", "listenerStartupLocked", "fine")); } row.read = () => { const addresses = readAddresses(); if (!addresses.length || addresses.length > 16 || new Set(addresses).size !== addresses.length) throw fieldError(readAddresses.input, t("listenerAddressesInvalid")); if (addresses.length > 1 && addresses.some(value => /^0\.0\.0\.0:/.test(value) || /^\[?::\]?:/.test(value))) throw fieldError(readAddresses.input, t("listenerAddressesExplicit")); const http2Enabled = (tls.checked && h2.checked) || (!tls.checked && h2c.checked); return { id: id.value.trim(), name: name.value.trim(), scope: scope.value, enabled: enabled.checked, binding: { address: addresses[0], tls: tls.checked, http2: tls.checked && h2.checked, http2_settings: http2Enabled ? preservedHttp2 : null, h2c: !tls.checked && h2c.checked, http3: tls.checked && h3.checked ? (preservedHttp3 || { ...h3Defaults }) : null }, additional_addresses: addresses.slice(1) }; }; $("named-listener-rows").append(row); } $("add-named-listener").addEventListener("click", () => { addRow(); setSettingsDirty("listeners-form"); }); function renderListeners() { const items = envelope?.config.listeners?.endpoints || []; $("named-listener-rows").replaceChildren(); for (const item of items) addRow(item); } function collectListeners() { const rows = [...$("named-listener-rows").children], endpoints = rows.map(row => row.read()), seen = new Set(); for (let index = 0; index < endpoints.length; index++) { const id = endpoints[index].id; if (seen.has(id)) throw fieldError(rows[index].querySelector('[name="listener-id"]'), t("listenerDuplicate")); seen.add(id); } return { endpoints }; } function renderHttp2Settings() { const root = $("http2-listener-rows"); if (!root) return; root.replaceChildren(); const items = (envelope?.config.listeners?.endpoints || []).filter(endpoint => { const binding = endpoint.binding || {}; return (binding.tls && binding.http2 !== false) || (!binding.tls && !!binding.h2c); }); const empty = $("http2-no-listeners"); empty.hidden = items.length !== 0; for (const endpoint of items) { const row = el("fieldset", undefined, "named-listener-row http2-listener-row"); row.dataset.listenerId = endpoint.id; const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); const legend = el("legend", `${endpoint.name || endpoint.id} · ${addresses}`); row.append(legend); const mode = endpoint.binding?.tls ? "H2 / TLS" : "h2c"; const meta = el("p", `${endpoint.id} · ${mode}${endpoint.enabled === false ? ` · ${t("vhDisabled")}` : ""}`, "fine"); row.append(meta); const controls = http2SettingsControls(row, endpoint.binding || {}); row.read = controls.read; row.sync = controls.sync; root.append(row); } syncHttp2Availability(); } function collectHttp2Listeners() { const listeners = structuredClone(envelope?.config.listeners || { endpoints: [] }); const rows = new Map([...$("http2-listener-rows").children].map(row => [row.dataset.listenerId, row])); for (const endpoint of listeners.endpoints || []) { const binding = endpoint.binding || {}, row = rows.get(endpoint.id); const enabled = (binding.tls && binding.http2 !== false) || (!binding.tls && !!binding.h2c); if (!row || !enabled) continue; endpoint.binding.http2_settings = row.read(); } return listeners; } function syncHttp2Availability() { const root = $("http2-listener-rows"), note = $("http2-listeners-dirty"), save = $("http2-save"); if (!root || !note || !save) return; const blocked = settingsDirty.has("listeners-form"); note.hidden = !blocked; for (const row of root.children) row.sync?.(blocked); save.disabled = blocked || root.children.length === 0; } function renderHttp3Settings() { const root = $("http3-listener-rows"); if (!root) return; root.replaceChildren(); const tlsItems = (envelope?.config.listeners?.endpoints || []).filter(endpoint => !!endpoint.binding?.tls); const items = tlsItems.filter(endpoint => !!endpoint.binding?.http3); const empty = $("http3-no-listeners"); empty.hidden = items.length !== 0; if (!items.length) { empty.textContent = t(tlsItems.length ? "http3NoEnabledListeners" : "http3NoTlsListeners"); delete empty.dataset.i18n; } for (const endpoint of items) { const row = el("fieldset", undefined, "named-listener-row http3-listener-row"); row.dataset.listenerId = endpoint.id; const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); const legend = el("legend", `${endpoint.name || endpoint.id} · ${addresses}`); row.append(legend); const meta = el("p", `${endpoint.id}${endpoint.enabled === false ? ` · ${t("vhDisabled")}` : ""}`, "fine"); row.append(meta); const controls = http3SettingsControls(row, endpoint.binding || {}); row.read = controls.read; row.sync = controls.sync; root.append(row); } syncHttp3Availability(); } function collectHttp3Listeners() { const listeners = structuredClone(envelope?.config.listeners || { endpoints: [] }); const rows = new Map([...$("http3-listener-rows").children].map(row => [row.dataset.listenerId, row])); for (const endpoint of listeners.endpoints || []) { const row = rows.get(endpoint.id); if (!row || !endpoint.binding?.tls || !endpoint.binding?.http3) continue; endpoint.binding.http3 = row.read(); } return listeners; } function syncHttp3Availability() { const root = $("http3-listener-rows"), note = $("http3-listeners-dirty"), save = $("http3-save"); if (!root || !note || !save) return; const blocked = settingsDirty.has("listeners-form"); note.hidden = !blocked; for (const row of root.children) row.sync?.(blocked); save.disabled = blocked || root.children.length === 0; } function bindings(parent, selected = [], selectDefault = false) { const choices = new Map(), listeners = new Map(), watchers = [], box = el("div", undefined, "listener-choices vh-wide"); parent.append(box); box.append(label("p", "listenerChooseHint", "fine")); const items = envelope?.config.listeners?.endpoints || [], autoIds = new Set(); if (selectDefault && !selected.length) { const hasPort = (endpoint, port) => [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).some(address => String(address).match(/:(\d+)$/)?.[1] === String(port)); const candidates = items.filter(endpoint => endpoint.enabled !== false); const prefer = (tls, port) => candidates.find(endpoint => endpoint.scope === "public" && !!endpoint.binding?.tls === tls && hasPort(endpoint, port)) || candidates.find(endpoint => !!endpoint.binding?.tls === tls && hasPort(endpoint, port)); const http80 = prefer(false, 80); if (http80) autoIds.add(http80.id); if (!autoIds.size) { const fallback = candidates.find(endpoint => !endpoint.binding?.tls) || candidates[0]; if (fallback) autoIds.add(fallback.id); } } const notify = () => watchers.forEach(run => run()); for (const endpoint of items) { const checked = selected.includes(endpoint.id) || autoIds.has(endpoint.id); const input = field(box, `bind-${endpoint.id.replace(/[^A-Za-z0-9_-]/g, "-")}`, "listenerEndpoint", checked, "checkbox"); const binding = endpoint.binding || {}, protocols = [binding.tls ? "HTTPS" : "HTTP"]; if (binding.tls && binding.http2 !== false) protocols.push("H2"); if (!binding.tls && binding.h2c) protocols.push("H2C"); if (binding.tls && (!Object.hasOwn(binding, "http3") || binding.http3 !== null)) protocols.push("H3"); const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); input.nextElementSibling.textContent = `${endpoint.name || endpoint.id} / ${protocols.join("+")} ${addresses} / ${t(endpoint.scope === "public" ? "listenerPublic" : "listenerInternal")}${endpoint.enabled === false ? ` / ${t("vhDisabled")}` : ""}`; delete input.nextElementSibling.dataset.i18n; choices.set(endpoint.id, input); listeners.set(endpoint.id, endpoint); input.addEventListener("change", notify); } for (const id of selected) if (!items.some(e => e.id === id)) { const input = field(box, `bind-${id}`, "listenerMissing", true, "checkbox"); input.nextElementSibling.textContent = `${t("listenerMissing")}: ${id}`; choices.set(id, input); input.addEventListener("change", notify); } const read = () => [...choices].filter(([, input]) => input.checked).map(([id]) => id); const capabilities = () => { const selectedIds = new Set(read()); let http2 = false, http3 = false; for (const [id, endpoint] of listeners) { if (!selectedIds.has(id) || endpoint.enabled === false) continue; const binding = endpoint.binding || {}; http2 ||= !!((binding.tls && binding.http2 !== false) || (!binding.tls && binding.h2c)); http3 ||= !!(binding.tls && (!Object.hasOwn(binding, "http3") || binding.http3 !== null)); } return { http2, http3 }; }; return { read, capabilities, onChange(run) { watchers.push(run); run(); } }; } $("certificate-upload-form").addEventListener("submit", async event => { event.preventDefault(); if (uploadBusy || !envelope || !authenticated || busy) return; const form = event.currentTarget, epoch = authEpoch, revision = envelope.revision; let keyPem = ""; uploadBusy = true; try { await withFormSubmitState(form, event.submitter, async () => { const cert = $("cert-import-pem").files[0], key = $("cert-import-key").files[0], id = $("cert-import-name").value.trim(); if ((envelope.config.tls?.certificates || []).some(c => c.id === id)) throw fieldError($("cert-import-name"), t("certDuplicate")); if (!cert) throw fieldError($("cert-import-pem"), t("vhSelectCertificateFiles")); if (!key) throw fieldError($("cert-import-key"), t("vhSelectCertificateFiles")); if (cert.size > 131072) throw fieldError($("cert-import-pem"), t("vhCertificateTooLarge")); if (key.size > 131072) throw fieldError($("cert-import-key"), t("vhCertificateTooLarge")); keyPem = await key.text(); const uploaded = await api("/api/certificates/upload", { method: "POST", body: JSON.stringify({ cert_pem: await cert.text(), key_pem: keyPem, hosts: $("cert-import-domains").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean) }) }); if (!authenticated || epoch !== authEpoch) return; if (envelope.revision !== revision) throw new Error(t("stale")); const next = structuredClone(envelope.config); next.tls ||= {}; next.tls.certificates ||= []; next.tls.certificates.push({ ...uploaded.certificate, id, hosts: [] }); if ($("cert-import-default").checked) next.tls.default_certificate = id; if (await saveSection("tls", next.tls || {})) { form.reset(); window.rustProxyCertificates?.close(); } }); } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { keyPem = ""; uploadBusy = false; } }); window.rustProxyIntegrations = { bindings, hostnames, renderListeners, collectListeners, renderHttp2Settings, collectHttp2Listeners, syncHttp2Availability, renderHttp3Settings, collectHttp3Listeners, syncHttp3Availability, reset() { $("certificate-upload-form").reset(); $("named-listener-rows").replaceChildren(); $("http2-listener-rows")?.replaceChildren(); $("http3-listener-rows")?.replaceChildren(); } }; if (envelope) renderSettings(); })();