This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 11:09:48 +02:00
1 parent 4afa88a65e
commit 2ab44c4c62
49 files changed
+1523 -176

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
"use strict";
window.rustProxyApiTokens = (() => {
let loading = false;
const hideSecret = () => { $("api-token-secret").textContent = ""; $("api-token-secret-wrap").hidden = true; };
async function refresh() {
if (!authenticated || loading) return;
loading = true;
try {
const result = await api("/api/api-tokens");
const list = $("api-token-list"); list.replaceChildren();
for (const entry of result.tokens || []) {
const card = el("div", undefined, "entity");
card.append(el("span", `${entry.id} · ${entry.permission.toUpperCase()} · ${entry.comment || "-"}`, "entity-title"));
card.append(el("p", `${t("apiTokenIpAllow")}: ${(entry.allowed_cidrs || []).join(", ")}`, "fine"));
card.append(el("p", `${t("apiTokenLastUsed")}: ${entry.last_used_at ? formatDateTime(entry.last_used_at * 1000) : "-"}`, "fine"));
card.append(action(t("apiTokenRevoke"), async () => {
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return;
try { await api(`/api/api-tokens/${encodeURIComponent(entry.id)}`, { method: "DELETE" }); hideSecret(); await refresh(); }
catch (error) { toast(error.message, true); }
}));
list.append(card);
}
if (!result.tokens?.length) list.append(el("p", t("apiTokenEmpty"), "fine"));
} catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { loading = false; }
}
$("api-tokens-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated) return;
const button = event.submitter; button.disabled = true;
hideSecret();
try {
const result = await api("/api/api-tokens", {
method: "POST", body: JSON.stringify({
permission: $("api-token-permission").value,
comment: $("api-token-comment").value.trim(),
allowed_cidrs: $("api-token-cidrs").value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
})
});
$("api-token-secret").textContent = result.token;
$("api-token-secret-wrap").hidden = false;
$("api-token-comment").value = "";
await refresh();
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
return { refresh, hideSecret, reset() { hideSecret(); $("api-token-list").replaceChildren(); } };
})();
+5 -1
View File
@@ -151,7 +151,7 @@ function setPanel(name, navigate = true) {
currentPanel = name;
$("overview-refresh").hidden = name !== "overview";
$("overview-samples-toolbar").hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview" || (id === "overview-layout-unlock" && !!window.rustProxyInlineLayout?.isEditing?.());
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
@@ -414,6 +414,10 @@ async function activate(info) {
}
}
if (!authenticated || epoch !== authEpoch) return false;
if (window.rustProxySettings?.current?.() === "security") {
void window.rustProxyApiTokens?.refresh?.();
window.rustProxySettings?.scrollToApiTokens?.();
}
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
@@ -138,6 +138,7 @@ window.rustProxyInlineLayout = (() => {
unlock.setAttribute("aria-pressed", "true");
unlock.dataset.i18n = "overviewLayoutLock";
unlock.textContent = t("overviewLayoutLock");
unlock.hidden = true; // Save and lock is the only save action while unlocked.
actions.hidden = false;
hint.hidden = false;
hint.textContent = t("overviewLayoutInlineHint");
@@ -153,6 +154,7 @@ window.rustProxyInlineLayout = (() => {
unlock.setAttribute("aria-pressed", "false");
unlock.dataset.i18n = "overviewLayoutUnlock";
unlock.textContent = t("overviewLayoutUnlock");
unlock.hidden = false;
actions.hidden = true;
hint.hidden = true;
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
+28 -1
View File
@@ -109,6 +109,33 @@ function renderCertificates() {
box.append(details);
const buttons = el("div", undefined, "entity-actions");
const assignPane = el("div", undefined, "certificate-bulk-assign");
assignPane.hidden = true;
const assignmentList = el("div", undefined, "certificate-bulk-list");
for (const host of envelope.config.vhosts || []) {
const label = el("label", undefined, "check");
const check = document.createElement("input");
check.type = "checkbox"; check.value = host.id;
check.checked = host.certificate_id === cert.id;
label.append(check, el("span", `${host.id} · ${(host.frontend?.hosts || []).join(", ")}`));
assignmentList.append(label);
}
const applyBindings = action(t("certAssignApply"), async () => {
if (busy) return;
const selected = new Set([...assignmentList.querySelectorAll('input:checked')].map(input => input.value));
const next = structuredClone(envelope.config);
for (const host of next.vhosts) {
if (selected.has(host.id)) host.certificate_id = cert.id;
else if (host.certificate_id === cert.id) host.certificate_id = null;
}
await saveSection("vhosts", next.vhosts);
});
assignPane.append(el("p", t("certAssignHelp"), "fine"), assignmentList, applyBindings);
const assignButton = action(t("certAssignVhosts"), () => {
assignPane.hidden = !assignPane.hidden;
});
assignButton.disabled = !(envelope.config.vhosts || []).length;
buttons.append(assignButton);
if (!managed) {
buttons.append(action(t("reloadCertificate"), async () => {
if (busy) return;
@@ -125,7 +152,7 @@ function renderCertificates() {
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
box.append(buttons);
box.append(buttons, assignPane);
return box;
});
$("certificate-list").replaceChildren(...entries);
+26 -4
View File
@@ -2769,10 +2769,14 @@
id="prom-path" maxlength="128" required="" value="/metrics" /></label></div>
<label><span data-i18n="prometheusCidrs"></span><textarea id="prom-cidrs" required=""
rows="3" spellcheck="false"></textarea></label>
<label><span data-i18n="prometheusTokenEnv"></span><input autocomplete="off"
id="prom-token-env" maxlength="128"
placeholder="RUST_PROXY_METRICS_TOKEN" /></label>
<p class="fine" data-i18n="prometheusTokenHelp"></p>
<div class="card-heading"><h3 data-i18n="promTokenTitle">Prometheus bearer token</h3></div>
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only its SHA-256 hash is retained; rotating invalidates the previous token immediately.</p>
<p class="fine" id="prom-token-status" role="status"></p>
<div class="form-actions">
<button class="outline" id="prom-token-generate" type="button" data-i18n="promTokenGenerate">Generate / rotate token</button>
<button class="quiet danger" id="prom-token-revoke" type="button" data-i18n="promTokenRevoke">Revoke token</button>
</div>
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre class="metrics-example" id="prom-token-secret"></pre></div>
<label class="check"><input id="prom-trusted-no-token" type="checkbox" /><span
data-i18n="prometheusTrustedNoToken"></span></label>
<p class="fine" data-i18n="prometheusTrustedNoTokenHelp"></p>
@@ -2864,6 +2868,11 @@
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save and apply</button></div>
</form>
<article class="card edit-form" id="api-tokens-shortcut">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to Settings → Security.</p>
<div class="form-actions"><a class="outline" id="api-tokens-security-link" href="/settings?tab=security#api-tokens-card" data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
</article>
</div>
<div aria-labelledby="settings-tab-system-info" class="settings-page"
data-settings-page="system-info" hidden="" id="settings-page-system-info" role="tabpanel">
@@ -3062,6 +3071,19 @@
type="button">Administration audit</button>
</div>
<div class="security-stack">
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens with read-only or read-write access, restricted to your client IP/CIDR. Existing browser login remains available. Non-loopback requests require HTTPS.</p>
<form id="api-tokens-form">
<label><span data-i18n="apiTokenComment">Description</span><input id="api-token-comment" maxlength="128" placeholder="external-automation" /></label>
<label><span data-i18n="apiTokenPermission">Permissions</span><select id="api-token-permission"><option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option><option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)</option></select></label>
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per line)</span><textarea id="api-token-cidrs" rows="3" required placeholder="192.0.2.10/32"></textarea></label>
<div class="form-actions"><button class="primary" type="submit" data-i18n="apiTokenCreate">Generate API token</button></div>
</form>
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre id="api-token-secret" class="metrics-example"></pre></div>
<div id="api-token-list" class="certificate-api-credentials"></div>
<pre class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
</article>
<article class="card security-account-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="currentSession">CURRENT SESSION</span>
+35
View File
@@ -0,0 +1,35 @@
"use strict";
window.rustProxyPrometheusToken = (() => {
let isConfigured = false;
const hideSecret = () => { $("prom-token-secret").textContent = ""; $("prom-token-secret-wrap").hidden = true; };
function showStatus() {
$("prom-token-status").textContent = t(isConfigured ? "promTokenConfigured" : "promTokenMissing");
$("prom-token-revoke").disabled = !isConfigured;
}
async function refresh() {
if (!authenticated) return;
try {
const result = await api("/api/prometheus-token");
isConfigured = !!result.configured;
showStatus();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
}
$("prom-token-generate").addEventListener("click", async event => {
const button = event.currentTarget; button.disabled = true; hideSecret();
try {
const result = await api("/api/prometheus-token", { method: "POST", body: "{}" });
isConfigured = true; $("prom-token-secret").textContent = result.token;
$("prom-token-secret-wrap").hidden = false;
showStatus(); window.rustProxySettings?.render?.();
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$("prom-token-revoke").addEventListener("click", async () => {
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return;
try {
await api("/api/prometheus-token", { method: "DELETE" });
isConfigured = false; hideSecret(); showStatus(); window.rustProxySettings?.render?.();
} catch (error) { toast(error.message, true); }
});
return { configured: () => isConfigured, refresh, hideSecret, reset() { isConfigured = false; hideSecret(); showStatus(); } };
})();
+29 -6
View File
@@ -48,7 +48,13 @@
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
active = name;
if (persist) { rememberedTab = name; try { localStorage.setItem("rp-settings-last-tab", name); } catch { } }
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
if (typeof window !== "undefined") {
window.rustProxyRefresh?.setSettingsTab(name);
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
}
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();
@@ -61,6 +67,23 @@
});
}
search?.addEventListener("input", applySettingsSearch);
function scrollToApiTokens() {
if (active !== "security" || typeof location === "undefined" || location.hash !== "#api-tokens-card") return;
const target = $("api-tokens-card");
if (target) { target.scrollIntoView({ block: "start" }); target.focus({ preventScroll: true }); }
}
// Preserve a real deep link (including opening in a new tab), while switching
// tabs without reloading the panel for ordinary left-click navigation.
$("api-tokens-security-link")?.addEventListener("click", event => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
select("security");
if (typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol)) {
const url = new URL($("api-tokens-security-link").href);
history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: "security" }, "", `${url.pathname}${url.search}${url.hash}`);
}
scrollToApiTokens();
});
const policyTabs = [...document.querySelectorAll("[data-policy-settings-tab]")].filter(button => typeof button.addEventListener === "function" && typeof button.setAttribute === "function");
function selectPolicyTab(name, focus = false) {
if (!policyTabs.some(button => button.dataset.policySettingsTab === name)) return;
@@ -143,14 +166,14 @@
$("geoip-mmdb-url").required = mmdb && $("geoip-enabled").checked;
}
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" : ""}`;
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), configured = window.rustProxyPrometheusToken?.configured?.() || Boolean(envelope?.config?.prometheus?.bearer_token_env);
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${configured ? "\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;
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["traffic", "include_traffic"], ["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;
@@ -263,7 +286,7 @@
});
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,
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: envelope?.config?.prometheus?.bearer_token_env || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_traffic: $("prom-traffic").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");
@@ -284,6 +307,6 @@
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, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, scrollToApiTokens, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); window.rustProxyApiTokens?.reset?.(); window.rustProxyPrometheusToken?.reset?.(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+18
View File
@@ -12158,4 +12158,22 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
color: var(--muted);
}
.certificate-bulk-assign {
margin-top: 12px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.certificate-bulk-list {
display: grid;
gap: 8px;
max-height: 250px;
overflow-y: auto;
margin: 10px 0;
}
.certificate-bulk-list .check {
overflow-wrap: anywhere;
}