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

+184
View File
@@ -0,0 +1,184 @@
"use strict";
(() => {
let job = null, pending = false, loading = false, renewalLogs = {};
const picker = $("certificate-provider");
const secrets = ["acme-secret", "acme-cf-token", "acme-ovh-application-key", "acme-ovh-application-secret", "acme-ovh-consumer-key"];
const tabs = [...document.querySelectorAll("[data-certificate-tab]")];
const requested = typeof location !== "undefined" && location.pathname === "/tls" ? new URLSearchParams(location.search).get("tab") : null;
let activeTab = tabs.some(button => button.dataset.certificateTab === requested) ? requested : "installed";
function syncLocation() {
if (typeof location === "undefined" || location.pathname !== "/tls" || !["http:", "https:"].includes(location.protocol)) return;
const url = new URL(location.href); url.search = ""; url.searchParams.set("tab", activeTab);
history.replaceState({ ...(history.state || {}), panel: "tls", certificateTab: activeTab }, "", `${url.pathname}${url.search}${url.hash}`);
}
function selectTab(name, focus = false, persist = true) {
if (!tabs.some(button => button.dataset.certificateTab === name)) return;
activeTab = name;
for (const button of tabs) {
const selected = button.dataset.certificateTab === name;
button.setAttribute("aria-selected", String(selected)); button.tabIndex = selected ? 0 : -1;
if (selected && focus) button.focus();
}
document.querySelectorAll("[data-certificate-page]").forEach(page => { page.hidden = page.dataset.certificatePage !== name; });
if (persist) syncLocation();
}
for (const button of tabs) {
button.addEventListener("click", () => selectTab(button.dataset.certificateTab));
button.addEventListener("keydown", event => {
const index = tabs.indexOf(button); let next;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % tabs.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + tabs.length - 1) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
else return;
event.preventDefault(); selectTab(tabs[next].dataset.certificateTab, true);
});
}
const clearSecrets = () => { for (const id of secrets) $(id).value = ""; };
function syncProvider() {
const provider = picker.value, acme = !["import", "existing"].includes(provider);
$("certificate-acme-editor").hidden = !acme;
$("certificate-upload-form").hidden = provider !== "import";
$("certificate-advanced").hidden = provider !== "existing";
$("certificate-provider-hint").textContent = t(`providerHint_${provider}`);
for (const node of document.querySelectorAll("[data-acme-provider]")) {
const visible = acme && (node.dataset.acmeProvider === provider || (node.dataset.acmeProvider === "dns" && provider !== "http01"));
node.hidden = !visible;
for (const input of node.querySelectorAll("input,select")) input.disabled = !visible;
}
$("acme-domains").placeholder = provider === "http01" ? "example.org, www.example.org" : "example.org, *.example.org";
}
function open(provider = "http01") {
selectTab("generate"); picker.value = provider;
if (provider !== "existing") { $("acme-environment").value = "production"; $("acme-confirm-production").required = true; }
syncProvider();
}
function close() { clearSecrets(); selectTab("installed"); }
picker.addEventListener("change", () => { clearSecrets(); syncProvider(); });
$("certificate-new").addEventListener("click", () => open());
const stateKey = value => ({ running: "certificateStateRunning", ready: "certificateStateReady", complete: "certificateStateComplete", failed: "certificateStateFailed" }[value?.status] || "certificateStateIdle");
function renderOperation(cardId, messageId, stateId, logId, value, idleKey) {
const card = $(cardId), message = $(messageId), state = $(stateId), log = $(logId);
card.dataset.state = value?.status || "idle";
state.textContent = t(stateKey(value));
if (!value) message.textContent = t(idleKey);
else message.textContent = `${value.certificate_id} · ${value.provider} / ${value.environment} · ${t(value.message)}`;
const showLog = value?.status === "failed" && !!value.log_directory;
log.hidden = !showLog;
log.textContent = showLog ? `${t("certbotLogs")}: ${value.log_directory}` : "";
}
function render(value) {
job = value; $("acme-submit").disabled = pending || value?.status === "running";
const issue = value?.kind === "issue" ? value : null;
const renewal = value?.kind?.startsWith("renewal") ? value : null;
renderOperation("acme-issue-status-card", "acme-job", "acme-issue-state", "acme-log-path", issue, "acmeIdle");
renderOperation("acme-renewal-status-card", "acme-renewal-job", "acme-renewal-state", "acme-renewal-log", renewal, "certificateNoRenewalOperation");
const installed = issue?.certificate && envelope?.config.tls?.certificates?.some(c => c.id === issue.certificate.id && c.cert_file === issue.certificate.cert_file && c.key_file === issue.certificate.key_file);
$("acme-import").hidden = issue?.status !== "ready" || !issue.certificate || !!installed;
}
function renewalMeta(parent, key, value) {
const item = el("div", undefined, "renewal-meta-item");
item.append(el("span", t(key), "small-label"), el("strong", value)); parent.append(item);
}
function renderRenewals(values = {}, logs = {}) {
renewalLogs = logs || {};
const list = $("renewal-list"); list.replaceChildren();
const certificates = envelope?.config.tls?.certificates || [];
const stamp = value => value ? new Date(value * 1000).toLocaleString(locale()) : "-";
for (const cert of certificates.filter(c => c.issuer)) {
const state = values[cert.id], enabled = cert.issuer.auto_renew !== false;
const entry = el("div", undefined, "renewal-entry"); entry.dataset.state = state?.status || "idle";
const head = el("div", undefined, "renewal-entry-head"), identity = el("div", undefined, "renewal-entry-identity");
identity.append(el("strong", cert.id), el("span", `${cert.issuer.provider} / ${cert.issuer.environment}`, "fine"));
const mode = el("span", t(enabled ? "renewalEnabled" : "renewalDisabled"), "pill"); head.append(identity, mode); entry.append(head);
const meta = el("div", undefined, "renewal-meta");
renewalMeta(meta, "renewalLast", stamp(state?.last_checked));
renewalMeta(meta, "renewalNext", enabled ? stamp(state?.next_check) : "-");
renewalMeta(meta, "renewalExpires", stamp(state?.not_after));
entry.append(meta);
if (state?.message) {
const note = el("p", t(state.message), `fine renewal-message${state.status === "failed" ? " status-error" : ""}`); entry.append(note);
if (state.status === "failed" && renewalLogs[cert.id]) entry.append(el("p", `${t("certbotLogs")}: ${renewalLogs[cert.id]}`, "fine certificate-log-path"));
}
const controls = el("div", undefined, "renewal-actions");
const b = (key, handler) => { const node = el("button", t(key), "quiet"); node.type = "button"; node.dataset.action = key; node.addEventListener("click", handler); controls.append(node); return node; };
b(enabled ? "renewalDisable" : "renewalEnable", async () => {
if (!envelope) return; const next = structuredClone(envelope.config), target = next.tls.certificates.find(c => c.id === cert.id); if (!target?.issuer) return;
target.issuer.auto_renew = !enabled; if (await saveSection("tls", next.tls || {})) void refresh();
});
for (const [key, dry_run] of [["renewalNow", false], ["renewalDryRun", true]]) {
const node = b(key, async () => {
node.disabled = true; const epoch = authEpoch;
try {
const data = await api("/api/acme/renew", { method: "POST", body: JSON.stringify({ certificate_id: cert.id, dry_run }) });
if (epoch === authEpoch && authenticated) { render(data.job); selectTab("renewal"); toast(t("acme.renewing")); }
} catch (error) {
if (epoch === authEpoch) toast(error.message, true);
} finally { node.disabled = false; }
});
node.disabled = job?.status === "running";
}
entry.append(controls); list.append(entry);
}
if (!list.children.length) list.append(el("p", t("renewalNone"), "empty"));
}
async function refresh() {
if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch;
try {
const data = await api("/api/acme");
if (authenticated && epoch === authEpoch) { render(data.job); renderRenewals(data.renewals, data.log_directories); }
} catch (error) {
if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); }
} finally { loading = false; }
}
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
$("acme-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated || pending || job?.status === "running") return;
const provider = picker.value, input = {
provider, certificate_id: $("acme-name").value.trim(), email: $("acme-email").value.trim(), domains: $("acme-domains").value.split(/[,\n]/).map(v => v.trim()).filter(Boolean), environment: $("acme-environment").value,
agree_tos: $("acme-tos").checked, confirm_production: $("acme-confirm-production").checked
};
if (provider === "rfc2136") Object.assign(input, { dns_server: $("acme-dns").value.trim(), dns_port: Number($("acme-port").value), key_name: $("acme-key-name").value.trim(), key_secret: $("acme-secret").value, key_algorithm: $("acme-algorithm").value, sign_query: $("acme-sign-query").checked });
else if (provider === "cloudflare") input.api_token = $("acme-cf-token").value;
else if (provider === "ovh") Object.assign(input, { endpoint: $("acme-ovh-endpoint").value, application_key: $("acme-ovh-application-key").value, application_secret: $("acme-ovh-application-secret").value, consumer_key: $("acme-ovh-consumer-key").value });
else if (provider !== "http01") return;
if (provider !== "http01") input.propagation_seconds = Number($("acme-propagation").value);
if (provider === "http01" && input.domains.some(d => d.startsWith("*."))) { toast(t("acme.http01_no_wildcard"), true); return; }
if (input.environment === "production" && !input.confirm_production) { toast(t("acme.production_confirmation"), true); return; }
pending = true; $("acme-submit").disabled = true; const epoch = authEpoch;
try {
const value = await api("/api/acme/issue", { method: "POST", body: JSON.stringify(input) });
if (epoch === authEpoch && authenticated) { render(value.job); selectTab("generate"); toast(t("acme.working")); }
} catch (error) { if (epoch === authEpoch) toast(error.message, true); }
finally {
for (const key of ["key_secret", "api_token", "application_key", "application_secret", "consumer_key"]) delete input[key];
clearSecrets(); pending = false; $("acme-submit").disabled = job?.status === "running";
}
});
$("acme-refresh").addEventListener("click", () => void refresh());
$("acme-import").addEventListener("click", async () => {
if (!envelope || !authenticated || !job?.certificate) return;
const cert = structuredClone(job.certificate), next = structuredClone(envelope.config); next.tls ||= {}; next.tls.certificates ||= [];
const index = next.tls.certificates.findIndex(c => c.id === cert.id);
if (index >= 0) { if (!(await appConfirm(t("acmeReplace"), { danger: true }))) return; cert.hosts = next.tls.certificates[index].hosts || []; next.tls.certificates[index] = cert; }
else next.tls.certificates.push(cert);
if (await saveSection("tls", next.tls || {})) { toast(t("acmeImported")); $("acme-import").hidden = true; selectTab("installed"); }
});
setInterval(() => { if (!document.hidden) void refresh(); }, 3000);
window.rustProxyAcme = {
refresh,
select: selectTab,
syncLocation,
current: () => activeTab,
reset() {
job = null; renewalLogs = {}; $("renewal-list").replaceChildren(); $("acme-form").reset(); $("acme-environment").value = "production"; $("acme-confirm-production").required = true;
render(null); selectTab("installed", false, false);
}
};
window.rustProxyCertificates = { open, close, select: selectTab, reset() { clearSecrets(); picker.value = "http01"; syncProvider(); selectTab("installed", false, false); } };
syncProvider(); selectTab(activeTab, false, false);
})();
+1005
View File
File diff suppressed because it is too large. Load diff
+309
View File
@@ -0,0 +1,309 @@
"use strict";
// Dedicated cache inspector. Entry bodies and request headers are never loaded.
(() => {
let loading = false, offset = 0, generation = 0;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null;
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
const LIVE_CHART_STORAGE_KEY = "rustproxy.cache.liveChartSamples";
let liveChartLimit = 60;
const scope = () => $("cache-scope").value;
const domainFilter = () => $("cache-domain-filter").value;
const number = v => fmt(v ?? 0);
const operationMessage = (id, value = "", error = false) => {
const box = $(id);
if (!box) return;
box.textContent = value;
box.classList.toggle("error", Boolean(value) && error);
};
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(value); return; } catch { }
}
const area = document.createElement("textarea");
area.value = value;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
document.execCommand("copy");
area.remove();
}
function makeCopyCell(value) {
const td = el("td", value, "cache-copy-cell");
td.title = value;
td.tabIndex = 0;
td.setAttribute("role", "button");
const copy = () => void copyText(value);
td.addEventListener("click", copy);
td.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
});
return td;
}
const cacheEnabledFor = (data, row) => {
const scoped = (data.vhosts || []).find(vhost => vhost.id === row.vhost);
return scoped?.settings?.enabled ?? data.settings?.enabled ?? false;
};
const visibleDomains = (data, groups) => {
const filter = domainFilter();
if (filter === "all") return groups;
const wanted = filter === "enabled";
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
};
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
function resetLiveChart(selected = scope()) {
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null;
for (const values of Object.values(liveChartSamples)) values.length = 0;
for (const id of ["cache-live-hit-line", "cache-live-miss-line", "cache-live-bypass-line"]) $(id)?.setAttribute("points", "0,170 900,170");
for (const id of ["cache-live-hit-rate", "cache-live-miss-rate", "cache-live-bypass-rate"]) if ($(id)) $(id).textContent = "0.0/s";
if ($("cache-live-chart-value")) $("cache-live-chart-value").textContent = "-";
}
function redrawLiveChart() {
const peak = Math.max(1, ...liveChartSamples.hits, ...liveChartSamples.misses, ...liveChartSamples.bypasses);
$("cache-live-hit-line")?.setAttribute("points", sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak));
$("cache-live-miss-line")?.setAttribute("points", sparklinePoints(liveChartSamples.misses, 900, 180, 10, peak));
$("cache-live-bypass-line")?.setAttribute("points", sparklinePoints(liveChartSamples.bypasses, 900, 180, 10, peak));
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60;
for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit);
if ($("cache-live-samples")) $("cache-live-samples").value = String(liveChartLimit);
if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch { } }
redrawLiveChart();
}
function initializeLiveChartLimit() {
let saved = 60;
try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch { }
setLiveChartLimit(saved, false);
}
function renderLiveChart(counters) {
const selected = scope();
if (liveChartScope !== selected) resetLiveChart(selected);
const now = performance.now(), current = {
hits: Math.max(0, Number(counters.hits) || 0),
misses: Math.max(0, Number(counters.misses) || 0),
bypasses: Math.max(0, Number(counters.bypasses) || 0)
}, rates = { hits: 0, misses: 0, bypasses: 0 };
if (liveChartPrevious && liveChartPreviousAt != null) {
const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000);
for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed;
}
liveChartPrevious = current; liveChartPreviousAt = now;
for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); }
redrawLiveChart();
$("cache-live-hit-rate").textContent = `${rates.hits.toFixed(1)}/s`;
$("cache-live-miss-rate").textContent = `${rates.misses.toFixed(1)}/s`;
$("cache-live-bypass-rate").textContent = `${rates.bypasses.toFixed(1)}/s`;
$("cache-live-chart-value").textContent = `Σ ${(rates.hits + rates.misses + rates.bypasses).toFixed(1)}/s`;
}
function configure() {
const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*";
for (const host of envelope?.config.vhosts || []) { const item = el("option", `${host.id} / ${host.frontend.hosts.join(", ")}`); item.value = host.id; items.push(item); }
$("cache-scope").replaceChildren(...items); $("cache-scope").value = items.some(e => e.value === selected) ? selected : "*";
}
function render(data) {
const m = data.metrics || {}, d = data.dashboard || { domains: [], objects: [], matching_objects: 0 }, groups = d.domains || [], shownGroups = visibleDomains(data, groups);
const total = field => groups.reduce((n, row) => n + (row[field] || 0), 0), all = scope() === "*";
const hits = total("hits"), misses = total("misses");
const hotEntries = all ? (m.entries || 0) : total("hot_entries"), coldEntries = all ? (m.cold_entries || 0) : total("cold_entries");
$("cp-entries").textContent = number(hotEntries);
$("cp-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.max_entries)}`;
const resident = all ? (m.hot_allocated_bytes ?? m.resident_bytes ?? 0) : total("resident_bytes");
const logical = all ? (m.hot_logical_bytes ?? 0) : total("logical_bytes");
const hotLimit = m.hot_limit_bytes ?? data.settings?.max_bytes ?? 0;
$("cp-bytes").textContent = fmtBytes(resident); $("cp-memory").value = resident / Math.max(1, hotLimit) * 100;
$("cp-memory-detail").textContent = `${t("cacheLogicalShort")}: ${fmtBytes(logical)} · ${t("cacheLimitShort")}: ${fmtBytes(hotLimit)}`;
$("cp-transient").textContent = `${fmtBytes(m.transient_bytes)} / ${fmtBytes(m.transient_limit_bytes ?? data.settings?.transient_max_bytes)}`;
$("cp-transient-detail").textContent = `${t("cacheFillShort")}: ${fmtBytes(m.fill_bytes_active)} · ${t("cacheQueueShort")}: ${fmtBytes(m.cold_queue_bytes)} / ${fmtBytes(m.cold_queue_limit_bytes ?? data.settings?.cold_queue_max_bytes)} · ${t("cacheIoShort")}: ${fmtBytes((Number(m.cold_write_bytes_active) || 0) + (Number(m.cold_read_bytes_active) || 0))}`;
const coldBytes = all ? (m.cold_bytes || 0) : total("cold_bytes");
$("cp-cold-entries").textContent = number(coldEntries); $("cp-cold-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.cold_max_entries || 0)}`;
$("cp-cold-bytes").textContent = fmtBytes(coldBytes); $("cp-cold-disk").value = coldBytes / Math.max(1, data.settings?.cold_max_bytes || 1) * 100;
$("cp-ratio").textContent = `${fmt((all ? (m.hit_ratio || 0) : (hits + misses ? hits / (hits + misses) : 0)) * 100, 1)}%`;
$("cp-requests").textContent = `${number(all ? m.hits : hits)} HIT / ${number(all ? m.misses : misses)} MISS`;
const bypasses = all ? (Number(m.bypasses) || 0) : total("bypasses");
$("cp-bypass").textContent = number(bypasses);
renderLiveChart({ hits: all ? (Number(m.hits) || 0) : hits, misses: all ? (Number(m.misses) || 0) : misses, bypasses });
$("cp-fills").textContent = `${t("cacheFills")}: ${number(m.fills_active)} · ${t("cacheCollapseWaiters")}: ${number(m.collapse_waiters)} · ${t("cacheCollapsed")}: ${number(m.collapsed_fills)}`;
$("cp-saved").textContent = fmtBytes(total("saved_body_bytes"));
$("cp-tier-moves").textContent = `${number(m.hot_demotions)} ↓ / ${number(m.cold_promotions)} ↑`;
$("cp-tier-errors").textContent = `${number(m.cold_hits)} ${t("cacheColdHits")} · ${number(m.cold_errors)} ${t("errors")} · ${number(m.cold_queue_dropped)} ${t("loggingDropped")}`;
$("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`;
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes), `${number(row.hits)} / ${number(row.misses)} / ${number(row.bypasses)}`, `${fmt((row.hit_ratio || 0) * 100, 1)}%`]) tr.append(el("td", value));
const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr;
}));
const domainEmpty = $("cache-domains-empty");
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
domainEmpty.hidden = !!shownGroups.length;
$("cache-object-rows").replaceChildren(...(d.objects || []).map(row => {
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge"));
tr.append(target);
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
tr.append(makeCopyCell(keyTags));
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
}));
const bans = data.bans || [];
$("cache-ban-rows").replaceChildren(...bans.map(rule => {
const tr = el("tr");
for (const value of [String(rule.id), rule.selector, rule.value, rule.host || "*", rule.vhost || "*", new Date(rule.expires_at_ms).toLocaleString(locale())]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cacheRemoveBan"), "quiet"); button.type = "button"; button.addEventListener("click", () => void removeBan(rule.id)); action.append(button); tr.append(action); return tr;
}));
$("cache-bans-empty").hidden = !!bans.length;
const bodyBytes = Number(d.matching_body_bytes) || 0, residentBytes = Number(d.matching_resident_bytes) || 0, diskBytes = Number(d.matching_cold_bytes) || 0;
$("cache-object-summary").textContent = `${number(d.matching_objects)} ${t("cacheObjectCount")} · ${t("cacheBodySize")}: ${fmtBytes(bodyBytes)} · ${t("cacheHotMemory")}: ${fmtBytes(residentBytes)} · ${t("cacheColdDisk")}: ${fmtBytes(diskBytes)}`;
$("cache-objects-empty").hidden = !!d.objects?.length;
$("cache-page").textContent = `${d.matching_objects ? offset + 1 : 0}-${Math.min(offset + (d.objects?.length || 0), d.matching_objects || 0)} / ${number(d.matching_objects)}`;
$("cache-prev").disabled = offset === 0; $("cache-next").disabled = offset + 50 >= (d.matching_objects || 0);
$("cache-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
$("cache-panel-cleanup").disabled = !data.settings?.cold_enabled;
}
async function runInvalidation(payload, messageTarget = "cache-invalidation-status") {
const result = await api("/api/cache/invalidate", { method: "POST", body: JSON.stringify(payload) });
const detail = result.result || {};
const message = detail.operation === "ban"
? `${t("cacheBanCreated")}: ${detail.ban_id}`
: `${t("cachePurged")}: ${number(detail.affected_entries)}`;
operationMessage(messageTarget, message);
offset = 0; generation++; await refresh();
}
async function purgeObject(row) {
if (!authenticated || !(await appConfirm(t("cacheConfirmPurgeUrl"), { danger: true }))) return;
operationMessage("cache-toolbar-operation-status");
try {
await runInvalidation(
{ operation: "purge", selector: "exact", value: row.target || row.path, host: row.authority || row.domain, vhost: row.vhost || null, ttl_seconds: null, confirm_wide: false },
"cache-toolbar-operation-status"
);
} catch (error) { operationMessage("cache-toolbar-operation-status", error.message, true); }
}
async function removeBan(id) {
if (!authenticated || !(await appConfirm(t("cacheConfirmRemoveBan"), { danger: true }))) return;
operationMessage("cache-bans-status");
try {
await api(`/api/cache/bans/${encodeURIComponent(id)}`, { method: "DELETE" });
operationMessage("cache-bans-status", t("cacheBanRemoved"));
generation++; await refresh();
} catch (error) { operationMessage("cache-bans-status", error.message, true); }
}
async function refresh() {
if (!authenticated || !envelope || currentPanel !== "cache" || loading) return;
loading = true; const epoch = authEpoch, token = generation; const query = new URLSearchParams({ offset: String(offset), limit: "50" }); if (scope() !== "*") query.set("vhost", scope());
try { const data = await api(`/api/cache?${query}`); if (epoch !== authEpoch || token !== generation || !authenticated) return; render(data); }
catch (error) { if (epoch === authEpoch) { $("cache-panel-state").textContent = error.message; if (error.status === 401) lock(); } }
finally { loading = false; if (token !== generation && authenticated && currentPanel === "cache") void refresh(); }
}
initializeLiveChartLimit();
$("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
$("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); });
$("cache-domain-filter").addEventListener("change", () => { generation++; void refresh(); });
$("cache-refresh").addEventListener("click", () => void refresh());
$("cache-prev").addEventListener("click", () => { offset = Math.max(0, offset - 50); generation++; void refresh(); });
$("cache-next").addEventListener("click", () => { offset += 50; generation++; void refresh(); });
$("cache-panel-settings").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("cache"); $("cache-form").scrollIntoView({ block: "start" }); });
const cleanupDialog = $("cache-cleanup-dialog");
const closeCleanup = () => { if (cleanupDialog.open) cleanupDialog.close(); };
$("cache-panel-cleanup").addEventListener("click", () => {
$("cache-cleanup-status").textContent = "";
cleanupDialog.showModal();
$("cache-cleanup-unused").focus();
});
$("cache-cleanup-close").addEventListener("click", closeCleanup);
$("cache-cleanup-cancel").addEventListener("click", closeCleanup);
$("cache-cleanup-form").addEventListener("submit", async event => {
event.preventDefault();
if (!authenticated) return;
const form = event.currentTarget, submit = event.submitter, epoch = authEpoch;
const unusedValue = $("cache-cleanup-unused").value.trim(), oldestValue = $("cache-cleanup-oldest").value.trim();
const payload = {
unused_for_seconds: unusedValue ? Number(unusedValue) : null,
oldest: oldestValue ? Number(oldestValue) : null,
hits_zero: $("cache-cleanup-hits-zero").checked
};
submit.disabled = true;
try {
const started = await api("/api/cache/cold/cleanup", { method: "POST", body: JSON.stringify(payload) });
const id = started.job?.id;
if (!id) throw new Error(t("cacheColdCleanupInvalidJob"));
$("cache-cleanup-status").textContent = t("cacheColdCleanupRunning");
for (; ;) {
await new Promise(resolve => setTimeout(resolve, 300));
if (epoch !== authEpoch || !authenticated) return;
const current = await api(`/api/cache/cold/cleanup/${encodeURIComponent(id)}`);
const job = current.job || {};
if (job.status === "done") {
const message = t("cacheColdCleanupDone")
.replace("{count}", number(job.removed_entries))
.replace("{bytes}", fmtBytes(job.freed_bytes));
$("cache-cleanup-status").textContent = message; toast(message);
generation++; await refresh();
break;
}
if (job.status === "failed") throw new Error(job.error || t("cacheColdCleanupFailed"));
}
} catch (error) {
if (epoch === authEpoch) { $("cache-cleanup-status").textContent = error.message; toast(error.message, true); }
} finally { submit.disabled = false; }
});
const invalidationDialog = $("cache-invalidation-dialog"), bansDialog = $("cache-bans-dialog");
const closeInvalidation = () => { if (invalidationDialog.open) invalidationDialog.close(); };
const closeBans = () => { if (bansDialog.open) bansDialog.close(); };
$("cache-invalidation-open").addEventListener("click", () => {
operationMessage("cache-invalidation-status");
$("cache-invalidation-operation").value = "purge";
$("cache-ban-ttl").disabled = true;
if (!invalidationDialog.open) invalidationDialog.showModal();
$("cache-invalidation-value").focus();
});
$("cache-bans-open").addEventListener("click", () => {
operationMessage("cache-bans-status");
if (!bansDialog.open) bansDialog.showModal();
generation++; void refresh();
});
$("cache-invalidation-close").addEventListener("click", closeInvalidation);
$("cache-bans-close").addEventListener("click", closeBans);
$("cache-invalidation-operation").addEventListener("change", () => {
operationMessage("cache-invalidation-status");
$("cache-ban-ttl").disabled = $("cache-invalidation-operation").value !== "ban";
});
$("cache-invalidation-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated) return;
const operation = $("cache-invalidation-operation").value, selector = $("cache-invalidation-selector").value, value = $("cache-invalidation-value").value.trim(), host = $("cache-invalidation-host").value.trim();
if (!value) return;
const selected = scope(), wide = selector === "regex" || (selector === "prefix" && value === "/");
if (wide && !(await appConfirm(t("cacheWideInvalidationConfirm"), { danger: true }))) return;
const submit = event.submitter; submit.disabled = true; operationMessage("cache-invalidation-status");
try {
await runInvalidation({ operation, selector, value, host: host || null, vhost: selected === "*" ? null : selected, ttl_seconds: operation === "ban" ? Number($("cache-ban-ttl").value || 3600) : null, confirm_wide: wide });
} catch (error) { operationMessage("cache-invalidation-status", error.message, true); } finally { submit.disabled = false; }
});
$("cache-panel-purge").addEventListener("click", async () => {
if (!authenticated || !(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("cache-panel-purge"), epoch = authEpoch, selected = scope(); button.disabled = true;
operationMessage("cache-toolbar-operation-status");
try {
const path = selected === "*" ? "/api/cache/purge" : `/api/cache/purge?vhost=${encodeURIComponent(selected)}`;
const result = await api(path, { method: "POST", body: "{}" });
if (epoch !== authEpoch) return;
operationMessage("cache-toolbar-operation-status", `${t("cachePurged")}: ${result.purged_entries}`);
offset = 0; generation++; await refresh();
} catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; }
});
setInterval(() => { if (!document.hidden && $("cache-live").checked) void refresh(); }, 3000);
window.rustProxyCachePanel = {
refresh, configure, reason, reset() {
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
}
};
if (envelope) configure();
})();
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>RustProxy is running.</h1>
<p>This address has no application configured. Contact the administrator for access.</p>
</main>
</body>
</html>
+360
View File
@@ -0,0 +1,360 @@
"use strict";
(() => {
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false;
const LIVE_KEY = "rustproxy.docker.live-view";
const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts";
const cfg = () => envelope?.config?.docker_agents || { agents: [] };
const obs = id => observations.find(item => item.agent_id === id);
const firstListener = () => envelope?.config?.listeners?.endpoints?.find(item => item.enabled !== false)?.id || "http";
const defaultHealth = () => ({ mode: "tcp", path: "/", host: null, interval_ms: 5000, timeout_ms: 2000, slow_response_ms: 1500, fall: 3, rise: 2, expected_status_min: 200, expected_status_max: 399 });
const defaultPassiveHealth = () => ({ enabled: false, count_connect_errors: true, count_timeouts: true, count_io_errors: true, count_http_5xx: true, count_slow_responses: false, fall: null, rise: null });
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const liveEnabled = () => localStorage.getItem(LIVE_KEY) === "1";
function loadHidden() { try { const value = JSON.parse(localStorage.getItem(HIDDEN_KEY) || "[]"); return new Set(Array.isArray(value) ? value.filter(item => typeof item === "string") : []); } catch { return new Set(); } }
let hiddenVhosts = loadHidden();
const viewKey = (agentId, serviceKey) => `${agentId}|${serviceKey}`;
function saveHidden() { try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hiddenVhosts].sort())); } catch { } }
function expandedHost(value, baseDomain) { const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); return host && host.includes(".") ? host : host ? `${host}.${baseDomain}` : ""; }
function publicListener(agent) {
const endpoints = envelope?.config?.listeners?.endpoints || [], wanted = new Set(agent?.listener_ids || []), candidates = endpoints.filter(item => item.enabled !== false && wanted.has(item.id));
return candidates.find(item => item.tls) || candidates[0] || null;
}
function proxiedUrl(agent, hostname) {
const host = expandedHost(hostname, agent.base_domain); if (!host) return "";
const listener = publicListener(agent), secure = !!listener?.tls, scheme = secure ? "https" : "http", address = String(listener?.address || "");
const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`;
return `${scheme}://${host}${suffix}/`;
}
function selectedListenerBindings(agent) {
const wanted = new Set(agent?.listener_ids || []);
return (envelope?.config?.listeners?.endpoints || []).filter(item => item.enabled !== false && wanted.has(item.id)).flatMap(item => {
const addresses = [item.binding?.address, ...(item.additional_addresses || [])].filter(Boolean);
return addresses.map(address => ({ address, tls: !!item.binding?.tls }));
});
}
function canForceSsl(agent) {
const bindings = selectedListenerBindings(agent), tlsPorts = new Set(); let hasHttp = false;
for (const binding of bindings) {
if (!binding.tls) { hasHttp = true; continue; }
const match = String(binding.address || "").match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
}
return hasHttp && tlsPorts.size > 0 && (tlsPorts.has(443) || tlsPorts.size === 1);
}
function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; }
function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; }
function checkboxField(key, control) { const field = el("div", undefined, "docker-field docker-checkbox-field"), label = el("label", undefined, "check vh-check"); label.append(control, el("span", t("enabled"))); field.append(el("span", t(key)), label); return field; }
function aliasEditor(initial = [], onChange = () => { }) {
const field = el("div", undefined, "docker-field docker-alias-field host-chip-field"), box = el("div", undefined, "host-chip-editor docker-alias-editor"), inputNode = input("", "text", "docker-alias-input");
field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd"));
const values = [];
const normalize = raw => {
const value = String(raw || "").trim().toLowerCase().replace(/\.$/, ""); if (!value) return null;
if (value.length > 253 || !/^[a-z0-9.-]+$/.test(value) || value.startsWith(".") || value.endsWith(".") || value.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
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"; remove.setAttribute("aria-label", `${t("remove")} ${value}`);
remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); onChange(); }); chip.append(remove); box.insertBefore(chip, inputNode);
}
};
const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); if (changed) onChange(); };
const consume = raw => { for (const part of String(raw || "").split(/[\s,]+/).filter(Boolean)) add(part); inputNode.value = ""; };
inputNode.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(inputNode.value); } catch (error) { toast(error.message, true); } } });
inputNode.addEventListener("blur", () => { if (!inputNode.value.trim()) return; try { consume(inputNode.value); } catch (error) { toast(error.message, true); } });
inputNode.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(inputNode); field.append(box); field.title = t("dockerAliasesHelp"); inputNode.title = t("dockerAliasesHelp"); for (const value of initial) { try { add(value, false); } catch { } }
return { node: field, read() { if (inputNode.value.trim()) consume(inputNode.value); return [...values]; }, count: () => values.length, input: inputNode };
}
function legacyHostname(assignment) { return assignment?.hostname || assignment?.subdomain || ""; }
function serviceBindings(service) {
if (!service) return [];
if (Array.isArray(service.bindings) && service.bindings.length) return service.bindings;
if (service.public_port) return [{ bind_ip: service.bind_ip || "", public_port: service.public_port }];
return [];
}
function ipFamily(ip) { return String(ip || "").includes(":") ? 6 : 4; }
function bindingSupports(binding, ip) {
const bind = String(binding?.bind_ip || "").trim();
if (!bind) return true;
if (bind === "0.0.0.0") return ipFamily(ip) === 4;
if (bind === "::") return ipFamily(ip) === 6;
return bind === ip;
}
function targetOptions(inventory, service, assignment, agent) {
const byIp = new Map();
const add = (ip, label) => { if (ip && !byIp.has(ip)) byIp.set(ip, label || ip); };
for (const address of inventory?.addresses || []) {
const details = [address.interface, address.scope, address.source].filter(Boolean).join(" · ");
if (!service || serviceBindings(service).some(binding => bindingSupports(binding, address.ip))) add(address.ip, details ? `${address.ip} (${details})` : address.ip);
}
for (const binding of serviceBindings(service)) {
const ip = String(binding.bind_ip || "");
if (ip && ip !== "0.0.0.0" && ip !== "::" && !ip.startsWith("127.") && ip !== "::1") add(ip, `${ip} (${t("dockerBindingIp")})`);
}
if (agent?.default_target_ip) add(agent.default_target_ip, `${agent.default_target_ip} (${t("dockerAgentDefaultIp")})`);
if (assignment?.target_ip) add(assignment.target_ip, assignment.target_ip);
return [...byIp.entries()];
}
function prettyFingerprint(value) { const compact = String(value || "").replace(/[^a-fA-F0-9]/g, "").toUpperCase(); return compact.length === 64 ? compact.match(/.{1,2}/g).join(":") : (value || "-"); }
function checkedAgo(value) {
if (!value) return t("dockerNeverChecked");
const seconds = Math.max(0, Math.round((Date.now() - Number(value)) / 1000));
if (seconds < 2) return t("dockerJustNow");
if (seconds < 60) return t("dockerSecondsAgo").replace("{seconds}", seconds);
return t("dockerMinutesAgo").replace("{minutes}", Math.floor(seconds / 60));
}
function securityInfo(agent, state) {
const https = String(agent?.endpoint || "").toLowerCase().startsWith("https://");
const pinned = !!agent?.tls_fingerprint_sha256;
if (!https) return { key: "dockerTlsPlain", cls: "warn", detail: t("dockerTlsPlainHelp") };
if (pinned) return { key: "dockerTlsPinned", cls: "live", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` };
if (state?.ok) return { key: "dockerTlsPublicCa", cls: "live", detail: t("dockerTlsPublicCaHelp") };
return { key: "dockerTlsPublicCa", cls: "", detail: t("dockerTlsPublicCaHelp") };
}
function updateSecurityBadge(row) {
const endpoint = row.querySelector(".docker-agent-endpoint")?.value || "", fingerprint = row.querySelector(".docker-agent-fingerprint")?.value || "";
const info = securityInfo({ endpoint, tls_fingerprint_sha256: fingerprint }, null), badge = row.querySelector(".docker-agent-security-badge");
if (badge) { badge.textContent = t(info.key); badge.className = `pill docker-agent-security-badge ${info.cls}`.trim(); }
const hint = row.querySelector(".docker-agent-security-hint"); if (hint) hint.textContent = info.detail;
}
function agentSettingsRow(agent = null) {
const value = agent || { id: "", name: "", enabled: true, endpoint: "https://127.0.0.1:9876", token: "", tls_fingerprint_sha256: null, base_domain: "docker1.lab.local", poll_interval_seconds: 30, certificate_id: null, acl_profile_id: null, default_target_ip: null, listener_ids: [firstListener()], assignments: [] };
const row = el("div", undefined, "docker-agent-settings entity"); row.dataset.originalId = agent?.id || ""; row._assignments = structuredClone(value.assignments || []);
const head = el("div", undefined, "card-heading"), title = el("div"); title.append(el("span", t("dockerAgent"), "small-label"), el("h3", value.name || value.id || t("dockerAgentNew")));
const enabled = input("", "checkbox", "docker-agent-enabled"); enabled.checked = value.enabled !== false;
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true;
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
const fingerprint = input(value.tls_fingerprint_sha256 || "", "text", "docker-agent-fingerprint mono"); fingerprint.maxLength = 128; fingerprint.placeholder = "SHA-256"; fingerprint.spellcheck = false;
const domain = input(value.base_domain, "text", "docker-agent-domain"); domain.required = true; domain.placeholder = "docker1.lab.local";
const poll = input(value.poll_interval_seconds ?? 30, "number", "docker-agent-poll"); poll.min = 5; poll.max = 3600; poll.required = true;
const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto");
const certificate = el("select", undefined, "docker-agent-certificate"), defaultCertificate = el("option", t("vhCertificateDefault")); defaultCertificate.value = ""; certificate.append(defaultCertificate);
for (const cert of envelope?.config?.tls?.certificates || []) { const item = el("option", cert.id); item.value = cert.id; certificate.append(item); } certificate.value = value.certificate_id || "";
const defaultAcl = el("select", undefined, "docker-agent-acl-profile"), noAcl = el("option", t("dockerDefaultAclNone")); noAcl.value = ""; defaultAcl.append(noAcl);
for (const profile of envelope?.config?.acl_profiles || []) { const item = el("option", profile.name || profile.id); item.value = profile.id; defaultAcl.append(item); } defaultAcl.value = value.acl_profile_id || "";
const grid = el("div", undefined, "field-grid docker-agent-grid");
grid.append(labeled("dockerAgentId", id), labeled("dockerAgentName", name), labeled("dockerAgentEndpoint", endpoint, t("dockerAgentEndpointHelp")), labeled("dockerAgentToken", token), labeled("dockerTlsFingerprint", fingerprint, t("dockerTlsFingerprintHelp")), labeled("dockerBaseDomain", domain), labeled("dockerPollInterval", poll, t("dockerPollIntervalHelp")), labeled("dockerDefaultTarget", target), labeled("dockerCertificate", certificate, t("dockerCertificateHelp")), labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp")));
const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label"));
const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField);
const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint);
endpoint.addEventListener("input", () => updateSecurityBadge(row)); fingerprint.addEventListener("input", () => updateSecurityBadge(row)); row.append(head, security, grid); updateSecurityBadge(row); return row;
}
function renderSettings() {
const target = $("docker-agent-settings-list"); if (!target || !envelope || settingsDirty.has("docker-agents-form")) return;
target.replaceChildren(...(cfg().agents || []).map(agentSettingsRow)); if (!target.children.length) target.append(el("p", t("dockerNoAgents"), "empty"));
}
function collectSettings() {
const rows = [...$("docker-agent-settings-list").querySelectorAll(".docker-agent-settings")];
return {
agents: rows.map(row => {
const listenerIds = row._readListeners?.() || []; if (!listenerIds.length) throw fieldError(row.querySelector(".docker-agent-listener-choices") || row, t("dockerListenersRequired"));
return {
id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked,
endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value,
tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(),
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: row.querySelector(".docker-agent-acl-profile").value || null, default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
listener_ids: listenerIds, assignments: structuredClone(row._assignments || [])
};
})
};
}
function runtimeFor(agentId, serviceKey) { const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey); return route ? poolIndex.get(route.pool_id) : null; }
function healthStatus(runtime, assignment, stale) {
if (stale) return { key: "dockerBackendMissing", cls: "warn" }; if (!assignment || assignment.enabled === false) return { key: "dockerBackendNotActive", cls: "" }; if (!runtime) return { key: "dockerBackendChecking", cls: "" };
const member = runtime.members?.[0]; if (!member) return { key: "dockerBackendChecking", cls: "" }; if (runtime.health_mode === "off") return { key: "dockerBackendHealthOff", cls: "" };
const map = { up: "dockerBackendUp", down: "dockerBackendDown", checking: "dockerBackendChecking", draining: "dockerBackendDraining", disabled: "dockerBackendNotActive" };
return { key: map[member.status] || "dockerBackendChecking", cls: member.status === "up" ? "live" : member.status === "down" ? "danger" : "" };
}
function runtimeSummary(runtime, assignment, stale) {
const wrap = el("div", undefined, "docker-runtime"), status = healthStatus(runtime, assignment, stale); wrap.append(el("span", t(status.key), `pill ${status.cls}`.trim()));
const member = runtime?.members?.[0]; if (!member) { wrap.append(el("span", t("dockerBackendNoMetrics"), "fine")); return wrap; }
const latency = Number(member.latency_ms || member.ewma_latency_ms || 0), metrics = [`${t("dockerHealthLatency")}: ${latency > 0 ? `${latency.toFixed(1)} ms` : "-"}`, `${t("dockerHealthResult")}: ${member.last_result || "-"}`, `${t("dockerHealthRequests")}: ${Number(member.requests || 0).toLocaleString()}`, `${t("dockerHealthErrors")}: ${Number(member.errors || 0).toLocaleString()}`, `${t("dockerHealthActive")}: ${Number(member.active || 0).toLocaleString()}`];
wrap.append(el("span", metrics.join(" · "), "fine")); return wrap;
}
function selectHealthMode(value) { const control = el("select", undefined, "docker-health-mode"); for (const [name, key] of [["http", "dockerHealthHttp"], ["tcp", "dockerHealthTcp"], ["off", "dockerHealthOff"]]) { const option = el("option", t(key)); option.value = name; control.append(option); } control.value = value; return control; }
function numberInput(value, cls, min, max, optional = false) { const node = input(value ?? "", "number", cls); node.min = min; node.max = max; if (!optional) node.required = true; return node; }
function syncHealthFields(details) { const http = details.querySelector(".docker-health-mode")?.value === "http"; details.querySelectorAll("[data-http-only]").forEach(node => node.hidden = !http); }
function healthEditor(assignment) {
const health = { ...defaultHealth(), ...(assignment?.health_check || {}) }, details = el("details", undefined, "docker-health-editor"), summary = el("summary"); summary.append(el("span", t("dockerHealthCheck")), el("small", t("dockerHealthSummary"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-health-grid"), mode = selectHealthMode(health.mode || "tcp"), path = input(health.path || "/", "text", "docker-health-path"), host = input(health.host || "", "text", "docker-health-host"); path.maxLength = 1024; host.maxLength = 253; host.placeholder = t("dockerHealthHostAuto");
const interval = numberInput(health.interval_ms, "docker-health-interval", 250, 300000), timeout = numberInput(health.timeout_ms, "docker-health-timeout", 50, 60000), slow = numberInput(health.slow_response_ms, "docker-health-slow", 0, 60000), fall = numberInput(health.fall, "docker-health-fall", 1, 20), rise = numberInput(health.rise, "docker-health-rise", 1, 20), statusMin = numberInput(health.expected_status_min, "docker-health-status-min", 100, 599), statusMax = numberInput(health.expected_status_max, "docker-health-status-max", 100, 599);
const httpFields = [labeled("dockerHealthPath", path), labeled("dockerHealthHost", host), labeled("dockerHealthStatusMin", statusMin), labeled("dockerHealthStatusMax", statusMax)]; httpFields.forEach(node => node.dataset.httpOnly = "1");
grid.append(labeled("dockerHealthMode", mode), ...httpFields, labeled("dockerHealthInterval", interval), labeled("dockerHealthTimeout", timeout), labeled("dockerHealthSlow", slow), labeled("dockerHealthFall", fall), labeled("dockerHealthRise", rise)); details.append(grid); mode.addEventListener("change", () => syncHealthFields(details)); syncHealthFields(details); return details;
}
function passiveHealthEditor(assignment) {
const passive = { ...defaultPassiveHealth(), ...(assignment?.passive_health || {}) }, details = el("details", undefined, "docker-health-editor docker-passive-health-editor"), summary = el("summary");
summary.append(el("span", t("dockerPassiveHealth")), el("small", t("dockerPassiveHealthSummary"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-health-grid");
const enabled = input("", "checkbox", "docker-passive-enabled"); enabled.checked = passive.enabled === true;
const connect = input("", "checkbox", "docker-passive-connect"); connect.checked = passive.count_connect_errors !== false;
const timeouts = input("", "checkbox", "docker-passive-timeouts"); timeouts.checked = passive.count_timeouts !== false;
const io = input("", "checkbox", "docker-passive-io"); io.checked = passive.count_io_errors !== false;
const http5xx = input("", "checkbox", "docker-passive-5xx"); http5xx.checked = passive.count_http_5xx !== false;
const slow = input("", "checkbox", "docker-passive-slow"); slow.checked = passive.count_slow_responses === true;
const fall = numberInput(passive.fall, "docker-passive-fall", 1, 20, true), rise = numberInput(passive.rise, "docker-passive-rise", 1, 20, true);
const optional = [connect, timeouts, io, http5xx, slow, fall, rise];
const optionalFields = [checkboxField("vhPassiveConnect", connect), checkboxField("vhPassiveTimeouts", timeouts), checkboxField("vhPassiveIo", io), checkboxField("vhPassive5xx", http5xx), checkboxField("vhPassiveSlow", slow), labeled("vhPassiveFall", fall), labeled("vhPassiveRise", rise)];
const warning5xx = el("p", t("vhPassive5xxWarning"), "fine docker-passive-5xx-warning");
const warningSlow = el("p", t("vhPassiveSlowWarning"), "fine docker-passive-slow-warning");
grid.append(checkboxField("vhPassiveEnabled", enabled), ...optionalFields, warning5xx, warningSlow); details.append(grid);
const sync = () => {
optional.forEach(control => { control.disabled = !enabled.checked; });
optionalFields.forEach(field => { field.hidden = !enabled.checked; });
warning5xx.hidden = !enabled.checked || !http5xx.checked;
warningSlow.hidden = !enabled.checked || !slow.checked;
};
enabled.addEventListener("change", sync); http5xx.addEventListener("change", sync); slow.addEventListener("change", sync); sync(); return details;
}
function collectPassiveHealth(row) {
const optional = cls => { const value = row.querySelector(cls)?.value?.trim() || ""; return value === "" ? null : Number(value); };
return {
enabled: row.querySelector(".docker-passive-enabled")?.checked === true,
count_connect_errors: row.querySelector(".docker-passive-connect")?.checked !== false,
count_timeouts: row.querySelector(".docker-passive-timeouts")?.checked !== false,
count_io_errors: row.querySelector(".docker-passive-io")?.checked !== false,
count_http_5xx: row.querySelector(".docker-passive-5xx")?.checked !== false,
count_slow_responses: row.querySelector(".docker-passive-slow")?.checked === true,
fall: optional(".docker-passive-fall"),
rise: optional(".docker-passive-rise")
};
}
function aclEditor(assignment, agent) {
const details = el("details", undefined, "docker-acl-editor"), summary = el("summary"); summary.append(el("span", t("dockerAccessControl")), el("small", t("dockerAclSummary"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-acl-grid"), profile = el("select", undefined, "docker-acl-profile");
const inheritedProfile = agent?.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(item => item.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : "";
const none = el("option", inheritedProfile ? `${t("dockerAclProfileInherit")}: ${inheritedProfile}` : t("dockerAclProfileNone")); none.value = ""; profile.append(none);
for (const item of envelope?.config?.acl_profiles || []) { const option = el("option", item.name || item.id); option.value = item.id; profile.append(option); } profile.value = assignment?.acl_profile_id || "";
const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline);
const defaultAction = el("select", undefined, "docker-acl-default"); for (const value of ["deny", "allow"]) { const option = el("option", t(value)); option.value = value; defaultAction.append(option); } defaultAction.value = assignment?.acl?.default || "deny";
const rules = document.createElement("textarea"); rules.className = "docker-acl-rules"; rules.rows = 4; rules.maxLength = 32768; rules.spellcheck = false; rules.value = rulesText(assignment?.acl); rules.placeholder = "allow 10.0.0.0/8\ndeny 0.0.0.0/0";
const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync();
grid.append(labeled("dockerAclProfile", profile), inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
}
function timeoutEditor(assignment) {
const value = { ...defaultProxyTimeouts(), ...(assignment?.proxy_timeouts || {}) }, details = el("details", undefined, "docker-timeout-editor"), summary = el("summary"); summary.append(el("span", t("dockerProxyTimeouts")), el("small", t("dockerProxyTimeoutsHelp"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-timeout-grid");
grid.append(labeled("vhProxyConnectTimeout", numberInput(value.connect_timeout_seconds ?? 10, "docker-proxy-connect", 1, 300)), labeled("vhProxyHeaderTimeout", numberInput(value.response_header_timeout_seconds ?? 120, "docker-proxy-header", 1, 86400)), labeled("vhProxyReadTimeout", numberInput(value.read_timeout_seconds ?? 60, "docker-proxy-read", 1, 86400)), labeled("vhProxySendTimeout", numberInput(value.send_timeout_seconds ?? 60, "docker-proxy-send", 1, 86400)));
details.append(grid); return details;
}
function collectHealth(row) { const n = cls => Number(row.querySelector(cls).value); return { mode: row.querySelector(".docker-health-mode").value, path: row.querySelector(".docker-health-path").value.trim() || "/", host: row.querySelector(".docker-health-host").value.trim() || null, interval_ms: n(".docker-health-interval"), timeout_ms: n(".docker-health-timeout"), slow_response_ms: n(".docker-health-slow"), fall: n(".docker-health-fall"), rise: n(".docker-health-rise"), expected_status_min: n(".docker-health-status-min"), expected_status_max: n(".docker-health-status-max") }; }
function collectAssignment(row) {
const inline = row.querySelector(".docker-acl-inline")?.checked;
return {
service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null,
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked,
acl_profile_id: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
};
}
async function saveAssignment(agentId, row, publish) {
const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false;
const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key);
if (publish) { const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired")); if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment); }
else if (index >= 0) agent.assignments.splice(index, 1);
const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved;
}
function assignmentRow(agent, inventory, service, assignment, stale = false) {
const key = assignment?.service_key || service.key, row = el("article", undefined, "docker-service-row"); row.dataset.key = key; row.dataset.viewKey = viewKey(agent.id, key);
const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false;
const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish")));
const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", ");
meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`));
const runtime = runtimeFor(agent.id, key), visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url || stale; openApp.title = url || ""; };
syncOpenApp(legacyHostname(assignment));
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top);
const editor = el("details", undefined, "docker-service-editor"), editorSummary = el("summary"), editorTitle = el("span", t("dockerConfigureService")), editorHint = el("small", "", "fine docker-config-hint"); editorSummary.append(editorTitle, editorHint); editor.append(editorSummary);
const route = el("div", undefined, "docker-route-grid"), host = input(legacyHostname(assignment) || service?.technical_name || "", "text", "docker-hostname"); host.maxLength = 253; host.placeholder = service?.technical_name || "app1";
let aliases; const aliasChanged = () => { updateHint?.(); row.dispatchEvent(new Event("input", { bubbles: true })); }; aliases = aliasEditor(assignment?.aliases || [], aliasChanged); row._readAliases = aliases.read;
const target = el("select", undefined, "docker-target-ip"), auto = el("option", agent.default_target_ip ? `${t("dockerBackendIpAuto")} (${agent.default_target_ip})` : t("dockerBackendIpAuto")); auto.value = ""; target.append(auto); for (const [ip, label] of targetOptions(inventory, service, assignment, agent)) { const option = el("option", label); option.value = ip; target.append(option); } target.value = assignment?.target_ip || "";
const scheme = el("select", undefined, "docker-scheme"); for (const name of ["http", "https"]) { const option = el("option", name.toUpperCase()); option.value = name; scheme.append(option); } scheme.value = assignment?.scheme || "http";
const preserve = input("", "checkbox", "docker-preserve-host"); preserve.checked = assignment?.preserve_host !== false; const preserveLabel = checkboxField("dockerPreserveHost", preserve); preserveLabel.classList.add("docker-preserve-field");
const forceSsl = input("", "checkbox", "docker-force-ssl"), forceCapable = canForceSsl(agent); forceSsl.checked = assignment ? assignment.force_ssl === true : forceCapable; forceSsl.disabled = !forceCapable; const forceSslField = checkboxField("dockerForceSsl", forceSsl); if (!forceCapable) forceSslField.append(el("small", t("dockerForceSslUnavailable"), "fine"));
const websockets = input("", "checkbox", "docker-websockets"); websockets.checked = assignment?.websockets !== false; const websocketField = checkboxField("dockerWebsockets", websockets);
const cacheEnabled = input("", "checkbox", "docker-cache-enabled"); cacheEnabled.checked = assignment?.cache_enabled === true; const cacheField = checkboxField("dockerCache", cacheEnabled);
const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled);
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
host.addEventListener("input", () => syncOpenApp(host.value));
row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); });
publish.addEventListener("change", async () => {
const wanted = publish.checked; publish.disabled = true; apply.disabled = true;
try { const saved = await saveAssignment(agent.id, row, wanted); if (!saved) publish.checked = !wanted; } catch (error) { publish.checked = !wanted; toast(error.message, true); }
finally { if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenApp(host.value); } }
});
row.classList.toggle("is-unpublished", !publish.checked); return row;
}
function addressSummary(inventory) { const addresses = inventory.addresses || []; if (!addresses.length && inventory.public_ip) return inventory.public_ip; return addresses.map(address => { const details = [address.interface, address.scope, address.source].filter(Boolean).join("/"); return details ? `${address.ip} (${details})` : address.ip; }).join(" · "); }
function renderDiscovery() {
const target = $("docker-discovery-list"); if (!target || !envelope) return; target.replaceChildren(); const agents = cfg().agents || [];
if (!agents.length) { target.append(el("p", t("dockerNoAgentsConfigured"), "card empty")); applyViewFilters(); return; }
for (const agent of agents) {
const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name));
const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head);
if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; }
if (!state?.ok || !state.inventory) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); target.append(box); continue; }
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary");
const defaultAclName = agent.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(profile => profile.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
}
applyViewFilters();
}
function applyViewFilters() {
const query = ($("docker-search")?.value || "").trim().toLowerCase();
for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-card")) {
const agentMatch = !!query && (card.dataset.searchText || "").includes(query);
const rows = [...card.querySelectorAll(".docker-service-row")]; let visible = 0;
for (const row of rows) {
const viewHidden = hiddenVhosts.has(row.dataset.viewKey);
const values = [...row.querySelectorAll("input, select")].map(control => control.value || "").join(" ");
const matches = !query || agentMatch || `${row.textContent} ${values}`.toLowerCase().includes(query);
row.classList.toggle("is-view-hidden", viewHidden);
row.hidden = (!showHidden && viewHidden) || !matches;
if (!row.hidden) visible += 1;
}
card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query));
}
const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); }
}
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
async function refresh(force = false) {
if (!authenticated) return; const button = $("docker-refresh"); if (button && force) button.disabled = true;
try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", force ? { method: "POST", body: "{}" } : {}); observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || []; poolIndex = new Map(pools.map(pool => [pool.id, pool])); renderSettings(); if (force || canLiveRender()) renderDiscovery(); }
catch (error) { if (force) toast(error.message, true); } finally { if (button) button.disabled = false; }
}
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); });
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
$("docker-search")?.addEventListener("input", applyViewFilters);
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
})();
+178
View File
@@ -0,0 +1,178 @@
/* A deliberately self-contained documentation layout. */
* {
box-sizing: border-box
}
body {
margin: 0;
background: #f5f6f7;
font: 14px system-ui, sans-serif;
color: #222b36
}
.docs-bar {
display: flex;
align-items: center;
gap: 22px;
padding: 14px 22px;
background: #101923;
color: #fff;
border-bottom: 3px solid #d97743
}
.docs-name {
font-weight: 750;
font-size: 20px
}
.docs-bar h1 {
font-size: 16px;
margin: 0;
flex: 1
}
.docs-bar a {
color: inherit;
text-decoration: none
}
.docs-bar button {
background: transparent;
color: inherit;
border: 1px solid #65717e;
padding: 7px 12px;
cursor: pointer
}
main {
max-width: 1400px;
margin: auto;
padding: 16px 22px
}
.docs-session {
background: #fff;
border: 1px solid #d7dde3;
padding: 12px 16px
}
.docs-session p {
margin: 5px 0 10px
}
.docs-session label {
display: inline-flex;
align-items: center;
gap: 7px
}
#docs-error:empty {
display: none
}
#docs-error {
padding: 12px;
border: 1px solid #9a4932
}
.swagger-ui .auth-wrapper {
display: none
}
.swagger-ui .info {
margin: 22px 0
}
.swagger-ui .opblock,
.swagger-ui .btn,
.swagger-ui select,
.swagger-ui input,
.swagger-ui textarea,
.swagger-ui .dialog-ux .modal-ux {
border-radius: 0
}
.swagger-ui .opblock .opblock-summary {
padding: 5px
}
.swagger-ui .info .title {
font-size: 24px
}
.swagger-ui .wrapper {
padding: 0
}
.swagger-ui .opblock-tag {
font-size: 19px
}
.swagger-ui .response-col_description {
min-width: 0
}
@media(max-width:680px) {
.docs-bar {
flex-wrap: wrap;
gap: 12px;
padding: 12px
}
.docs-bar h1 {
flex-basis: 60%
}
main {
padding: 12px
}
.swagger-ui .opblock .opblock-summary-path {
font-size: 12px
}
.swagger-ui .opblock-summary-description {
display: none
}
}
.docs-confirm {
width: min(480px, calc(100vw - 28px));
border: 1px solid #65717e;
background: #fff;
color: #222b36;
padding: 18px
}
.docs-confirm::backdrop {
background: rgb(16 25 35 / .62)
}
.docs-confirm h2 {
margin: 0 0 10px;
font-size: 18px
}
.docs-confirm p {
margin: 0;
white-space: pre-line
}
.docs-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px
}
.docs-confirm-actions button {
border: 1px solid #65717e;
background: transparent;
padding: 8px 12px;
cursor: pointer
}
.docs-confirm-actions .docs-confirm-primary {
background: #101923;
color: #fff
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / API</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/docs/swagger-ui.css">
<link rel="stylesheet" href="/docs/style.css">
<script defer src="/docs/swagger-ui-bundle.js"></script>
<script defer src="/docs/init.js"></script>
</head>
<body>
<header class="docs-bar"><a class="docs-name" href="/">RustProxy</a>
<h1 data-i18n="docsTitle">API documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout"
data-i18n="docsLogout">Sign out</button>
</header>
<main>
<section class="docs-session">
<p id="docs-session" role="status"></p>
<p data-i18n="docsSession">Uses your administrator session. Writes require CSRF protection.</p>
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write
operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed
here.</p>
</section>
<p id="docs-error" role="alert"></p>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
</main>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
"use strict";
// Fixed same-origin schema + browser session; never query-configurable/CDN-based.
(() => {
const element = id => document.getElementById(id); let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
const t = key => translations[key] ?? fallback[key] ?? key;
function docsConfirm(message) {
return new Promise(resolve => {
const dialog = document.createElement("dialog"); dialog.className = "docs-confirm";
const title = document.createElement("h2"); title.textContent = t("confirmTitle");
const body = document.createElement("p"); body.textContent = message;
const actions = document.createElement("div"); actions.className = "docs-confirm-actions";
const cancel = document.createElement("button"); cancel.type = "button"; cancel.textContent = t("cancelAction");
const accept = document.createElement("button"); accept.type = "button"; accept.className = "docs-confirm-primary"; accept.textContent = t("continueAction");
actions.append(cancel, accept); dialog.append(title, body, actions); document.body.append(dialog);
let settled = false; const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); };
cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true)); dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); }); dialog.addEventListener("close", () => finish(false));
dialog.showModal(); cancel.focus();
});
}
function clear() { epoch++; session = null; spec = null; ui = null; element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-session").textContent = ""; }
function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); }
channel?.addEventListener("message", event => { if (event.data === "locked") expired(); });
async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); }
async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; }
async function requestInterceptor(request) {
if (!session || !spec) throw new Error(t("docsExpired"));
const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase();
if (url.origin !== location.origin || url.username || url.password || !Object.hasOwn(spec.paths, url.pathname) || !Object.hasOwn(spec.paths[url.pathname], method)) throw new Error("Only documented same-origin operations are allowed.");
if (["/api/auth/login", "/api/auth/setup", "/api/events"].includes(url.pathname)) throw new Error(t("docsReadOnly"));
await checkSession();
request.credentials = "same-origin"; request.headers = { ...request.headers, "X-RustProxy-Request": "1" }; request.cache = "no-store"; request.redirect = "error";
if (!["get", "head"].includes(method)) {
if (!element("docs-writes").checked || !(await docsConfirm(t("docsWriteConfirm")))) throw new Error(t("docsReadOnly"));
request.headers["X-CSRF-Token"] = session.csrf_token;
}
return request;
}
function render() {
if (!session || !spec) return;
// Disable the online validator and URL/config overrides. No persisted auth or
// externally supplied schema can send cookies or CSRF tokens elsewhere.
// These mandatory wire headers are supplied by the browser/interceptor, not
// typed into Swagger fields. Keep the downloadable specification accurate.
const displaySpec = structuredClone(spec);
for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) {
if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters)
operation.parameters = operation.parameters.filter(p => !(p.in === "header" && ["origin", "x-rustproxy-request"].includes(p.name.toLowerCase())));
}
ui = SwaggerUIBundle({
spec: displaySpec, dom_id: "#swagger-ui", layout: "BaseLayout", presets: [SwaggerUIBundle.presets.apis],
validatorUrl: null, queryConfigEnabled: false, persistAuthorization: false, withCredentials: true,
supportedSubmitMethods: element("docs-writes").checked ? ["get", "post", "put"] : ["get"],
docExpansion: "none", filter: true, displayRequestDuration: true, tryItOutEnabled: false, showMutatedRequest: false,
requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; }
});
}
element("docs-writes").addEventListener("change", render);
element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } });
async function boot() {
const mine = ++epoch; try {
const info = await get("/api/auth/session"); if (mine !== epoch) return;
const [document, bundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json")]); if (mine !== epoch) return;
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { }
const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n));
session = info; spec = document; element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = ""; render();
} catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } }
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); });
window.addEventListener("pageshow", event => { if (event.persisted) { clear(); void boot(); } });
setInterval(() => { if (!document.hidden && session) void checkSession().catch(() => expired()); }, 30000);
void boot();
})();
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>401 - Authentication required - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>401 - Authentication required</h1>
<p>Authentication is required to access this resource.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>403 - Access denied - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>403 - Access denied</h1>
<p>This request is not permitted by the site's access policy.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 - Not found - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>404 - Not found</h1>
<p>The requested resource was not found.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>429 - Too many requests - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>429 - Too many requests</h1>
<p>The request rate limit was exceeded. Please try again after the indicated delay.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>503 - Service unavailable - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>503 - Service unavailable</h1>
<p>The upstream service is temporarily unavailable. Please try again shortly.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>504 - Gateway timeout - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>504 - Gateway timeout</h1>
<p>The upstream service did not respond in time. Please try again shortly.</p>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#131615"/><path d="M14 45V23h13M37 20v28M37 23h12v13H37" fill="none" stroke="#f69b74" stroke-width="4"/><path d="M26 48l9-32" stroke="#bdd293" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 270 B

