This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 11:16:41 +01:00
1 parent d7fb869783
commit 0cde2c8a18
21 files changed
+195 -45

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.36.5",
"version": "0.36.6",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
+2 -1
View File
@@ -773,7 +773,8 @@ const rustProxyAutoCertificate = (() => {
const list = hosts || current.options.hosts();
const best = suggest(list, current.options.certificates());
if (select.value !== best) { select.value = best; select.dispatchEvent(new Event("change", { bubbles: true })); }
current.hint.hidden = !best; current.hint.textContent = best ? t("autoCertHint").replace("{id}", best) : "";
const certificates = current.options.certificates(), splitCoverage = !best && list.length > 1 && list.every(host => certificates.some(cert => suggest([host], [cert])));
current.hint.hidden = !best && !splitCoverage; current.hint.textContent = best ? t("autoCertHint").replace("{id}", best) : (splitCoverage ? t("autoCertSniHint") : "");
};
return { refresh };
}
+17
View File
@@ -2020,3 +2020,20 @@ h2+.fine {
margin-top: 12px;
}
}
/* Redirect cards (0.36.5): host chips → target flow plus a quiet meta line, no nested boxes */
@media (min-width: 761px) { .redirect-card .vhost-card-head { align-items: center; } }
.redirect-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.4fr); gap: 12px; align-items: center; margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
.redirect-flow-from, .redirect-flow-to { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; align-items: center; }
.redirect-flow-to { flex-direction: column; align-items: flex-start; gap: 3px; }
.redirect-flow-arrow { color: var(--muted); font-size: 16px; line-height: 1; }
.redirect-host-chip { max-width: 100%; padding: 2px 6px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px/1.45 var(--mono); color: var(--text); overflow-wrap: anywhere; }
.redirect-target { font: 12px/1.45 var(--mono); font-weight: 600; color: var(--green); overflow-wrap: anywhere; min-width: 0; }
.redirect-target-more { font-size: 11px; color: var(--muted); }
.redirect-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 10px; font-size: 11px; color: var(--muted); }
.redirect-meta-item { display: inline-flex; gap: 6px; min-width: 0; }
.redirect-meta-item b { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.redirect-flow { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.redirect-flow-arrow { transform: rotate(90deg); justify-self: start; }
}
+1 -1
View File
@@ -82,7 +82,7 @@
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"), () => { window.rustProxyDrafts.prepare(); 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 actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("copyEntry"), () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(item); copy.id = ""; copy.name = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; load(copy); }), 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"));
+5 -1
View File
@@ -376,6 +376,10 @@
countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n");
setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy();
}
function copyProfile(profile) {
edit(profile); editing = null; $("acl-profile-id").readOnly = false; $("acl-profile-id").value = ""; $("acl-profile-name").value = ""; attachAclProfileId();
$("acl-profile-editing").textContent = t("aclProfileNew"); dirty = true; $("acl-profile-name").focus();
}
function friendlySummary(profile) {
const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || [];
if (mode === "allow_exceptions" || mode === "block_exceptions") {
@@ -422,7 +426,7 @@
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
box.append(attached);
}
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("copyEntry"), () => copyProfile(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;
+11 -4
View File
@@ -120,11 +120,18 @@
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
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"), () => { window.rustProxyDrafts.prepare(); 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);
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title); head.append(identity);
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("copyEntry"), () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(item); copy.id = ""; copy.name = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; load(copy); }), 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;
const flow = el("div", undefined, "redirect-flow"), from = el("div", undefined, "redirect-flow-from"), to = el("div", undefined, "redirect-flow-to");
for (const host of item.frontend?.hosts || []) from.append(el("span", host, "redirect-host-chip"));
if (!targets.length) to.append(el("span", "-", "redirect-target"));
for (const target of targets.slice(0, 3)) to.append(el("span", target, "redirect-target"));
if (targets.length > 3) to.append(el("span", `+${targets.length - 3}`, "redirect-target-more"));
flow.append(from, el("span", "→", "redirect-flow-arrow"), to); card.append(flow);
const meta = el("div", undefined, "redirect-meta");
for (const [k, v] of [["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ") || "-"], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("span", undefined, "redirect-meta-item"); node.append(el("span", t(k)), el("b", v)); meta.append(node); }
card.append(meta); return card;
}));
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
}
+1 -1
View File
@@ -214,7 +214,7 @@
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", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("remove", async () => {
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(stream); copy.id = ""; copy.name = ""; copy.listen = ""; load(copy); }), 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);
+10 -1
View File
@@ -327,7 +327,7 @@ function render() {
openButton.disabled = !browseHost || v.enabled === false;
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(v); copy.id = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; copy.allow_certificate_mismatch = false; copy.www_redirect_target = null; load(copy); }), 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(); }
@@ -590,6 +590,15 @@ form.addEventListener("submit", async event => {
wwwRedirect: item?.www_redirect === true,
wwwTarget: item?.www_redirect_target || null
});
if (value.certificate_id && !value.allow_certificate_mismatch) {
const certificate = [...(envelope?.config.tls?.certificates || []), ...stagedCertificates].find(item => item.id === value.certificate_id);
const inventory = certificateInventory.get(value.certificate_id)?.covered_hosts || [], coverage = inventory.length ? inventory : (certificate?.covered_hosts || []);
const uncovered = coverage.length ? (value.frontend?.hosts || []).filter(host => !coverage.some(pattern => window.rustProxyAutoCertificate.covers(pattern, host))) : [];
if (uncovered.length) {
if (!(await appConfirm(t("vhCertificateUncoveredPrompt").replace("{certificate}", value.certificate_id).replace("{hosts}", uncovered.join(", ")), { danger: true }))) { activateTab("ssl"); return; }
value.allow_certificate_mismatch = true;
}
}
if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { 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;
+74
View File
@@ -139,6 +139,73 @@ impl Snapshot {
}
None
}
/// Specificity of the vhost frontend matching `host` (an exact name beats any wildcard,
/// a longer wildcard suffix beats a shorter one).
pub fn frontend_rank(&self, host: &str, local: SocketAddr, tls: bool) -> Option<usize> {
let mut rank = None;
Self::find_host_pattern(host, |pattern| {
let found = self.frontends.get(pattern).is_some_and(|frontends| {
frontends
.iter()
.any(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
});
if found {
rank = Some(host_pattern_rank(pattern, host));
Some(())
} else {
None
}
});
rank
}
/// Specificity of the redirect host matching `host` and `path` (see `frontend_rank`).
pub fn redirect_rank(
&self,
host: &str,
path: &str,
local: SocketAddr,
tls: bool,
) -> Option<usize> {
let mut rank = None;
Self::find_host_pattern(host, |pattern| {
let found = self.redirects.get(pattern).is_some_and(|redirects| {
redirects.iter().any(|redirect| {
redirect
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
&& redirect.rules.iter().any(|rule| rule.matches(path, tls))
})
});
if found {
rank = Some(host_pattern_rank(pattern, host));
Some(())
} else {
None
}
});
rank
}
/// Specificity of the 404 host matching `host` (see `frontend_rank`).
pub fn not_found_rank(&self, host: &str, local: SocketAddr, tls: bool) -> Option<usize> {
let mut rank = None;
Self::find_host_pattern(host, |pattern| {
let found = self.not_found_hosts.get(pattern).is_some_and(|items| {
items.iter().any(|item| {
item.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
});
if found {
rank = Some(host_pattern_rank(pattern, host));
Some(())
} else {
None
}
});
rank
}
pub fn redirect_on(
&self,
host: &str,
@@ -204,6 +271,13 @@ impl Snapshot {
})
}
}
fn host_pattern_rank(pattern: &str, host: &str) -> usize {
if pattern == host {
usize::MAX
} else {
pattern.len()
}
}
fn select_route<'a, F>(
routes: &'a [Arc<CompiledRoute>],
path: &str,
+15 -2
View File
@@ -139,8 +139,21 @@ async fn forward_request(
}
let websocket_legacy_support =
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
// The most specific host name wins: an exact vhost such as autodiscover.example.com must not be
// shadowed by a wildcard redirect or 404 host (*.example.com) that happens to be evaluated first.
let frontend_rank = frontend.and_then(|_| snapshot.frontend_rank(&host, local.0, tls));
let redirect = snapshot
.redirect_on(&host, &path, local.0, tls)
.filter(|_| match (frontend_rank, snapshot.redirect_rank(&host, &path, local.0, tls)) {
(Some(vhost), Some(redirect)) => redirect >= vhost,
_ => true,
});
let local_host = snapshot
.not_found_host_on(&host, local.0, tls)
.filter(|_| match (frontend_rank, snapshot.not_found_rank(&host, local.0, tls)) {
(Some(vhost), Some(local_host)) => local_host >= vhost,
_ => true,
});
if request.version() == Version::HTTP_2
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
+1 -1
View File
@@ -367,7 +367,7 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
.with_context(|| format!("certificate {} does not cover {host}. Select a certificate that covers it, or enable \"Force an incompatible certificate\" in the vhost SSL tab (not available for redirects and 404 hosts)", cert.id))?;
} else {
tracing::warn!(certificate = %cert.id, hostname = %host,
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");