function basicAuthEditor(parent, settings = {}) { const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 }); const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox"); const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0; function add(user = {}) { if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user"); const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 }); const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword"); const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row); row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); }; } for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp"); return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) }; } function hostTagEditor(parent, hosts = [], onUpdate = () => {}) { const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts")); const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true; 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("vhHostAdd")); const values = []; 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 = () => { hidden.value = values.join("\n"); onUpdate([...values]); 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(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } }; const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); }; 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(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false; return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; }; } function load(value = defaults(), migrationIds = [], show = true) { draftEpoch++; stagedCertificates = []; uploading = false; editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel"); let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp"); let refreshCanonical = () => {}; const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox"); window.rustProxyEnableControl(enabledControl, "vhEnableHint"); const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] }); const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || ""); const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource(); note(g, "sourceHelp"); const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox"); const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1"; const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] }); note(g, "vhWebsocketTransportHelp"); const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox"); note(g, "vhWebsocketLegacySupportHelp"); g = group("vhFrontends"); // Keep the list of names beside the WWW routing control. Every name in // frontend.hosts remains an independent alias of this vhost. readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts)); const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read; note(g, "listenerDefaultPortsHint"); field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox"); note(g, "vhForceSslHelp"); const wwwPanel = el("section", undefined, "vh-wide vh-www-panel"); const wwwHeader = el("div", undefined, "vh-www-header"); const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title"); const wwwHostCount = el("span", "", "vh-www-host-count"); const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox"); wwwRedirect.closest("label").classList.add("vh-www-toggle"); wwwHeader.prepend(wwwTitle, wwwHostCount); const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive"); const wwwDetails = el("div", undefined, "vh-www-details"); wwwDetails.id = "vh-www-details"; wwwRedirect.setAttribute("aria-controls", wwwDetails.id); const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title"); const wwwChoices = el("div", undefined, "vh-www-choices"); wwwChoices.setAttribute("role", "radiogroup"); wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget")); const wwwTarget = document.createElement("input"); wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target"; const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts"); const wwwOther = el("div", undefined, "vh-www-other"); const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help"); wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp); wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget); g.append(wwwPanel); // Persist only the selected canonical host; choosing the www variant does // not require adding an extra hostname to the vhost manually. let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || ""; const syncCanonical = () => { const allHosts = lines(read("hosts").value); const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host)); const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))] .filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253); const options = bases.flatMap(base => [base, `www.${base}`]); if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || ""; wwwTarget.value = selectedCanonical; wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length)); wwwChoices.replaceChildren(); for (const base of bases) { const pair = el("div", undefined, "vh-www-domain-pair"); pair.append(el("strong", base, "vh-www-domain-label")); const directions = el("div", undefined, "vh-www-pair-directions"); for (const target of [base, `www.${base}`]) { const companion = target === base ? `www.${base}` : base; const choice = el("label", undefined, "vh-www-choice"); if (target === selectedCanonical) choice.classList.add("is-selected"); const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice"; radio.value = target; radio.checked = target === selectedCanonical; const copy = el("span", undefined, "vh-www-choice-copy"); copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route")); choice.append(radio, copy); radio.addEventListener("change", () => { if (!radio.checked) return; selectedCanonical = target; syncCanonical(); changed(); }); directions.append(choice); } pair.append(directions); wwwChoices.append(pair); } const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`; const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion); wwwOther.replaceChildren(); if (aliases.length) { wwwOther.append(text("span", "vhWwwOtherDomains")); for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias")); } wwwNoHosts.hidden = bases.length > 0; wwwRedirect.disabled = !bases.length; if (!bases.length) wwwRedirect.checked = false; // Never hide the destination choices: the unchecked state is a preview // that the operator can configure before enabling the rule. wwwDetails.hidden = false; wwwInactive.hidden = wwwRedirect.checked; wwwRedirect.setAttribute("aria-expanded", "true"); wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked); }; refreshCanonical = syncCanonical; wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); }); syncCanonical(); field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox"); note(g, "vhForwardClientIpHelp"); field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox"); const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow); const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox"); const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox"); note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly"); const syncFrontendTransports = () => { const capabilities = bindingControl.capabilities(); h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3; if (!capabilities.http2) h2Enabled.checked = false; if (!capabilities.http3) h3Enabled.checked = false; const h2Available = capabilities.http2 && h2Enabled.checked; const h3Available = capabilities.http3 && h3Enabled.checked; websocketMode.disabled = !websocketEnabled.checked; websocketLegacy.disabled = !websocketEnabled.checked; const optionH2 = websocketMode.querySelector('option[value="h2"]'); const optionH3 = websocketMode.querySelector('option[value="h3"]'); const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]'); optionH2.disabled = !h2Available; optionH3.disabled = !h3Available; optionH2H3.disabled = !h2Available || !h3Available; const selected = websocketMode.value; if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) { websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1"; } }; websocketEnabled.addEventListener("change", syncFrontendTransports); bindingControl.onChange(syncFrontendTransports); h2Enabled.addEventListener("change", syncFrontendTransports); h3Enabled.addEventListener("change", syncFrontendTransports); syncFrontendTransports(); g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members); (value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); })); g = group("vhBackendDiagnostics"); field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox"); field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox"); field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox"); note(g, "vhBackendDiagnosticsHelp"); const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) }; g = group("vhProxyTimeouts"); field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 }); field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 }); field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 }); field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 }); note(g, "vhProxyTimeoutsHelp"); const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {}; g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] }); field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 }); field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 }); field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox"); field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox"); field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 }); note(g, "vhRetryHelp"); field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox"); field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 }); field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 }); field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 }); field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 }); field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 }); field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 }); note(g, "vhPoolRecycleHelp"); g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] }); const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning(); field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 }); field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 }); for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote"); const passive = settings.passive_health || {}; g = group("vhPassiveHealth"); const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox"); note(g, "vhPassiveHelp"); const passiveFields = [ field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"), field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"), field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"), field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"), field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"), field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }), field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 }) ]; const passive5xx = passiveFields[3], passiveSlow = passiveFields[4]; const passive5xxWarning = note(g, "vhPassive5xxWarning"); const passiveSlowWarning = note(g, "vhPassiveSlowWarning"); const syncPassive = () => { passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; }); passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked; passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked; }; passiveEnabled.addEventListener("change", syncPassive); passive5xx.addEventListener("change", syncPassive); passiveSlow.addEventListener("change", syncPassive); syncPassive(); g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] }); field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" }); field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote"); const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {}; g = group("vhTrafficPolicy"); const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select"); const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp"); field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] }); field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 }); note(g, "vhMethodHelp"); field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox"); field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 }); field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 }); field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 }); field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] }); field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 }); note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp"); const security = value.security_headers || {}, hsts = security.hsts || {}; g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 }); field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox"); field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] }); field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 }); field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 }); field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 }); g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 }); field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 }); field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 }); g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []); g = group("vhSsl"); const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select"); const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates(); g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint"); const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; const tlsPolicy = value.tls_policy || inheritedTlsPolicy; const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox"); const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox"); const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox"); const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 }); const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; }; customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp"); g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g; const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] }); note(g, "authModeHelp"); const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody); aclBody.append(text("h3", "vhIpAccess")); aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); }); const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody); taBody.append(text("h3", "authTinyauth")); const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary); const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox"); const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 }); const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 }); authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login"; field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox"); field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox"); authEnable.closest("label").hidden = true; note(taBody, "taHelp"); const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced")); const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced); field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 }); field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 }); field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 }); field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 }); field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 }); field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 }); basicBody.append(text("h3", "authBasic")); const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid); basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {}); const syncAuth = () => { authEnable.checked = authMode.value === "tinyauth"; taBody.hidden = !authEnable.checked; basicBody.hidden = authMode.value !== "basic"; authUrl.required = loginUrl.required = authEnable.checked; }; authMode.addEventListener("change", syncAuth); syncAuth(); g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers; const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] }); const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 }); const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer(); const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = []; for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox")); const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp"); g = group("vhCompression"); const compression = value.compression || {}; const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] }); const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled); const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions); const compressionCodecInputs = [ compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd), compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br), compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip), compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate) ]; const syncCompressionVisibility = () => { const disabled = compressionToggle.value === "off"; compressionOptions.hidden = disabled; for (const input of compressionCodecInputs) input.disabled = disabled; }; compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility(); note(g, "vhCompressionHelp"); g = group("vhCache"); const cache = value.cache || {}; const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] }); const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled); if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled"); const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions); field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 }); field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 }); field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 }); tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query); tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies); const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox"); const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 }); const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp"); tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox"); field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint"); const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox"); const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 }); const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache(); note(cacheOptions, "vhCacheForceHelp"); if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing))); const syncCacheVisibility = () => { const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"; cacheOptions.hidden = !enabled; }; cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility(); g = group("vhTransform"); const transform = value.transform || { enabled: false }; const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox"); const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions); for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]); const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox"); const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 }); const syncTransform = () => { transformOptions.hidden = !transformEnabled.checked; transformExtensions.disabled = !customTransformExtensions.checked; }; transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform(); note(transformOptions, "vhTransformHelp"); g = group("vhHeaders"); headerEditors(g, value.headers || {}); g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {}); initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal(); } function collect() { locationsReader(); // Validate every visual row; never silently save an earlier JSON value. let paths, acl, headers; try { paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers")); if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error(); } catch { throw new Error(t("vhJsonInvalid")); } const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url); const bindingSelection = readBindings(); const mainSourceType = str("source-type"); const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); if (!bindingSelection.length) throw new Error(t("vhListenerRequired")); if (needsProxy && !members.length) throw new Error(t("vhBackendRequired")); if (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts")); if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired")); if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired")); if (yes("force-ssl")) { const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false; for (const id of bindingSelection) { const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id); if (!endpoint || endpoint.enabled === false) continue; if (!endpoint.binding?.tls) { httpSelected = true; continue; } tlsSelected = true; for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) { const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1])); } } if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp")); if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls")); if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict")); } if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired")); const selectedUsesHttp3 = bindingSelection.some(id => { const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id); return !!endpoint?.binding?.http3; }); if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) { const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() }; const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() }; if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict")); } return { id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"), proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") }, rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() }, method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) }, server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") }, diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") }, tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers, load_balancing: { algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") }, retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) }, health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") }, passive_health: { enabled: yes("passive-enabled"), count_connect_errors: yes("passive-connect"), count_timeouts: yes("passive-timeouts"), count_io_errors: yes("passive-io"), count_http_5xx: yes("passive-5xx"), count_slow_responses: yes("passive-slow"), fall: str("passive-fall") === "" ? null : num("passive-fall"), rise: str("passive-rise") === "" ? null : num("passive-rise") }, sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") } }, security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") } }; }