v0.34.4
This commit is contained in:
1 parent
ee174bc283
commit
52e7ce7b46
24 files changed
+672
-418
No files matched your search
@@ -2832,6 +2832,16 @@
|
||||
"default": true,
|
||||
"description": "Enable classic HTTP/1.1 WebSocket Upgrade forwarding for this generated Docker vhost."
|
||||
},
|
||||
"http2_enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Allow frontend HTTP/2 (h2) for this Docker publication when the selected TLS listener supports it. false disables H2 for this generated vhost only; it does not change the backend protocol. Missing fields in existing assignments default to true."
|
||||
},
|
||||
"http3_enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Allow frontend HTTP/3 (QUIC) and Alt-Svc advertisement when configured on the selected listener. false disables H3 for this generated vhost only. Missing fields in existing assignments default to true. HTTP/1.1 remains available."
|
||||
},
|
||||
"cache_enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
@@ -83,6 +83,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/management.js"),
|
||||
include_str!("web/drafts.js"),
|
||||
include_str!("web/integrations.js"),
|
||||
include_str!("web/acl-picker.js"),
|
||||
admin_vhosts_javascript(),
|
||||
include_str!("web/web-server-profiles.js"),
|
||||
include_str!("web/web-server.js"),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
window.rustProxyAclPicker = (() => {
|
||||
const LIMIT = 8;
|
||||
const profiles = () => envelope?.config?.acl_profiles || [];
|
||||
const chosenIds = root => [...(root?.querySelectorAll('.acl-picker-option input[type="checkbox"]:checked') || [])].map(input => input.value);
|
||||
const preview = profile => {
|
||||
const rules = profile.acl?.rules || [], country = profile.country_access || {};
|
||||
const lines = rules.slice(0, 3).map(rule => `${rule.action === "deny" ? t("deny") : t("allow")} ${rule.network}`);
|
||||
if (rules.length > 3) lines.push(`+${rules.length - 3} ${t("aclPickerMoreRules")}`);
|
||||
if (country.mode === "allow" || country.mode === "block") {
|
||||
const codes = (country.countries || []).slice(0, 5).join(", ");
|
||||
const extra = (country.countries || []).length - 5;
|
||||
lines.push(`${t(country.mode === "block" ? "aclPickerBlockedCountries" : "aclPickerAllowedCountries")}: ${codes}${extra > 0 ? ` +${extra}` : ""}`);
|
||||
}
|
||||
if (!lines.length) lines.push(t(profile.acl ? "aclPickerNoRules" : "aclPickerNoIpRules"));
|
||||
return lines.join(" · ");
|
||||
};
|
||||
function create(selected = [], className = "") {
|
||||
const root = el("div", undefined, `acl-picker ${className}`.trim());
|
||||
const details = el("details", undefined, "acl-picker-details"), summary = el("summary", undefined, "acl-picker-summary");
|
||||
const heading = el("span", t("aclPickerChoose"), "acl-picker-heading");
|
||||
const counter = el("span", "", "acl-picker-counter");
|
||||
summary.append(heading, counter); details.append(summary);
|
||||
const dropdown = el("div", undefined, "acl-picker-dropdown");
|
||||
const search = el("input", "", "acl-picker-search"); search.type = "search"; search.placeholder = t("aclPickerSearch"); search.setAttribute("aria-label", t("aclPickerSearch"));
|
||||
const options = el("div", undefined, "acl-picker-options");
|
||||
const empty = el("p", t("aclPickerNoMatches"), "fine acl-picker-empty");
|
||||
const maxHint = el("small", t("aclPickerLimit"), "fine acl-picker-limit");
|
||||
dropdown.append(search, options, empty, maxHint); details.append(dropdown);
|
||||
const selectedTags = el("div", undefined, "acl-picker-selected");
|
||||
root.append(details, selectedTags);
|
||||
const items = profiles();
|
||||
const checks = new Map(), selectedSet = new Set(selected);
|
||||
if (!items.length) {
|
||||
const noProfiles = el("p", t("aclPickerNoProfiles"), "fine");
|
||||
options.append(noProfiles); search.disabled = true;
|
||||
}
|
||||
for (const profile of items) {
|
||||
const label = el("label", undefined, "acl-picker-option");
|
||||
const check = el("input"); check.type = "checkbox"; check.value = profile.id; check.checked = selectedSet.has(profile.id);
|
||||
const content = el("span", undefined, "acl-picker-option-content");
|
||||
const headingRow = el("span", undefined, "acl-picker-option-heading");
|
||||
const name = el("strong", profile.name || profile.id), identity = el("code", profile.id, "acl-picker-id");
|
||||
const badge = el("span", t(!profile.acl ? "aclPickerNoIpRules" : profile.acl.default === "allow" ? "aclPickerDefaultAllow" : "aclPickerDefaultDeny"), `acl-picker-policy ${profile.acl?.default === "deny" ? "is-deny" : profile.acl?.default === "allow" ? "is-allow" : "is-none"}`);
|
||||
headingRow.append(name, identity, badge);
|
||||
content.append(headingRow, el("small", preview(profile), "acl-picker-rule-preview"));
|
||||
label.append(check, content); options.append(label); checks.set(profile.id, check);
|
||||
const allRules = (profile.acl?.rules || []).map(rule => `${rule.action} ${rule.network}`).join(" ");
|
||||
const countries = (profile.country_access?.countries || []).join(" ");
|
||||
const searchText = `${profile.name || ""} ${profile.id} ${allRules} ${countries}`.toLocaleLowerCase();
|
||||
label.dataset.search = searchText;
|
||||
}
|
||||
const syncSearch = () => {
|
||||
const query = search.value.trim().toLocaleLowerCase(); let visible = 0;
|
||||
for (const label of options.querySelectorAll(".acl-picker-option")) {
|
||||
label.hidden = !!query && !label.dataset.search.includes(query);
|
||||
if (!label.hidden) visible++;
|
||||
}
|
||||
empty.hidden = visible > 0 || items.length === 0;
|
||||
};
|
||||
const sync = () => {
|
||||
const ids = chosenIds(root), full = ids.length >= LIMIT;
|
||||
counter.textContent = `${ids.length}/${LIMIT}`;
|
||||
heading.textContent = ids.length ? t("aclPickerSelected").replace("{count}", ids.length) : t("aclPickerChoose");
|
||||
selectedTags.replaceChildren();
|
||||
for (const id of ids) {
|
||||
const profile = items.find(item => item.id === id);
|
||||
const tag = el("span", undefined, "acl-picker-tag");
|
||||
const label = el("span", profile?.name || id); label.title = preview(profile || {});
|
||||
const remove = el("button", "×", "acl-picker-remove"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${profile?.name || id}`);
|
||||
remove.addEventListener("click", () => { const box = checks.get(id); box.checked = false; box.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
tag.append(label, remove); selectedTags.append(tag);
|
||||
}
|
||||
if (!ids.length) selectedTags.append(el("span", t("aclPickerNone"), "fine acl-picker-no-selection"));
|
||||
for (const check of checks.values()) check.disabled = full && !check.checked;
|
||||
maxHint.hidden = !full;
|
||||
};
|
||||
options.addEventListener("change", event => { if (event.target.matches('input[type="checkbox"]')) sync(); });
|
||||
search.addEventListener("input", syncSearch);
|
||||
search.addEventListener("keydown", event => { if (event.key === "Escape") { search.value = ""; syncSearch(); details.open = false; summary.focus(); } });
|
||||
syncSearch(); sync(); return root;
|
||||
}
|
||||
return { create, read: chosenIds, count: root => chosenIds(root).length };
|
||||
})();
|
||||
+49
-24
@@ -52,23 +52,25 @@
|
||||
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 selectedProfileIds(value) { return [...new Set([value?.acl_profile_id, ...(value?.acl_profile_ids || [])].filter(Boolean))]; }
|
||||
function profileChoices(initial, className) {
|
||||
const box = el("div", undefined, `docker-acl-choices ${className}`), chosen = new Set(initial);
|
||||
for (const profile of envelope?.config?.acl_profiles || []) {
|
||||
const label = el("label", undefined, "check docker-acl-choice"), control = input("", "checkbox");
|
||||
control.value = profile.id; control.checked = chosen.has(profile.id);
|
||||
label.append(control, el("span", profile.name || profile.id)); box.append(label);
|
||||
}
|
||||
if (!box.children.length) box.append(el("span", t("dockerDefaultAclNone"), "fine"));
|
||||
return box;
|
||||
return window.rustProxyAclPicker.create(initial, className);
|
||||
}
|
||||
function readProfileChoices(scope, selector) {
|
||||
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
|
||||
return window.rustProxyAclPicker.read(scope.querySelector(selector));
|
||||
}
|
||||
function profileWarning(choices, inheritedCount = 0) {
|
||||
const warning = el("p", t("multiAclWarning"), "fine acl-stack-warning");
|
||||
const sync = () => { warning.hidden = (readProfileChoices(choices, ":scope").length || inheritedCount) < 2; };
|
||||
const sync = () => {
|
||||
const selected = window.rustProxyAclPicker.count(choices);
|
||||
warning.hidden = (selected || inheritedCount) < 2;
|
||||
};
|
||||
choices.addEventListener("change", sync); sync(); return warning;
|
||||
}
|
||||
function profileField(key, picker, help) {
|
||||
const wrapper = el("div", undefined, "docker-field docker-profile-field");
|
||||
wrapper.append(el("span", t(key)), picker);
|
||||
if (help) wrapper.append(el("small", help, "fine"));
|
||||
return wrapper;
|
||||
}
|
||||
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"));
|
||||
@@ -165,7 +167,7 @@
|
||||
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 = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
|
||||
const defaultAclField = labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
|
||||
const defaultAclField = profileField("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
|
||||
defaultAclField.append(profileWarning(defaultAcl));
|
||||
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")), defaultAclField);
|
||||
@@ -258,13 +260,13 @@
|
||||
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 = profileChoices(selectedProfileIds(assignment), "docker-acl-profile");
|
||||
const inheritedProfiles = selectedProfileIds(agent);
|
||||
const profileField = labeled("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
|
||||
profileField.append(profileWarning(profile, inheritedProfiles.length));
|
||||
const profileControl = profileField("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
|
||||
profileControl.append(profileWarning(profile, inheritedProfiles.length));
|
||||
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(profileField, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
|
||||
grid.append(profileControl, 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);
|
||||
@@ -277,7 +279,7 @@
|
||||
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, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
|
||||
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, http2_enabled: row.querySelector(".docker-http2-enabled").checked, http3_enabled: row.querySelector(".docker-http3-enabled").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
|
||||
// Docker publications intentionally expose one compression switch only. Codec selection stays global.
|
||||
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
|
||||
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
|
||||
@@ -335,13 +337,40 @@
|
||||
const certMismatch = input("", "checkbox", "docker-cert-mismatch"); certMismatch.checked = assignment?.allow_certificate_mismatch === true;
|
||||
const mismatchField = checkboxField("dockerCertMismatchForce", certMismatch);
|
||||
const mismatchHelp = el("p", t("dockerCertMismatchWarning"), "fine acl-stack-warning");
|
||||
const syncMismatch = () => { certMismatch.disabled = !agent.certificate_id; if (!agent.certificate_id) certMismatch.checked = false; mismatchHelp.hidden = !certMismatch.checked; };
|
||||
certMismatch.addEventListener("change", syncMismatch); syncMismatch(); mismatchField.append(mismatchHelp);
|
||||
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches, mismatchField);
|
||||
editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
|
||||
const mismatchUnavailable = el("p", t("dockerCertMismatchUnavailable"), "fine");
|
||||
const riskPill = el("span", t("dockerCertMismatchRisk"), "acl-picker-policy is-deny docker-cert-risk-pill");
|
||||
const syncMismatch = () => {
|
||||
certMismatch.disabled = !agent.certificate_id;
|
||||
if (!agent.certificate_id) certMismatch.checked = false;
|
||||
mismatchHelp.hidden = !certMismatch.checked;
|
||||
mismatchUnavailable.hidden = !!agent.certificate_id;
|
||||
riskPill.hidden = !certMismatch.checked;
|
||||
};
|
||||
certMismatch.addEventListener("change", syncMismatch);
|
||||
mismatchField.append(mismatchHelp, mismatchUnavailable);
|
||||
const certificateBar = el("div", undefined, "docker-service-certificate-bar");
|
||||
const tlsControl = el("div", undefined, "docker-service-certificate-control");
|
||||
const tlsHeading = el("div", undefined, "docker-service-certificate-title");
|
||||
tlsHeading.append(el("strong", t("dockerCertSection")), riskPill);
|
||||
tlsControl.append(tlsHeading, mismatchField); certificateBar.append(tlsControl);
|
||||
syncMismatch();
|
||||
|
||||
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
|
||||
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment?.http3_enabled !== false;
|
||||
const protocolBox = el("section", undefined, "docker-protocol-panel");
|
||||
const protocolHeading = el("div", undefined, "docker-protocol-heading");
|
||||
protocolHeading.append(el("strong", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
|
||||
const protocolOptions = el("div", undefined, "docker-protocol-options");
|
||||
const h1 = el("span", undefined, "docker-protocol-fixed");
|
||||
h1.append(el("strong", "HTTP/1.1"), el("small", t("dockerHttp1Always"), "fine"));
|
||||
protocolOptions.append(h1, checkboxField("dockerHttp2Enabled", h2Enabled), checkboxField("dockerHttp3Enabled", h3Enabled));
|
||||
protocolBox.append(protocolHeading, protocolOptions);
|
||||
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
|
||||
editor.append(certificateBar, route, protocolBox, 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();
|
||||
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel} · H1${h2Enabled.checked ? "/H2" : ""}${h3Enabled.checked ? "/H3" : ""}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
|
||||
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
|
||||
for (const control of [h2Enabled, h3Enabled]) 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 () => {
|
||||
@@ -360,8 +389,6 @@
|
||||
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; }
|
||||
// No observation is the normal state while the first background poll is in flight.
|
||||
// A configured TLS fingerprint alone is not proof that an agent was contacted.
|
||||
if (!state) box.append(el("p", t("dockerNoObservation"), "fine"));
|
||||
else if (!state.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state.error || t("dockerUnknownPollError"), "fine")); box.append(error); }
|
||||
if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; }
|
||||
@@ -455,8 +482,6 @@
|
||||
});
|
||||
rustProxyRefresh.register("docker", {
|
||||
interval: () => 5,
|
||||
// Even with local Live view off, observe the first startup result.
|
||||
// Once every enabled agent has a first result, stop unless Live is enabled.
|
||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||
run: async context => refresh(false, context)
|
||||
});
|
||||
|
||||
+171
-65
@@ -12315,7 +12315,8 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
font: 400 10px var(--mono);
|
||||
}
|
||||
|
||||
.docker-agent-summary>span,
|
||||
/* Docker TLS SHA-256 values must wrap even in narrow mobile details cards. */
|
||||
.docker-agent-summary > span,
|
||||
.docker-agent-security-hint,
|
||||
.docker-fingerprint {
|
||||
min-width: 0;
|
||||
@@ -12324,76 +12325,181 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vh-acl-profile-picker,
|
||||
.docker-acl-choices {
|
||||
/* Reusable searchable ACL multi-select: identical in the ordinary vhost editor,
|
||||
Docker agent defaults and Docker per-service overrides. All internal corners
|
||||
follow the user-selected control radius; fallbacks preserve Original style. */
|
||||
.vh-acl-profile-picker, .acl-picker { min-width: 0; width: 100%; }
|
||||
.vh-acl-profile-picker > strong { display: block; margin-bottom: 8px; }
|
||||
.acl-picker-details {
|
||||
border: 1px solid var(--field-line);
|
||||
background: var(--field-bg);
|
||||
border-radius: var(--rp-radius-control, 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vh-acl-profile-picker>strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vh-acl-profile-choices,
|
||||
.docker-acl-choices {
|
||||
.acl-picker-details[open] { border-color: var(--border-hover); }
|
||||
.acl-picker-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 5px 14px;
|
||||
}
|
||||
|
||||
.vh-acl-profile-choice,
|
||||
.docker-acl-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 21px;
|
||||
}
|
||||
|
||||
.vh-acl-profile-choice input,
|
||||
.docker-acl-choice input {
|
||||
flex: 0 0 auto;
|
||||
width: auto !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
.acl-picker-heading { flex: 1 1 auto; font-weight: 600; min-width: 0; }
|
||||
.acl-picker-counter {
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--pill-line);
|
||||
border-radius: var(--rp-radius-control, 12px);
|
||||
padding: 2px 7px;
|
||||
font: 11px var(--mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.acl-stack-warning {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.docker-acl-grid .docker-acl-choices,
|
||||
.docker-agent-grid .docker-acl-choices {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.docker-cert-mismatch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.docker-agent-summary>span,
|
||||
.docker-fingerprint {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.docker-agent-grid .docker-agent-fingerprint {
|
||||
min-width: 0;
|
||||
.acl-picker-dropdown { padding: 10px; border-top: 1px solid var(--line); }
|
||||
.acl-picker-search, .docker-agent-grid .acl-picker-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.acl-picker-options { display: grid; max-height: 270px; overflow-y: auto; gap: 5px; }
|
||||
.acl-picker-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--rp-radius-control, 6px);
|
||||
background: var(--panel);
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.acl-picker-option:hover, .acl-picker-option:has(input:focus-visible) { border-color: var(--border-hover); }
|
||||
.acl-picker-option:has(input:checked) { border-color: var(--pill-live-line); background: var(--nav-selected-bg); }
|
||||
.acl-picker-option[hidden], .acl-picker-empty[hidden], .acl-picker-limit[hidden] { display: none !important; }
|
||||
.acl-picker-option input[type=checkbox] {
|
||||
flex: 0 0 auto;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
min-height: 16px !important;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.acl-picker-option input:disabled { opacity: .35; }
|
||||
.acl-picker-option:has(input:disabled) { cursor: not-allowed; opacity: .6; }
|
||||
.acl-picker-option-content { display: grid; flex: 1; min-width: 0; gap: 5px; }
|
||||
.acl-picker-option-heading { display: flex; flex-wrap: wrap; gap: 5px 9px; align-items: center; }
|
||||
.acl-picker-option-heading strong { font-size: 12px; color: var(--text); overflow-wrap: anywhere; }
|
||||
.acl-picker-id { font: 10px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
|
||||
.acl-picker-policy {
|
||||
font: 10px var(--mono);
|
||||
line-height: 1.4;
|
||||
border-radius: var(--rp-radius-control, 12px);
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.acl-picker-policy.is-deny { border-color: var(--danger-line); color: var(--red); }
|
||||
.acl-picker-policy.is-allow { border-color: var(--pill-live-line); color: var(--green); }
|
||||
.acl-picker-rule-preview {
|
||||
display: block;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.acl-picker-selected { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 0; min-width: 0; }
|
||||
.acl-picker-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 7px;
|
||||
padding: 2px 4px 2px 9px;
|
||||
border-radius: var(--rp-radius-control, 15px);
|
||||
border: 1px solid var(--pill-live-line);
|
||||
background: var(--nav-selected-bg);
|
||||
font-size: 11px;
|
||||
}
|
||||
.acl-picker-tag > span { overflow-wrap: anywhere; min-width: 0; }
|
||||
.acl-picker-tag .acl-picker-remove {
|
||||
border: none;
|
||||
border-radius: var(--rp-radius-control, 50%);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.acl-picker-tag .acl-picker-remove:hover { background: var(--panel2); color: var(--text); }
|
||||
.acl-picker-no-selection { font-size: 11px; }
|
||||
.acl-picker-limit { display: block; padding-top: 7px; }
|
||||
.acl-stack-warning { margin: 8px 0 0; font-size: 11px; line-height: 1.5; color: var(--notice-text); }
|
||||
.docker-profile-field { min-width: 0; }
|
||||
.docker-acl-grid .docker-profile-field { grid-column: span 2; }
|
||||
.docker-agent-grid .docker-profile-field { grid-column: span 2; }
|
||||
|
||||
/* Certificate acceptance is an independent, upper-right security control. */
|
||||
.docker-service-certificate-bar { display: flex; justify-content: flex-end; margin: 0 0 12px; }
|
||||
.docker-service-certificate-control {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
width: min(100%, 510px);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--notice-line);
|
||||
border-radius: 8px;
|
||||
background: var(--notice-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
.docker-service-certificate-title { display: flex; align-items: center; gap: 9px; }
|
||||
.docker-service-certificate-title strong { font-size: 11px; }
|
||||
.docker-cert-risk-pill[hidden] { display: none; }
|
||||
.docker-service-certificate-control .docker-field > span:first-child { color: var(--text); }
|
||||
.docker-service-certificate-control .docker-checkbox-field { gap: 3px; }
|
||||
.docker-service-certificate-control .docker-checkbox-field .check { font-size: 11px; }
|
||||
.docker-service-certificate-control .acl-stack-warning { color: var(--notice-text); }
|
||||
.docker-service-certificate-control .fine[hidden] { display: none; }
|
||||
.docker-cert-mismatch { flex: 0 0 auto; }
|
||||
|
||||
.docker-protocol-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 11px 12px;
|
||||
margin: 10px 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.docker-protocol-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 5px 12px; margin-bottom: 9px; }
|
||||
.docker-protocol-heading strong { font-size: 12px; }
|
||||
.docker-protocol-heading small { font-size: 11px; }
|
||||
.docker-protocol-options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.docker-protocol-options > * {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-width: 130px;
|
||||
flex: 1 1 145px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel2);
|
||||
padding: 8px 12px;
|
||||
min-height: 49px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.docker-protocol-fixed { gap: 4px; }
|
||||
.docker-protocol-fixed strong { font-size: 11px; }
|
||||
.docker-protocol-fixed small { font-size: 10px; }
|
||||
.docker-protocol-options .docker-checkbox-field > span:first-child { color: var(--text); font-size: 11px; }
|
||||
.docker-protocol-options .docker-checkbox-field .vh-check { font-size: 11px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.docker-acl-grid .docker-profile-field, .docker-agent-grid .docker-profile-field { grid-column: auto; }
|
||||
.docker-service-certificate-control { width: 100%; }
|
||||
.docker-agent-summary > span, .docker-fingerprint { width: 100%; }
|
||||
.acl-picker-option-heading { align-items: flex-start; }
|
||||
}
|
||||
|
||||
/* Keep the editable SHA-256 input from expanding narrow settings grids. */
|
||||
.docker-agent-grid .docker-agent-fingerprint { min-width: 0; width: 100%; box-sizing: border-box; }
|
||||
@@ -83,8 +83,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
|
||||
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
|
||||
g.append(wwwPanel);
|
||||
// Persist only the selected canonical host; choosing the www variant does
|
||||
// not require adding an extra hostname to the vhost manually.
|
||||
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
const syncCanonical = () => {
|
||||
const allHosts = lines(read("hosts").value);
|
||||
@@ -129,8 +127,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
wwwNoHosts.hidden = bases.length > 0;
|
||||
wwwRedirect.disabled = !bases.length;
|
||||
if (!bases.length) wwwRedirect.checked = false;
|
||||
// Never hide the destination choices: the unchecked state is a preview
|
||||
// that the operator can configure before enabling the rule.
|
||||
wwwDetails.hidden = false;
|
||||
wwwInactive.hidden = wwwRedirect.checked;
|
||||
wwwRedirect.setAttribute("aria-expanded", "true");
|
||||
@@ -241,14 +237,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
|
||||
const profileBox = el("div", undefined, "vh-wide vh-acl-profile-picker");
|
||||
profileBox.append(text("strong", "vhAclProfile"));
|
||||
const choices = el("div", undefined, "vh-acl-profile-choices");
|
||||
for (const profile of envelope?.config.acl_profiles || []) {
|
||||
const label = el("label", undefined, "check vh-acl-profile-choice");
|
||||
const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.name = "acl-profile-choices";
|
||||
checkbox.value = profile.id; checkbox.checked = selectedProfiles.has(profile.id);
|
||||
label.append(checkbox, el("span", profile.name || profile.id)); choices.append(label);
|
||||
}
|
||||
if (!choices.children.length) choices.append(text("span", "vhAclProfileNone", "fine"));
|
||||
const choices = window.rustProxyAclPicker.create([...selectedProfiles], "vh-acl-profile-choices");
|
||||
profileBox.append(choices);
|
||||
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
|
||||
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
|
||||
@@ -406,7 +395,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
locationsReader();
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
|
||||
@@ -464,7 +453,7 @@ function collect() {
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), allow_certificate_mismatch: yes("allow-certificate-mismatch"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: null,
|
||||
acl_profile_ids: [...form.querySelectorAll('input[name="acl-profile-choices"]:checked')].map(input => input.value),
|
||||
acl_profile_ids: window.rustProxyAclPicker.read(form.querySelector(".vh-acl-profile-choices")),
|
||||
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(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
|
||||
Reference in new issue
Block a user