This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
"use strict";
// Shared controls for named listeners, HTTP/2/HTTP/3 and the certificate inventory.
// All labels are resolved from discovered language packs.
(() => {
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"]) 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 };
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 });
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)) { value[name] = Number(input.value); if (!Number.isSafeInteger(value[name])) throw fieldError(input, t("vhJsonInvalid")); } 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 h2Settings = button(row, "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(row, "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();
})();