Files
RustProxy/src/admin/web/vhosts/editor.js
T
2026-10-11 17:10:04 +02:00

474 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; backendHealthEditorControl = null;
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");
backendHealthEditorControl = backendHealthEditor(g, members);
note(g, "backendHealthOverridesHelp");
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 });
const uaBlock = value.user_agent_blocking || {};
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(g, "vhUaBlockHelp");
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");
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
note(g, "vhErrorFallbackHelp");
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); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
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),
health_check_enabled: yes("member-health-enabled", row),
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
} : null,
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 mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
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" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, 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") },
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
};
}