+70
View File
@@ -0,0 +1,70 @@
"use strict";
(() => {
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
if (!form || !dialog || !list) return;
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [];
const ERROR_STATUSES = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
const BUILTIN_STATUSES = [401, 403, 404, 429, 503, 504];
const STATUS_NAMES = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" };
function populateStatuses(selected = null) {
const select = $("notfound-status"); if (!select) return;
const codes = $("notfound-mode")?.value === "builtin" ? BUILTIN_STATUSES : ERROR_STATUSES;
const wanted = Number(selected ?? select.value ?? 404);
select.replaceChildren(...codes.map(code => { const node = el("option", `${code} ${STATUS_NAMES[code]}`); node.value = String(code); return node; }));
select.value = codes.includes(wanted) ? String(wanted) : "404";
}
const value = id => $(id).value.trim(), yes = id => $(id).checked;
const defaults = () => ({ id: "", name: "", enabled: true, http2_enabled: true, http3_enabled: true, frontend: { hosts: [], listener_ids: [] }, certificate_id: null, page: { kind: "welcome", status: 404, html: "" } });
const option = (value, text) => { const node = el("option", text); node.value = value; return node; };
const changed = () => { dirty = true; };
const allowDiscard = async () => !dirty || await appConfirm(t("notfoundUnsaved"), { danger: true, confirmLabel: t("discardAction") });
function certificateChoices(selected) {
const select = $("notfound-certificate"); select.replaceChildren(option("", t("vhCertificateDefault")));
for (const cert of envelope?.config.tls?.certificates || []) select.append(option(cert.id, cert.id));
select.value = selected || "";
}
function listenerChoices(selected = []) {
const holder = $("notfound-listeners"); holder.replaceChildren();
const control = window.rustProxyIntegrations.bindings(holder, selected, !editing); readBindings = control.read;
const h2 = $("notfound-http2"), h3 = $("notfound-http3");
control.onChange(() => { const caps = control.capabilities(); h2.disabled = !caps.http2; h3.disabled = !caps.http3; if (!caps.http2) h2.checked = false; if (!caps.http3) h3.checked = false; });
}
function syncMode() {
const mode = $("notfound-mode").value;
populateStatuses();
$("notfound-html-field").hidden = mode !== "html";
}
function load(item = defaults()) {
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
$("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || "";
$("notfound-enabled").checked = item.enabled !== false; $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed });
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
$("notfound-mode").value = item.page?.kind || "welcome"; populateStatuses(item.page?.status ?? 404); $("notfound-html").value = item.page?.html || ""; syncMode(); dialog.showModal();
}
function collect() {
const id = value("notfound-id"), name = value("notfound-name") || id, hosts = readHosts(), listener_ids = readBindings(), kind = value("notfound-mode"), status = Number(value("notfound-status")), html = $("notfound-html").value;
if (!listener_ids.length) throw new Error(t("notfoundListenersRequired"));
const allowed = kind === "builtin" ? BUILTIN_STATUSES : ERROR_STATUSES;
if (!Number.isInteger(status) || !allowed.includes(status)) throw new Error(t("notfoundStatusInvalid"));
if (kind === "html" && !html.trim()) throw new Error(t("notfoundHtmlRequired"));
return { id, name, enabled: yes("notfound-enabled"), http2_enabled: yes("notfound-http2"), http3_enabled: yes("notfound-http3"), frontend: { hosts, listener_ids }, certificate_id: value("notfound-certificate") || null, page: { kind, status, html: kind === "html" ? html : "" } };
}
function render() {
if (!envelope) return; const query = ($("notfound-search").value || "").trim().toLowerCase();
const entries = (envelope.config.not_found_hosts || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || [])].join(" ").toLowerCase().includes(query));
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card;
}));
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
}
form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode);
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
$("notfound-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
window.rustProxyNotFound = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); $("notfound-search").value = ""; } };
if (envelope) render();
})();
File diff suppressed because it is too large. Load diff
+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();
})();
+27
View File
@@ -0,0 +1,27 @@
[
"auth.already_initialized",
"auth.busy",
"auth.csrf",
"auth.invalid_credentials",
"auth.invalid_host",
"auth.invalid_origin",
"auth.invalid_request",
"auth.invalid_setup_token",
"auth.invalid_username",
"auth.password_policy",
"auth.rate_limited",
"auth.required",
"auth.unavailable",
"authPrivateNote",
"confirmPassword",
"createAndSignIn",
"language",
"loginTitle",
"password",
"passwordMismatch",
"setupHelp",
"setupTitle",
"setupToken",
"signIn",
"username"
]
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html data-theme="dark" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / Sign in</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/assets/style.css">
<script defer src="/assets/login.js"></script>
</head>
<body class="auth-page auth-loading">
<header class="auth-top"><a class="brand" href="/" aria-label="RustProxy"><span
class="brand-mark">r<span>/</span>p</span><span>RustProxy</span></a><select id="language"
class="language-select" aria-label="Language" data-i18n-aria="language"></select></header>
<main class="auth-main">
<section class="login-section" id="login">
<h1 data-i18n="loginTitle">Administration</h1>
<form class="login-card" id="login-form">
<div class="card-heading">
<h2 id="auth-title">Sign in</h2><span class="pill" id="auth-badge">LOCAL</span>
</div>
<div hidden="" id="setup-fields">
<p class="fine" data-i18n="setupHelp">Create the first administrator using a one-time local
capability.</p><code class="command">rust-proxy --data-dir DATA_DIR admin setup-token</code>
<label data-i18n="setupToken" for="setup-token">One-time setup token</label><input
autocomplete="off" id="setup-token" maxlength="64" minlength="64" spellcheck="false"
type="password" />
</div>
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.-]{3,64}"
required="" spellcheck="false" />
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
id="password" maxlength="128" required="" type="password" />
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
password</label><input autocomplete="new-password" id="password-confirm" maxlength="128"
type="password" /></div>
<button class="primary" id="login-submit" type="submit">Sign in</button>
<p class="fine" data-i18n="authPrivateNote">Access is restricted to authorized administrators.</p>
</form>
<p class="fine" id="auth-message" role="alert" aria-live="polite"></p>
</section>
</main>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
"use strict";
// No operational API or dashboard module is loaded on this document.
(() => {
const node = id => document.getElementById(id);
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/cache", "/transform", "/logging", "/settings"]);
const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
const t = key => packs.get(language)?.translations?.[key] ?? packs.get(fallback)?.translations?.[key] ?? key;
const reveal = () => document.body.classList.remove("auth-loading");
function message(value) { const box = node("auth-message"); box.textContent = value; box.classList.toggle("error", Boolean(value)); }
function panelDestination() {
const params = new URLSearchParams(location.search);
if (params.get("next") === "docs") return "/docs";
if (!panelPaths.has(location.pathname)) return "/";
if (location.pathname === "/logging") return "/settings?tab=logging";
if (location.pathname === "/settings") {
const tab = params.get("tab");
if (settingsTabs.has(tab)) return `/settings?tab=${encodeURIComponent(tab)}`;
}
return location.pathname;
}
async function request(path, body) {
const response = await fetch(path, { method: body ? "POST" : "GET", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1" }, ...(body ? { body: JSON.stringify(body) } : {}) });
let value; try { value = await response.json(); } catch { value = { error: "auth.unavailable" }; }
if (!response.ok) { const error = new Error(t(value.error || "auth.unavailable")); error.code = value.error; throw error; } return value;
}
function translate() {
const pack = packs.get(language) || packs.get(fallback); document.documentElement.lang = pack?.meta.locale || "und"; document.documentElement.dir = pack?.meta.direction || "ltr";
document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n));
document.querySelectorAll("[data-i18n-aria]").forEach(n => n.setAttribute("aria-label", t(n.dataset.i18nAria)));
node("setup-fields").hidden = !setupRequired; node("confirm-field").hidden = !setupRequired;
node("setup-token").required = setupRequired; node("password-confirm").required = setupRequired;
node("password").minLength = setupRequired ? 12 : 1; node("password").autocomplete = setupRequired ? "new-password" : "current-password";
node("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); node("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn");
node("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL";
}
function selectLanguage(bundle) {
if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("auth.unavailable");
packs = new Map(bundle.languages.map(p => [p.id, p])); fallback = bundle.default; if (!packs.has(fallback)) throw new Error("auth.unavailable");
const values = [...packs.values()], candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(s => s.replaceAll("_", "-").toLowerCase());
language = candidates.map(v => values.find(p => p.id.replaceAll("_", "-").toLowerCase() === v || p.meta.locale.toLowerCase() === v) || values.find(p => p.meta.locale.toLowerCase().split("-")[0] === v.split("-")[0])).find(Boolean)?.id || fallback;
node("language").replaceChildren(...values.map(p => { const option = document.createElement("option"); option.value = p.id; option.textContent = p.meta.native_name; return option; })); node("language").value = language;
}
node("language").addEventListener("change", () => { language = node("language").value; try { localStorage.setItem("rp-language", language); } catch { } message(""); translate(); });
node("login-submit").disabled = true;
node("login-form").addEventListener("submit", async event => {
event.preventDefault(); if (!ready) return; const button = node("login-submit"); button.disabled = true; message("");
try {
const username = node("username").value.trim(), password = node("password").value;
if (setupRequired) { if (password !== node("password-confirm").value) throw new Error(t("passwordMismatch")); await request("/api/auth/setup", { token: node("setup-token").value.trim(), username, password }); setupRequired = false; translate(); }
await request("/api/auth/login", { username, password });
node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = "";
// Never accept arbitrary return URLs (including scheme-relative redirects).
// A direct panel route is an exact local allowlist entry, so an expired
// session can sign back in without losing the requested subpage.
location.replace(panelDestination());
} catch (error) { node("password").value = ""; node("password-confirm").value = ""; if (error.code === "auth.already_initialized") { setupRequired = false; translate(); } message(error.message); }
finally { button.disabled = false; }
});
async function redirectExistingSession() {
try { await request("/api/auth/session"); location.replace(panelDestination()); return true; }
catch (error) { if (error.code === "auth.required") return false; throw error; }
}
window.addEventListener("pageshow", event => {
node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = "";
if (event.persisted && ready && !setupRequired) void redirectExistingSession().catch(() => message(t("auth.unavailable")));
});
(async () => {
try {
const [bundle, status] = await Promise.all([request("/assets/login-langs.json"), request("/api/auth/status")]);
selectLanguage(bundle); setupRequired = status.setup_required === true; translate();
if (!setupRequired && await redirectExistingSession()) return;
ready = true; node("login-submit").disabled = false; reveal();
} catch (error) { reveal(); message(t("auth.unavailable")); }
})();
})();
+295
View File
@@ -0,0 +1,295 @@
"use strict";
// Shared, safe controls: HTML is edited as text, never injected into the admin DOM.
(() => {
const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const option = (value, key) => { const node = label("option", key); node.value = value; return node; };
const codes = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
const STATUS_NAMES = { 200: "OK", 204: "No Content", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 410: "Gone", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" };
const statusLabel = code => `${code} ${STATUS_NAMES[code] || ""}`.trim();
function pageEditor(parent, key, value = null) {
const row = el("div", undefined, "page-editor"), heading = label("strong", key, "page-label"); row.append(heading);
const mode = el("select"), status = el("select"), html = el("textarea"), file = el("input");
mode.setAttribute("aria-label", t(key)); mode.className = "page-mode";
mode.append(option("", "pageInherit"), option("welcome", "pageWelcome"), option("builtin", "pageBuiltin"), option("empty", "pageEmpty"), option("html", "pageHtml"));
mode.value = value?.kind || "";
status.className = "page-status"; status.setAttribute("aria-label", t("pageStatus"));
status.append(option("", "pageOriginalStatus"));
for (const code of [200, 204, 400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]) { const o = el("option", statusLabel(code)); o.value = code; status.append(o); }
status.value = value?.status ?? ""; html.value = value?.html || ""; html.rows = 5; html.maxLength = 32768; html.spellcheck = false; html.className = "page-html"; html.setAttribute("aria-label", t("pageHtmlSource"));
file.type = "file"; file.accept = ".html,.htm,text/html,text/plain"; file.setAttribute("aria-label", t("pageUploadHtml"));
const source = el("div", undefined, "page-source"); source.append(label("span", "pageUploadHtml", "fine"), file, html);
function sync() { status.disabled = !mode.value; source.hidden = mode.value !== "html"; if (status.value === "204" && mode.value !== "empty") status.value = ""; }
for (const event of ["change", "input"]) { mode.addEventListener(event, sync); status.addEventListener(event, sync); }
file.addEventListener("change", async () => { try { const chosen = file.files[0]; if (!chosen) return; if (chosen.size > 32768) throw new Error(t("pageTooLarge")); html.value = await chosen.text(); html.dispatchEvent(new Event("input", { bubbles: true })); } catch (error) { toast(error.message, true); } finally { file.value = ""; } });
row.append(mode, status, source); parent.append(row); sync();
return { row, read() { if (!mode.value) return null; const markup = mode.value === "html" ? html.value : ""; if (new TextEncoder().encode(markup).length > 32768) throw new Error(t("pageTooLarge")); return { kind: mode.value, status: status.value ? Number(status.value) : null, html: markup }; } };
}
function errorsEditor(parent, values = {}) {
const details = el("details", undefined, "pages-errors"); details.append(label("summary", "customErrorPages")); parent.append(details);
const rows = new Map(); for (const code of codes) { const group = el("div"); details.append(group); const editor = pageEditor(group, "customErrorPages", values[code]); editor.row.querySelector("strong").removeAttribute("data-i18n"); editor.row.querySelector("strong").textContent = statusLabel(code); rows.set(code, editor); }
return { read() { return Object.fromEntries([...rows].map(([code, editor]) => [code, editor.read()]).filter(([, page]) => page)); } };
}
function vhostEditor(parent, values = {}) {
const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback), errors = errorsEditor(parent, values.errors);
parent.append(label("p", "pagesSafetyHint", "fine")); return { read: () => ({ response: response.read(), fallback: fallback.read(), errors: errors.read() }) };
}
let global = null;
function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; }
window.rustProxyPages = { render, vhostEditor };
bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages");
$("settings-security-open").addEventListener("click", () => setPanel("security"));
$("settings-vhost-open").addEventListener("click", () => setPanel("vhosts"));
const density = $("ui-density"); let chosen = "compact"; try { chosen = localStorage.getItem("rp-density") || chosen; } catch { }
if (!["compact", "dense", "comfortable"].includes(chosen)) chosen = "compact"; density.value = chosen; document.body.dataset.density = chosen;
density.addEventListener("change", () => { document.body.dataset.density = density.value; try { localStorage.setItem("rp-density", density.value); } catch { } });
if (envelope) render();
})();
(() => {
const dialog = $("logs-dialog"); let paused = null, expanded = null, docked = false, pinning = false, preferencesLoaded = false, preferencesLoading = null, preferenceSaveRunning = false, preferenceSaveQueued = false;
const filters = ["log-vhost", "log-method", "log-status", "log-outcome", "log-filter"];
const columns = [
{ id: "time", label: "time", value: e => new Date(e.time_ms).toLocaleTimeString(locale()) },
{ id: "request", label: "request", value: e => `${e.method} ${e.path}` },
{ id: "host", label: "logHost", value: e => e.host },
{ id: "source_ip", label: "sourceIp", value: e => e.client_ip || "-" },
{ id: "vhost", label: "vhVhost", value: e => e.vhost || "-" },
{ id: "backend", label: "backend", value: e => e.backend || "-" },
{ id: "status", label: "status", value: e => e.status },
{ id: "duration", label: "duration", value: e => `${e.duration_ms} ms` },
{ id: "cache", label: "cache", value: e => e.cache || "-" },
{ id: "outcome", label: "outcome", value: e => t(e.outcome) },
{ id: "transport", label: "logTransport", value: e => e.transport ? `${e.transport}${e.http_version ? ` / HTTP ${e.http_version}` : ""}` : "-" },
{ id: "websocket", label: "logWebSocket", value: e => [e.websocket_bootstrap, e.websocket_phase, e.websocket_error, e.h3_reset_code].filter(Boolean).join(" / ") || "-" },
{ id: "upstream_error", label: "logUpstreamError", value: e => e.upstream_error || "-" },
];
const defaultColumns = () => new Set(columns.map(column => column.id));
let visibleColumns = defaultColumns();
function source() { return paused || expanded || lastLogs || { entries: [] }; }
function mergeExpanded(data) {
if (!expanded || paused || !data?.entries) return;
const seen = new Set();
const entries = [];
for (const entry of [...data.entries, ...(expanded.entries || [])]) {
const key = entry.id ?? `${entry.time_ms}:${entry.method}:${entry.host}:${entry.path}:${entry.status}`;
if (seen.has(key)) continue;
seen.add(key); entries.push(entry);
if (entries.length >= 4096) break;
}
expanded = { ...expanded, ...data, entries, capacity: 4096, window: 4096 };
}
function syncExpandedButton() {
const button = $("logs-load-history");
button.textContent = t(expanded ? "logExpandedBuffer" : "logExpandBuffer");
button.dataset.i18n = expanded ? "logExpandedBuffer" : "logExpandBuffer";
button.disabled = Boolean(expanded);
}
function filtered() {
const query = $("log-filter").value.trim().toLowerCase(), outcome = $("log-outcome").value;
return source().entries.filter(e => (!$("log-vhost").value || e.vhost === $("log-vhost").value)
&& (!$("log-method").value || e.method === $("log-method").value)
&& (!$("log-status").value || String(e.status).startsWith($("log-status").value))
&& (outcome === "all" || (outcome === "errors" ? e.status >= 400 : e.cache === outcome))
&& `${e.method} ${e.path} ${e.host} ${e.client_ip || ""} ${e.vhost || ""} ${e.backend || ""} ${e.transport || ""} ${e.http_version || ""} ${e.websocket_bootstrap || ""} ${e.websocket_phase || ""} ${e.websocket_error || ""} ${e.h3_reset_code || ""} ${e.upstream_error || ""}`.toLowerCase().includes(query));
}
function syncHeaders() {
dialog.querySelectorAll("th[data-log-column]").forEach(header => { header.hidden = !visibleColumns.has(header.dataset.logColumn); });
}
function syncColumnPicker() {
const picker = $("log-column-options");
picker.replaceChildren(...columns.map(column => {
const label = el("label", undefined, "check log-column-option");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id;
const text = el("span", t(column.label)); text.dataset.i18n = column.label;
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("logColumnsRequired"), true); return; }
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
syncHeaders(); render(); void savePreferences();
});
label.append(input, text); return label;
}));
}
function applyPreferences(preferences) {
if (!preferences) return;
const selected = Array.isArray(preferences.live_log_columns) ? preferences.live_log_columns.filter(id => columns.some(column => column.id === id)) : [];
visibleColumns = selected.length ? new Set(selected) : defaultColumns(); preferencesLoaded = true;
syncColumnPicker(); syncHeaders(); render();
}
async function loadPreferences(force = false) {
if (!authenticated || (preferencesLoaded && !force)) return;
if (preferencesLoading) return preferencesLoading;
preferencesLoading = (async () => {
try { applyPreferences(await window.rustProxyPreferences.load(force)); }
catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { preferencesLoading = null; }
})();
return preferencesLoading;
}
async function savePreferences() {
if (!authenticated) return;
preferenceSaveQueued = true;
if (preferenceSaveRunning) return;
preferenceSaveRunning = true;
try {
while (preferenceSaveQueued && authenticated) {
preferenceSaveQueued = false;
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
const preferences = await window.rustProxyPreferences.update({ live_log_columns: selected });
if (!authenticated) return;
if (!preferenceSaveQueued) applyPreferences(preferences);
}
} catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { preferenceSaveRunning = false; }
}
function render(data = null) {
if (data) mergeExpanded(data);
if (!dialog.open) return;
const selector = $("log-vhost"), selected = selector.value, ids = new Set([...(envelope?.config.vhosts || []).map(v => v.id), ...source().entries.map(e => e.vhost).filter(Boolean)]);
const all = el("option", t("logAllVhosts")); all.value = ""; selector.replaceChildren(all, ...[...ids].sort().map(id => { const o = el("option", id); o.value = id; return o; })); if (ids.has(selected)) selector.value = selected;
const entries = filtered(), rows = entries.map(e => {
const row = el("tr"); const hints = [];
if (e.cache_reason) hints.push(window.rustProxyCachePanel?.reason(e.cache_reason) || e.cache_reason);
if (e.upstream_error) hints.push(`upstream: ${e.upstream_error}`);
row.title = hints.join(" · ");
for (const column of columns) {
if (!visibleColumns.has(column.id)) continue;
const value = column.value(e), cell = el("td", String(value)); cell.title = String(value);
if (column.id === "status") cell.className = e.status < 400 ? "status-good" : "status-error";
if (column.id === "cache" && e.cache === "HIT") cell.className = "status-good";
row.append(cell);
}
return row;
});
syncHeaders(); $("logs").replaceChildren(...rows); $("empty-logs").hidden = rows.length > 0; $("log-count").textContent = `${entries.length} / ${source().entries.length} | ${t(paused ? "logPaused" : "live")}`;
$("logs-pause").textContent = t(paused ? "logResume" : "logPause"); $("logs-pin").textContent = t(docked ? "logUndock" : "logPin"); syncExpandedButton();
}
function open(vhost) {
if (!authenticated) return;
if (!preferencesLoaded) void loadPreferences();
if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; }
if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); }
render();
}
syncColumnPicker(); syncHeaders();
$("logs-open").addEventListener("click", () => open()); $("logs-close").addEventListener("click", () => dialog.close());
$("logs-load-history").addEventListener("click", async () => {
const button = $("logs-load-history"); button.disabled = true;
try {
const snapshot = await api("/api/logs?limit=4096");
expanded = { ...snapshot, capacity: 4096, window: 4096 }; render();
toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0)));
} catch (error) { toast(error.message, true); }
finally { button.disabled = Boolean(expanded); }
});
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); });
$("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); });
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); });
for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render);
$("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
})();
// Reusable ACL profiles. They are intentionally composed with, not substituted
// for, the per-vhost policy so attaching a profile cannot weaken a vhost rule.
(() => {
let editing = null, dirty = false;
const form = $("acl-profile-form");
const split = value => value.split(/[\s,]+/).map(v => v.trim()).filter(Boolean);
const ipEnabled = $("acl-profile-ip-enabled"), aclDefault = $("acl-profile-default"), rules = $("acl-profile-rules"), ruleRows = $("acl-profile-rule-rows");
const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules");
const i18n = (tag, key) => { const node = el(tag, t(key)); node.dataset.i18n = key; return node; };
function addRule(rule = { action: "allow", network: "" }, mark = true) {
if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; }
const row = el("div", undefined, "acl-rule acl-profile-rule");
const actionLabel = el("label"); actionLabel.append(i18n("span", "aclAction"));
const actionSelect = el("select"); actionSelect.className = "acl-profile-rule-action";
for (const value of ["allow", "deny"]) { const option = el("option", t(value === "allow" ? "aclAllow" : "vhDeny")); option.value = value; actionSelect.append(option); }
actionSelect.value = rule.action || "allow"; actionLabel.append(actionSelect);
const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork"));
const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8"; network.value = rule.network || ""; networkLabel.append(network);
const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncIp(); }); remove.classList.add("acl-profile-rule-remove");
row.append(actionLabel, networkLabel, remove); ruleRows.append(row);
if (mark) { syncRulesText(); dirty = true; }
syncIp();
}
function collectRuleRows() {
return [...ruleRows.children].map(row => ({
action: row.querySelector(".acl-profile-rule-action").value,
network: row.querySelector(".acl-profile-rule-network").value.trim(),
}));
}
function syncRulesText() { rules.value = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); }
function renderRuleRows(values) { ruleRows.replaceChildren(); for (const rule of values || []) addRule(rule, false); syncRulesText(); syncIp(); }
function importRules() {
try { renderRuleRows(parseRules(rules.value, rules)); dirty = true; toast(t("aclProfileRulesImported")); }
catch (error) { toast(error.message, true); }
}
function syncIp() {
const disabled = !ipEnabled.checked;
aclDefault.disabled = disabled; rules.disabled = disabled; addButton.disabled = disabled; importButton.disabled = disabled;
for (const node of ruleRows.querySelectorAll("input,select,button")) node.disabled = disabled;
}
function clear() {
editing = null; dirty = false; form.reset(); ipEnabled.checked = false; aclDefault.value = "deny"; $("acl-profile-country-mode").value = "off"; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncIp();
}
function edit(profile) {
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
ipEnabled.checked = !!profile.acl; aclDefault.value = profile.acl?.default || "deny"; renderRuleRows(profile.acl?.rules || []);
$("acl-profile-country-mode").value = profile.country_access?.mode || "off"; $("acl-profile-countries").value = (profile.country_access?.countries || []).join("\n");
$("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncIp();
}
function render() {
if (!envelope) return;
const profiles = envelope.config.acl_profiles || [], list = $("acl-profile-list");
const cards = profiles.map(profile => {
const box = el("div", undefined, "entity");
const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top);
const pieces = [];
if (profile.acl) pieces.push(`${t("aclDefault")}: ${t(profile.acl.default || "deny")} · ${(profile.acl.rules || []).length} ${t("aclProfileIpRulesShort")}`);
else pieces.push(t("aclProfileIpOff"));
const countries = profile.country_access?.countries || [];
pieces.push(profile.country_access?.mode && profile.country_access.mode !== "off" ? `${t("vhCountryMode")}: ${t(profile.country_access.mode === "block" ? "vhCountryBlock" : "vhCountryAllow")} · ${countries.join(", ") || "-"}` : t("aclProfileCountriesOff"));
const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id);
for (const agent of envelope.config.docker_agents?.agents || []) {
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`);
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
}
if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`);
box.append(el("p", pieces.join("\n"), "entity-details"));
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
})); box.append(actions); return box;
});
list.replaceChildren(...(cards.length ? cards : [el("p", t("aclProfilesEmpty"), "empty")]));
if (!dirty && editing) { const current = profiles.find(p => p.id === editing); if (current) edit(current); else clear(); }
}
form.addEventListener("input", () => { dirty = true; }); form.addEventListener("change", () => { dirty = true; });
ruleRows.addEventListener("input", syncRulesText); ruleRows.addEventListener("change", syncRulesText);
ipEnabled.addEventListener("change", () => { if (ipEnabled.checked && !ruleRows.children.length) addRule(); syncIp(); });
addButton.addEventListener("click", () => addRule()); importButton.addEventListener("click", importRules);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const id = $("acl-profile-id").value.trim(), name = $("acl-profile-name").value.trim();
const countries = [...new Set(split($("acl-profile-countries").value).map(v => v.toUpperCase()))];
if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError($("acl-profile-countries"), t("aclProfileCountryInvalid"));
// If the bulk textarea was edited directly, apply it before saving instead of
// silently ignoring pasted content. The parser reports the exact bad line.
const visualText = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n");
if (ipEnabled.checked && normalizeAclRuleText(rules.value).trim() !== normalizeAclRuleText(visualText).trim()) renderRuleRows(parseRules(rules.value, rules));
const aclRules = collectRuleRows();
if (ipEnabled.checked && aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); }
const profile = { id, name, acl: ipEnabled.checked ? { default: aclDefault.value, rules: aclRules } : null, country_access: { mode: $("acl-profile-country-mode").value, countries } };
const next = structuredClone(envelope.config); next.acl_profiles ||= [];
const index = next.acl_profiles.findIndex(item => item.id === editing);
if (index < 0) { if (next.acl_profiles.some(item => item.id === id)) throw fieldError($("acl-profile-id"), t("aclProfileDuplicate")); next.acl_profiles.push(profile); } else next.acl_profiles[index] = profile;
if (await saveSection("acl-profiles", next.acl_profiles || [])) { editing = id; dirty = false; render(); }
});
} catch (error) { toast(error.message, true); }
});
$("acl-profile-new").addEventListener("click", clear); $("acl-profile-reset").addEventListener("click", clear);
window.rustProxyAclProfiles = { render, reset: clear };
clear(); if (envelope) render();
})();
+108
View File
@@ -0,0 +1,108 @@
"use strict";
(() => {
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
if (!form || !dialog || !list || !rules) return;
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [];
const yes = id => $(id).checked;
const value = id => $(id).value.trim();
const option = (value, key) => { const node = el("option", t(key)); node.value = value; return node; };
const defaults = () => ({
id: "", name: "", enabled: true, http2_enabled: true, http3_enabled: true,
frontend: { hosts: [], listener_ids: [] }, certificate_id: null,
rules: [{ id: "root", enabled: true, source_scheme: "any", match_type: "prefix", path_prefix: "/", target_type: "url", target: "https://example.com/", target_scheme: "preserve", path_mode: "append", query_mode: "preserve", status_code: 308, cache_seconds: 0 }]
});
function addRule(rule = {}, index = rules.children.length) {
const data = { ...defaults().rules[0], ...rule };
const box = el("fieldset", undefined, "redirect-rule"); box.dataset.redirectRule = "1";
const legend = el("legend", `${t("redirectRule")} ${index + 1}`); box.append(legend);
const grid = el("div", undefined, "vh-grid");
const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; };
const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; };
const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; };
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id;
const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false;
const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme;
const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type;
const prefix = input("redirectSourcePath", "path_prefix"); prefix.required = true; prefix.maxLength = 512; prefix.value = data.path_prefix;
const targetType = select("redirectTargetType", "target_type", [["url", "redirectTargetUrl"], ["host", "redirectTargetHost"], ["path", "redirectTargetPath"]]); targetType.value = data.target_type;
const target = input("redirectTarget", "target"); target.required = true; target.maxLength = 2048; target.value = data.target;
const targetScheme = select("redirectTargetScheme", "target_scheme", [["preserve", "redirectSchemePreserve"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); targetScheme.value = data.target_scheme;
const pathMode = select("redirectPathMode", "path_mode", [["append", "redirectPathAppend"], ["strip_prefix", "redirectPathStrip"], ["replace", "redirectPathReplace"]]); pathMode.value = data.path_mode;
const queryMode = select("redirectQueryMode", "query_mode", [["preserve", "redirectQueryPreserve"], ["drop", "redirectQueryDrop"]]); queryMode.value = data.query_mode;
const status = select("redirectStatus", "status_code", [["301", "redirect301"], ["302", "redirect302"], ["303", "redirect303"], ["307", "redirect307"], ["308", "redirect308"]]); status.value = String(data.status_code);
const cache = input("redirectCacheSeconds", "cache_seconds", "number"); cache.min = "0"; cache.max = "31536000"; cache.step = "1"; cache.value = String(data.cache_seconds || 0);
const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", () => { box.remove(); renumber(); changed(); });
const actions = el("div", undefined, "form-actions"); actions.append(remove); grid.append(actions); box.append(grid); rules.append(box); syncRule(box); targetType.addEventListener("change", () => syncRule(box));
}
function syncRule(box) {
const kind = box.querySelector('[data-field="target_type"]').value;
const scheme = box.querySelector('[data-field="target_scheme"]').closest("label"); scheme.hidden = kind !== "host";
const target = box.querySelector('[data-field="target"]'); target.placeholder = kind === "url" ? "https://app.example.com/" : kind === "host" ? "app.example.com:8443" : "/new-path";
}
function renumber() { [...rules.children].forEach((box, i) => { const legend = box.querySelector("legend"); if (legend) legend.textContent = `${t("redirectRule")} ${i + 1}`; }); }
function changed() { dirty = true; }
async function allowDiscard() { return !dirty || await appConfirm(t("redirectUnsaved"), { danger: true, confirmLabel: t("discardAction") }); }
function listenerChoices(selected = []) {
const holder = $("redirect-listeners"); holder.replaceChildren();
const control = window.rustProxyIntegrations.bindings(holder, selected, !editing); readRedirectBindings = control.read;
const h2 = $("redirect-http2"), h3 = $("redirect-http3");
control.onChange(() => {
const capabilities = control.capabilities();
h2.disabled = !capabilities.http2; h3.disabled = !capabilities.http3;
if (!capabilities.http2) h2.checked = false;
if (!capabilities.http3) h3.checked = false;
});
}
function certificateChoices(selected) {
const select = $("redirect-certificate"); select.replaceChildren(option("", "vhCertificateDefault"));
for (const cert of envelope?.config.tls?.certificates || []) { const node = el("option", cert.id); node.value = cert.id; select.append(node); }
select.value = selected || "";
}
function load(item = defaults()) {
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
$("redirect-name").value = item.name || "";
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
$("redirect-enabled").checked = item.enabled !== false; $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
rules.replaceChildren(); for (const [i, rule] of (item.rules?.length ? item.rules : defaults().rules).entries()) addRule(rule, i);
dialog.showModal();
}
function readRule(box) {
const get = name => box.querySelector(`[data-field="${name}"]`);
return { id: get("id").value.trim(), enabled: get("enabled").checked, source_scheme: get("source_scheme").value, match_type: get("match_type").value, path_prefix: get("path_prefix").value.trim(), target_type: get("target_type").value, target: get("target").value.trim(), target_scheme: get("target_scheme").value, path_mode: get("path_mode").value, query_mode: get("query_mode").value, status_code: Number(get("status_code").value), cache_seconds: Number(get("cache_seconds").value) || 0 };
}
function collect() {
const id = value("redirect-id"), name = value("redirect-name") || id, hosts = readRedirectHosts();
const listener_ids = readRedirectBindings();
if (!hosts.length) throw new Error(t("redirectHostsRequired")); if (!listener_ids.length) throw new Error(t("redirectListenersRequired"));
const values = [...rules.querySelectorAll('[data-redirect-rule="1"]')].map(readRule); if (!values.length) throw new Error(t("redirectRulesRequired"));
return { id, name, enabled: yes("redirect-enabled"), http2_enabled: yes("redirect-http2"), http3_enabled: yes("redirect-http3"), frontend: { hosts, listener_ids }, certificate_id: value("redirect-certificate") || null, rules: values };
}
function render() {
if (!envelope) return; const query = ($("redirect-search").value || "").trim().toLowerCase();
const entries = (envelope.config.redirects || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || []), ...(item.rules || []).map(rule => rule.target)].join(" ").toLowerCase().includes(query));
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card;
}));
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try { const item = collect(), next = structuredClone(envelope.config.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); }
});
$("redirect-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
$("redirect-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } };
if (envelope) render();
})();
+197
View File
@@ -0,0 +1,197 @@
"use strict";
// Persistent tab DOM: switching tabs never discards unsaved form values.
(() => {
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners";
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
? "logging"
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
const tabAliases = { http2: "connections" };
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
function syncLocation() {
if (typeof location === "undefined" || !["/settings", "/configuration", "/logging"].includes(location.pathname) || !["http:", "https:"].includes(location.protocol)) return;
const url = new URL(location.href); const alreadyCanonical = url.pathname === "/settings" && url.searchParams.get("tab") === active; if (alreadyCanonical) return;
url.pathname = "/settings"; url.search = ""; url.searchParams.set("tab", active);
history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: active }, "", `${url.pathname}${url.search}${url.hash}`);
}
function select(name, focus = false, persist = true) {
if (!buttons.some(b => b.dataset.settingsTab === name)) return; active = name;
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
if (persist) syncLocation();
}
for (const b of buttons) {
b.addEventListener("click", () => select(b.dataset.settingsTab)); b.addEventListener("keydown", event => {
const index = buttons.indexOf(b); let next;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % buttons.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + buttons.length - 1) % buttons.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = buttons.length - 1; else return;
event.preventDefault(); select(buttons[next].dataset.settingsTab, true);
});
}
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
function selectCacheTab(name, focus = false) {
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
for (const button of cacheTabs) { const chosen = button.dataset.cacheSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); }
document.querySelectorAll("[data-cache-settings-page]").forEach(page => page.hidden = page.dataset.cacheSettingsPage !== name);
}
for (const button of cacheTabs) {
button.addEventListener("click", () => selectCacheTab(button.dataset.cacheSettingsTab));
button.addEventListener("keydown", event => { const index = cacheTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % cacheTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + cacheTabs.length - 1) % cacheTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = cacheTabs.length - 1; else return; event.preventDefault(); selectCacheTab(cacheTabs[next].dataset.cacheSettingsTab, true); });
}
selectCacheTab("general");
const httpTabs = [...document.querySelectorAll("[data-http-settings-tab]")];
function selectHttpTab(name, focus = false) {
if (!httpTabs.some(button => button.dataset.httpSettingsTab === name)) return;
for (const button of httpTabs) { const chosen = button.dataset.httpSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); }
document.querySelectorAll("[data-http-settings-page]").forEach(page => page.hidden = page.dataset.httpSettingsPage !== name);
}
for (const button of httpTabs) {
button.addEventListener("click", () => selectHttpTab(button.dataset.httpSettingsTab));
button.addEventListener("keydown", event => { const index = httpTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % httpTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + httpTabs.length - 1) % httpTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = httpTabs.length - 1; else return; event.preventDefault(); selectHttpTab(httpTabs[next].dataset.httpSettingsTab, true); });
}
selectHttpTab("http2");
let certificateApiState = null, certificateApiLoading = false;
function certificateApiCredentials() {
const values = certificateApiState?.credentials;
if (Array.isArray(values)) return values;
if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }];
return [];
}
function certificateApiExample() {
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
$("certificate-api-curl").textContent = `curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \
--header 'Content-Type: application/json' \
--data @deploy.json \
${location.origin}/api/certificates/deploy
# Reload one manual/file-backed certificate without changing its configuration:
curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \
${location.origin}/api/certificates/CERTIFICATE_ID/reload`;
}
function renderCertificateApi() {
const state = certificateApiState, target = $("certificate-api-credentials");
if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; }
const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32;
$("certificate-api-status").textContent = credentials.length
? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max)
: t("certificateApiDisabled");
$("certificate-api-revoke-all").disabled = !credentials.length;
if (target) {
target.replaceChildren();
for (const credential of credentials) {
const row = el("div", undefined, "certificate-api-credential-row");
const info = el("div", undefined, "certificate-api-credential-info");
info.append(el("b", credential.key_id || "-", "mono"));
if (credential.comment) info.append(el("span", credential.comment, "fine"));
const lastUsed = credential.last_used_at ? new Date(Number(credential.last_used_at) * 1000).toLocaleString() : t("certificateApiNeverUsed");
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
const remove = action(t("certificateApiRevoke"), async () => {
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
remove.disabled = true;
try {
await api(`/api/certificate-api/${encodeURIComponent(credential.key_id)}`, { method: "DELETE" });
certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id);
certificateApiState.configured = certificateApiState.credentials.length > 0;
renderCertificateApi(); toast(t("certificateApiRevoked"));
} catch (error) { toast(error.message, true); remove.disabled = false; }
});
remove.classList.add("danger");
row.append(info, remove); target.append(row);
}
}
certificateApiExample();
}
async function loadCertificateApi(force = false) {
if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return;
certificateApiLoading = true;
try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); }
catch (error) { $("certificate-api-status").textContent = error.message; }
finally { certificateApiLoading = false; }
}
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
function example() {
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim();
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
}
function render() {
if (!envelope) return; const p = { ...defaults, ...envelope.config.prometheus };
if (!settingsDirty.has("prometheus-form")) {
$("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path;
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
}
const status = latestStatus?.prometheus;
$("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example();
const admin = envelope.config.administration || {}, adminStatus = latestStatus?.administration;
if (!settingsDirty.has("administration-form")) {
$("admin-listen").value = adminStatus?.locked_listen || admin.listen || adminStatus?.desired?.listen || latestStatus?.admin_listen || "127.0.0.1:9090";
$("admin-tls").checked = !!admin.tls;
const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate");
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
$("admin-session-days").value = Number(admin.session_days || 30);
}
const listenLocked = !!adminStatus?.listen_locked;
$("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked;
const syncAdminTls = () => { $("admin-certificate").disabled = !$("admin-tls").checked; }; syncAdminTls();
$("admin-status").textContent = adminStatus?.restart_required ? t("restartPending") : adminStatus?.active?.tls ? `${t("administrationActiveHttps")}: ${adminStatus.active.listen}` : `${t("administrationActiveHttp")}: ${adminStatus?.active?.listen || latestStatus?.admin_listen || "-"}`;
const runtime = latestStatus?.runtime || {};
for (const [id, key] of [["runtime-platform", "platform"], ["runtime-statistics-source", "statistics_source"], ["runtime-data-directory", "data_directory"], ["runtime-database-path", "database_path"]]) {
const node = $(id); if (node) node.textContent = runtime[key] || t("unavailable");
}
const policy = envelope.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
if (!settingsDirty.has("tls-policy-form")) { $("tls-policy-12").checked = policy.tls12 !== false; $("tls-policy-13").checked = policy.tls13 !== false; $("tls-policy-ciphers").value = (policy.cipher_suites || []).join("\n"); }
const available = latestStatus?.tls?.cipher_suites || [];
const effective = latestStatus?.tls?.effective_cipher_suites || [];
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
renderCertificateApi(); loadCertificateApi();
}
bindSettingsForm("prometheus-form", next => {
next.prometheus = {
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
};
}, "prometheus");
bindSettingsForm("administration-form", next => {
const locked = !!latestStatus?.administration?.listen_locked;
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value) };
}, "administration");
bindSettingsForm("tls-policy-form", next => {
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
next.tls ||= {}; next.tls.policy = { tls12: $("tls-policy-12").checked, tls13: $("tls-policy-13").checked, cipher_suites: $("tls-policy-ciphers").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean) };
}, "tls");
$("certificate-api-form").addEventListener("submit", async event => {
event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true;
try {
const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim();
const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment }) });
const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential;
$("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated"));
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
});
$("certificate-api-revoke-all").addEventListener("click", async () => {
if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return;
const button = $("certificate-api-revoke-all"); button.disabled = true;
try { await api("/api/certificate-api", { method: "DELETE" }); certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] }; $("certificate-api-secret-wrap").hidden = true; $("certificate-api-secret").textContent = ""; renderCertificateApi(); toast(t("certificateApiRevoked")); }
catch (error) { toast(error.message, true); } finally { button.disabled = false; }
});
$("certificate-api-key-id").addEventListener("input", certificateApiExample);
$("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; });
$("prometheus-form").addEventListener("input", example);
$("prom-preview").addEventListener("click", async () => {
const epoch = authEpoch, button = $("prom-preview"); button.disabled = true;
try {
const response = await fetch("/api/metrics", { credentials: "same-origin", headers: { "Accept": "text/plain" } }); if (!response.ok) throw new Error(`${t("prometheusPreviewFailed")} (${response.status})`);
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+177
View File
@@ -0,0 +1,177 @@
"use strict";
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
// listeners separate from the HTTP policy stack.
(() => {
let editing = null, revision = null, dirty = false, initialized = false;
const chartHistory = new Map();
const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; };
const field = (parent, name, key, value, type = "text", options = {}) => {
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
const input = document.createElement(type === "select" ? "select" : "input"); input.name = name;
if (type === "select") for (const [val, optionKey] of options.values || []) { const option = text("option", optionKey); option.value = val; input.append(option); }
else input.type = type;
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
for (const attr of ["min", "max", "step", "maxLength", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
parent.append(label); return input;
};
const group = (key, tab) => { const box = el("fieldset", undefined, "vh-group"); box.dataset.tab = tab; box.append(text("legend", key)); const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body; };
const note = (parent, key) => parent.append(text("p", key, "fine vh-wide"));
const read = name => form.querySelector(`[name="${name}"]`);
const str = name => read(name).value.trim();
const num = name => { const value = Number(str(name)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
const yes = name => read(name).checked;
const changed = () => { dirty = true; };
const defaults = () => ({ id: "", name: "", enabled: true, protocol: "tcp", listen: "0.0.0.0:10000", upstream: "127.0.0.1:10001", connect_timeout_seconds: 5, idle_timeout_seconds: 300, max_connections: 1024 });
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
function activateTab(name) {
document.querySelectorAll("#stream-tabs button").forEach(node => { node.classList.toggle("active", node.dataset.tab === name); node.setAttribute("aria-pressed", String(node.dataset.tab === name)); });
container.querySelectorAll(".vh-group").forEach(groupNode => groupNode.hidden = groupNode.dataset.tab !== name);
}
function initTabs() {
const tabs = $("stream-tabs"); tabs.replaceChildren();
for (const [name, key] of [["details", "streamDetailsTab"], ["network", "streamNetworkTab"], ["limits", "streamLimitsTab"]]) { const node = button(key, () => activateTab(name)); node.dataset.tab = name; tabs.append(node); }
activateTab("details");
}
function socketChipField(parent, value) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "streamListen"));
const box = el("div", undefined, "host-chip-editor stream-socket-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 128; input.placeholder = "0.0.0.0:10000 / [::]:10000"; input.setAttribute("aria-label", t("streamListenAdd"));
let current = value || "";
const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); if (current) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", current)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${current}`); remove.addEventListener("click", () => { current = ""; sync(); changed(); input.focus(); }); chip.append(remove); box.insertBefore(chip, input); } input.hidden = Boolean(current); };
const commit = () => { const candidate = input.value.trim(); if (!candidate) return; if (candidate.length > 128 || /[\s,]/.test(candidate)) throw new Error(t("streamListenInvalid")); current = candidate; input.value = ""; sync(); changed(); };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { commit(); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { commit(); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(box, text("p", "streamListenHelp", "fine")); parent.append(wrapper); sync();
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
}
let readListen = () => "";
function load(value = defaults(), show = true) {
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
let g = group("streamEditor", "details");
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
note(g, "streamsHelp");
g = group("streamNetwork", "network");
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 });
note(g, "streamDedicatedListenerHelp");
g = group("streamLimits", "limits");
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync();
note(g, "streamLimitsHelp");
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
if (latestStatus) renderStatus(latestStatus);
dirty = false;
}
function collect() {
return {
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5),
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
};
}
async function closeEditor() { if (!(await allowDiscard())) return false; dialog.close(); return true; }
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), false);
const configured = envelope.config.streams || [];
const configuredIds = new Set(configured.map(stream => stream.id));
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
const query = ($("stream-search")?.value || "").trim().toLowerCase();
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
const cards = streams.map(stream => {
const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", stream.name || stream.id, "entity-title"), el("span", String(stream.protocol || "tcp").toUpperCase(), "pill live"));
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
actions.append(button("edit", async () => { if (await allowDiscard()) load(structuredClone(stream)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
}));
head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
metric("status", "streamState"); metric("streamActiveSessions", "streamActive"); metric("streamSessionsTotal", "streamSessions"); metric("streamErrors", "streamErrors"); metric("streamRejected", "streamRejected"); metric("streamTransferred", "streamTransferred"); box.append(live);
const chart = el("div", undefined, "vhost-chart stream-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "streamTrafficChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.streamChartValue = stream.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.streamChart = stream.id; svg.setAttribute("aria-label", t("streamTrafficChart"));
for (const cls of ["stream-chart-rx", "stream-chart-tx"]) { const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", cls); svg.append(polyline); }
chart.append(svg); const legend = el("div", undefined, "stream-chart-legend"); legend.append(el("span", `RX · ${t("streamClientToUpstream")}`), el("span", `TX · ${t("streamUpstreamToClient")}`)); chart.append(legend); box.append(chart);
return box;
});
$("stream-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "streamSearchEmpty" : "streamEmpty", "fine")]));
if (latestStatus) renderStatus(latestStatus);
}
function addPoint(history, rx, tx) {
history.pendingRx += rx; history.pendingTx += tx; history.pendingCount += 1;
if (history.pendingCount >= history.bucketSize) { history.points.push([history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]); history.pendingRx = 0; history.pendingTx = 0; history.pendingCount = 0; }
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) { const a = history.points[i], b = history.points[i + 1] || a; compacted.push([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]); } history.points = compacted; history.bucketSize *= 2; }
}
function renderStatus(status) {
const streamStatus = status.streams || {}, metrics = streamStatus.metrics || {}, active = new Map((streamStatus.active || []).map(item => [item.id, item]));
$("stream-restart-banner").hidden = !streamStatus.restart_required; $("stream-restart-banner").textContent = t("streamRestartPending");
const activeList = (streamStatus.active || []).filter(item => item.enabled !== false).map(item => `${String(item.protocol || "tcp").toUpperCase()} ${item.listen} → ${item.upstream}`);
$("stream-active-summary").textContent = `${t("streamActive")}: ${activeList.join(" | ") || t("streamNone")}`;
const now = performance.now();
for (const desired of envelope?.config.streams || []) {
const id = desired.id, live = active.get(id), data = metrics[id];
const same = live && JSON.stringify(live) === JSON.stringify(desired);
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
if (!data) continue;
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;
history.lastTime = now; history.lastIn = inBytes; history.lastOut = outBytes; addPoint(history, rx, tx); chartHistory.set(id, history);
const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points;
const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" ");
const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1));
const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`;
}
if (editing) { const data = metrics[editing]; $("stream-live-summary").textContent = data ? `${t("streamActiveSessions")}: ${fmt(data.active)} · RX ${bytes(data.bytes_in)} · TX ${bytes(data.bytes_out)} · ${t("streamErrors")}: ${fmt(data.errors)}` : t("streamNoLiveMetrics"); }
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const value = collect(), next = structuredClone(envelope.config); next.streams ||= [];
if (!editing && next.streams.some(stream => stream.id === value.id)) throw new Error(t("streamDuplicate"));
if (!editing && next.streams.length >= 128) throw new Error(t("streamLimit"));
const index = next.streams.findIndex(stream => stream.id === editing); if (index < 0) next.streams.push(value); else next.streams[index] = value;
if (await saveSection("streams", next.streams || [])) { dirty = false; load(value, false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
$("stream-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
$("stream-listener-add").addEventListener("click", async () => { if (await allowDiscard()) load(); });
$("stream-search").addEventListener("input", render);
$("stream-close").addEventListener("click", closeEditor);
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
File diff suppressed because it is too large. Load diff
+170
View File
@@ -0,0 +1,170 @@
"use strict";
(() => {
let loading = false, generation = 0, lastData = null;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null;
const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
const LIVE_CHART_STORAGE_KEY = "rustproxy.transform.liveChartSamples";
let liveChartLimit = 60;
const scope = () => $("transform-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
const percent = counters => {
const original = Number(counters?.original_bytes) || 0;
const saved = Number(counters?.saved_bytes) || 0;
return original > 0 ? `${fmt(saved / original * 100, 1)}%` : "0.0%";
};
const averageMs = counters => {
const examined = Number(counters?.examined) || 0;
const duration = Number(counters?.duration_us) || 0;
return examined ? `${fmt(duration / examined / 1000, 2)} ms` : "0.00 ms";
};
const emptyCounters = () => ({ examined: 0, applied: 0, skipped: 0, errors: 0, cache_hits: 0, original_bytes: 0, transformed_bytes: 0, saved_bytes: 0, duration_us: 0, by_kind: {}, reasons: {} });
function resetLiveChart(selected = scope()) {
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null;
for (const values of Object.values(liveChartSamples)) values.length = 0;
for (const id of ["transform-live-applied-line", "transform-live-cache-line", "transform-live-skipped-line", "transform-live-error-line"]) $(id)?.setAttribute("points", "0,170 900,170");
for (const id of ["transform-live-applied-rate", "transform-live-cache-rate", "transform-live-skipped-rate", "transform-live-error-rate"]) if ($(id)) $(id).textContent = "0.0/s";
if ($("transform-live-chart-value")) $("transform-live-chart-value").textContent = "-";
}
function redrawLiveChart() {
const peak = Math.max(1, ...liveChartSamples.applied, ...liveChartSamples.cache_hits, ...liveChartSamples.skipped, ...liveChartSamples.errors);
$("transform-live-applied-line")?.setAttribute("points", sparklinePoints(liveChartSamples.applied, 900, 180, 10, peak));
$("transform-live-cache-line")?.setAttribute("points", sparklinePoints(liveChartSamples.cache_hits, 900, 180, 10, peak));
$("transform-live-skipped-line")?.setAttribute("points", sparklinePoints(liveChartSamples.skipped, 900, 180, 10, peak));
$("transform-live-error-line")?.setAttribute("points", sparklinePoints(liveChartSamples.errors, 900, 180, 10, peak));
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60;
for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit);
if ($("transform-live-samples")) $("transform-live-samples").value = String(liveChartLimit);
if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch {} }
redrawLiveChart();
}
function initializeLiveChartLimit() {
let saved = 60;
try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch {}
setLiveChartLimit(saved, false);
}
function renderLiveChart(counters) {
const selected = scope();
if (liveChartScope !== selected) resetLiveChart(selected);
const now = performance.now(), current = {
applied: Math.max(0, Number(counters.applied) || 0),
cache_hits: Math.max(0, Number(counters.cache_hits) || 0),
skipped: Math.max(0, Number(counters.skipped) || 0),
errors: Math.max(0, Number(counters.errors) || 0)
}, rates = { applied: 0, cache_hits: 0, skipped: 0, errors: 0 };
if (liveChartPrevious && liveChartPreviousAt != null) {
const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000);
for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed;
}
liveChartPrevious = current; liveChartPreviousAt = now;
for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); }
redrawLiveChart();
$("transform-live-applied-rate").textContent = `${rates.applied.toFixed(1)}/s`;
$("transform-live-cache-rate").textContent = `${rates.cache_hits.toFixed(1)}/s`;
$("transform-live-skipped-rate").textContent = `${rates.skipped.toFixed(1)}/s`;
$("transform-live-error-rate").textContent = `${rates.errors.toFixed(1)}/s`;
$("transform-live-chart-value").textContent = `Σ ${(rates.applied + rates.cache_hits + rates.skipped + rates.errors).toFixed(1)}/s`;
}
function countersFor(data) {
if (scope() === "*") return data?.metrics?.total || emptyCounters();
return data?.metrics?.scopes?.find(row => row.vhost === scope()) || emptyCounters();
}
function configure(data) {
const selected = scope(), labels = new Map();
for (const vhost of data?.vhosts || []) {
const hosts = Array.isArray(vhost.hosts) ? vhost.hosts.join(", ") : "";
labels.set(vhost.id, hosts ? `${vhost.id} / ${hosts}` : vhost.id);
}
for (const row of data?.metrics?.scopes || []) if (!labels.has(row.vhost)) labels.set(row.vhost, row.vhost);
const items = [el("option", t("logAllVhosts"))]; items[0].value = "*";
for (const [id, label] of [...labels].sort((a, b) => a[0].localeCompare(b[0]))) {
const option = el("option", label); option.value = id; items.push(option);
}
$("transform-scope").replaceChildren(...items);
$("transform-scope").value = items.some(item => item.value === selected) ? selected : "*";
}
function renderTypes(counters) {
const kinds = [
["javascript", "JavaScript"], ["css", "CSS"], ["html", "HTML"], ["svg", "SVG"], ["json", "JSON"]
];
$("transform-type-rows").replaceChildren(...kinds.map(([key, label]) => {
const tr = el("tr"); tr.append(el("td", label), el("td", number(counters?.by_kind?.[key] || 0))); return tr;
}));
}
function renderReasons(counters) {
const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]);
const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => {
const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag;
}));
$("transform-reasons-empty").hidden = entries.length > 0;
$("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort");
}
function renderVhosts(data) {
const wanted = scope();
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
const tr = el("tr");
for (const value of [
row.vhost,
number(row.applied),
number(row.cache_hits),
number(row.skipped),
number(row.errors),
fmtBytes(row.saved_bytes || 0),
percent(row),
averageMs(row)
]) tr.append(el("td", value));
return tr;
}));
$("transform-vhosts-empty").hidden = rows.length > 0;
}
function render(data) {
lastData = data; configure(data);
const counters = countersFor(data);
renderLiveChart(counters);
$("tp-applied").textContent = number(counters.applied);
$("tp-examined").textContent = `${t("transformExamined")}: ${number(counters.examined)}`;
$("tp-cache-hits").textContent = number(counters.cache_hits);
$("tp-saved").textContent = fmtBytes(counters.saved_bytes || 0);
$("tp-byte-flow").textContent = `${fmtBytes(counters.original_bytes || 0)} → ${fmtBytes(counters.transformed_bytes || 0)}`;
$("tp-reduction").textContent = percent(counters);
$("tp-skipped").textContent = number(counters.skipped);
$("tp-errors").textContent = number(counters.errors);
$("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`;
renderTypes(counters); renderReasons(counters); renderVhosts(data);
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
}
async function refresh() {
if (!authenticated || currentPanel !== "transform" || loading) return;
loading = true; const epoch = authEpoch, token = generation;
try {
const data = await api("/api/transform");
if (epoch !== authEpoch || token !== generation || !authenticated) return;
render(data);
} catch (error) {
if (epoch === authEpoch) { $("transform-panel-state").textContent = error.message; if (error.status === 401) lock(); }
} finally {
loading = false;
if (token !== generation && authenticated && currentPanel === "transform") void refresh();
}
}
initializeLiveChartLimit();
$("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
$("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); });
$("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); });
$("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); });
setInterval(() => { if (!document.hidden && $("transform-live")?.checked) void refresh(); }, 3000);
window.rustProxyTransformPanel = {
refresh,
reset() {
generation++; lastData = null; resetLiveChart();
$("transform-type-rows")?.replaceChildren();
$("transform-vhost-rows")?.replaceChildren();
$("transform-reason-list")?.replaceChildren();
if ($("transform-panel-state")) $("transform-panel-state").textContent = "";
}
};
})();
+899
View File
@@ -0,0 +1,899 @@
"use strict";
// Vhost UI: language content comes from langs/*.json, never a locale switch.
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
(() => {
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const dialog = $("vhost-dialog");
const form = $("vhost-form"), container = $("vhost-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const field = (parent, name, key, value, type = "text", options = {}) => {
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
input.name = name;
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
else if (type !== "textarea") input.type = type;
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
if (type === "textarea") input.spellcheck = false;
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
parent.append(label); return input;
};
const group = (key, advanced = false) => {
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
const str = (name, scope) => read(name, scope).value.trim();
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
const yes = (name, scope) => read(name, scope).checked;
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const nullable = (name, scope) => str(name, scope) || null;
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
return {
enabled: yes("vh-transform-enabled"),
javascript: tri("vh-transform-javascript"),
css: tri("vh-transform-css"),
html: tri("vh-transform-html"),
svg: tri("vh-transform-svg"),
json: tri("vh-transform-json"),
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
};
}
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
function validateHeaders(value) {
const fail = () => { throw new Error(t("headersInvalid")); };
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
for (const direction of ["request", "response"]) {
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
}
} return value;
}
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
const holders = {}, adders = {}, emptyStates = {};
const renumber = direction => {
const rows = holders[direction];
[...rows.children].forEach((row, index) => {
const title = row.querySelector(".vh-header-rule-title");
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
});
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
};
const sync = () => {
const result = {};
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
return op === "remove" ? { op, name } : { op, name, value };
});
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
};
for (const direction of ["request", "response"]) {
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
const heading = el("div", undefined, "location-header-direction-head");
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
block.append(heading);
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
const add = (value = { op: "set", name: "", value: "" }) => {
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
const row = el("article", undefined, "vh-header-row");
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
const fields = el("div", undefined, "vh-header-rule-fields");
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
row.append(fields);
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
};
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
}
target.addEventListener("input", () => {
try {
const value = validateHeaders(JSON.parse(target.value));
for (const direction of ["request", "response"]) {
holders[direction].replaceChildren();
for (const rule of value[direction] || []) adders[direction](rule);
renumber(direction);
}
target.setCustomValidity(""); onChange();
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
});
note(parent, "locationHeadersHelp");
return { read: () => validateHeaders(JSON.parse(target.value)) };
}
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
function administrationListenCandidates() {
return [
envelope?.config.administration?.listen,
latestStatus?.administration?.locked_listen,
latestStatus?.administration?.desired?.listen,
latestStatus?.administration?.active?.listen,
latestStatus?.admin_listen,
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
}
function adminListenerBackendCandidate(raw) {
const splitSocket = value => {
const textValue = String(value || "").trim();
if (!textValue) return null;
if (textValue.startsWith("[")) {
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
}
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
};
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
if (!adminListeners.length) return null;
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
return adminListeners.some(admin => {
if (port !== admin.port) return false;
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
});
}
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { required: true, maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
const controls = {};
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
note(riskGate, "vhLocalListenerRiskHelp");
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
actions.append(button("vhDebugOriginPreset", async () => {
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
controls["member-pool"].checked = true; controls["member-private"].checked = true;
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
}));
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
const syncRisk = (clearWhenHidden = false) => {
const show = adminListenerBackendCandidate(url.value);
riskGate.hidden = show !== true;
if (clearWhenHidden && show === false) localRisk.checked = false;
};
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
row.syncLocalRisk = () => syncRisk(false);
syncIdentity(); syncRisk(false); parent.append(row);
}
function countryRateEditor(parent, values = []) {
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
const add = (value = {}) => {
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
const row = el("div", undefined, "vh-row vh-grid");
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
};
for (const value of values) add(value);
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
return () => [...rows.children].map(row => ({
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
}));
}
function debugOriginIssues(members) {
return members.filter(member => {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
});
}
function aclEditor(parent, initial, onChange = () => { }) {
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
}
function openLocationHelp(topic = "locations") {
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
if (!dialog || !title || !body) return;
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
const section = (headingKey, keys, cls = "") => {
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
if (headingKey) box.append(text("h3", headingKey));
for (const key of keys) box.append(el("p", t(key)));
return box;
};
if (topic === "headers") {
body.replaceChildren(
el("p", t("headersDocsIntro"), "location-doc-intro"),
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
);
} else {
body.replaceChildren(
el("p", t("locationDocsIntro"), "location-doc-intro"),
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
);
}
dialog.showModal();
}
function locationsEditor(parent, values) {
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
acl: r.readAcl(), headers: r.readHeaders(),
rewrite: { mode: action === "proxy" ? rewriteMode : "preserve", value: action === "proxy" && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
};
});
}
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
function add(value = {}) {
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
const row = el("details", undefined, "vh-location"); row.open = true;
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const action = field(body, "location-action", "locationAction", value.response ? "return" : "proxy", "select", { values: [["proxy", "locationProxyAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
const preview = el("section", undefined, "vh-wide location-preview");
preview.append(text("h4", "locationPreviewTitle"));
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
}
preview.append(previewGrid); body.append(preview);
const actions = el("div", undefined, "vh-wide location-actions");
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = isReturn; cache.disabled = isReturn; dedicated.disabled = isReturn;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
if (isReturn) {
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
const replacement = read("location-rewrite-value", row).value.trim();
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
try {
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
} catch { previewValues.headers.textContent = t("headersInvalid"); }
};
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
rows.append(row); syncUi();
}
// The main vhost backend is an internal fallback, not a Custom location.
// An empty location list must stay empty so the editor cannot accidentally
// delete or recreate the main route as user configuration.
for (const value of values) add(value);
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
sync(); return readRows;
}
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 = []) {
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"); 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");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
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");
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");
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 });
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 || {};
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, "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/v0.24.5", "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("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");
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) }));
const bindingSelection = readBindings();
if (!bindingSelection.length || !members.length) throw new Error(t("vhJsonInvalid"));
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"), force_ssl: yes("force-ssl"), 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(), 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() },
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") },
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") }
};
}
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
const haystack = [
v.id,
...(v.frontend?.hosts || []),
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
const cards = visibleVhosts.map(v => {
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
openButton.disabled = !browseHost || v.enabled === false;
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
config.listeners?.endpoints?.find(e => e.id === id) || { id }
);
const listeners = selectedEndpoints.map(endpoint => {
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
});
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
const protocols = [
"HTTP/1.1",
v.http2_enabled !== false && supportsH2 ? "H2" : null,
v.http3_enabled !== false && supportsH3 ? "H3" : null,
].filter(Boolean).join(" + ");
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
const detailItems = [
["vhListeners", listeners.join(" | ") || "-"],
["vhProtocols", protocols],
["tlsPolicy", tlsPolicy],
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
["vhBackendPool", `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
if (v.websockets) {
const transports = ["H1"];
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
}
if (policy.children.length) box.append(policy);
return box;
});
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
if (latestStatus) renderStatus(latestStatus);
}
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
function backendTrafficRate(poolId, member, snapshot, now, seen) {
const key = `${poolId}\u0000${member.id}`; seen.add(key);
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
const previous = backendTrafficHistory.get(key);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
const rate = reported > 0 ? reported : observed;
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
return rate;
}
function renderStatus(status) {
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
for (const pool of status.backend_pools || []) {
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
for (const member of members) {
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
}
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
summaries.set(pool.id, summary || t("vhHealthEmpty"));
aggregate.set(pool.id, {
disabled: vhostDisabled,
healthMode: pool.health_mode,
up,
total: members.length,
active,
requests,
errors,
traffic,
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
});
}
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
$("pool-rows").replaceChildren(...rows); $("pool-empty").hidden = rows.length > 0;
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
for (const [id, data] of aggregate) {
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
history.pendingSum += rate; history.pendingCount += 1;
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
chartHistory.set(id, history);
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
}
$("vhost-health-live").textContent = editing ? (summaries.get(editing) || t("vhHealthEmpty")) : t("vhNewHealthHint");
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy || uploading) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const epoch = draftEpoch; uploading = true;
try { await basicEditor.prepare(); } finally { uploading = false; }
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
if (saved) { load(value, [], false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
load(saved ? structuredClone(saved) : defaults());
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();