This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:47:13 +02:00
1 parent ee174bc283
commit 52e7ce7b46
24 files changed
+672 -418

No files matched your search

+14 -36
View File
@@ -59,17 +59,13 @@ impl Acl {
ensure!(rule.network.len() <= 254, "ACL network is too long");
let target = if let Ok(network) = parse_network(&rule.network) {
if let IpNet::V6(net) = network {
ensure!(
net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
);
ensure!(net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules");
}
RuleMatch::Ip(network)
} else {
ensure!(
rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
ensure!(rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny");
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
};
rules.push((target, rule.action));
@@ -107,16 +103,9 @@ pub fn allows_profiles_with_country(
let mut explicit_allow = false;
let mut defaults_allow = true;
for profile in profiles {
let ip_action = profile
.acl
.as_ref()
.and_then(|acl| acl.explicit_action(peer));
let ip_action = profile.acl.as_ref().and_then(|acl| acl.explicit_action(peer));
let country_listed = country.is_some_and(|code| {
profile
.countries
.countries
.iter()
.any(|value| value.eq_ignore_ascii_case(code))
profile.countries.countries.iter().any(|value| value.eq_ignore_ascii_case(code))
});
if ip_action == Some(Action::Deny)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed)
@@ -126,34 +115,22 @@ pub fn allows_profiles_with_country(
explicit_allow |= ip_action == Some(Action::Allow)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed);
defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow
&& profile
.acl
.as_ref()
.is_none_or(|acl| acl.default == Action::Allow);
&& profile.acl.as_ref().is_none_or(|acl| acl.default == Action::Allow);
}
explicit_allow || defaults_allow
}
/// The legacy singular reference remains supported. Modern clients write the
/// array only; if both exist we require both policies to avoid silent bypass.
pub fn profile_ids<'a>(
legacy: Option<&'a str>,
ids: &'a [String],
) -> impl Iterator<Item = &'a str> {
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str))
}
pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> {
ensure!(
ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8,
"at most 8 ACL profiles may be assigned"
);
ensure!(ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, "at most 8 ACL profiles may be assigned");
let mut seen = std::collections::HashSet::new();
for id in profile_ids(legacy, ids) {
ensure!(
crate::config::valid_id(id) && seen.insert(id),
"invalid or duplicate ACL profile id"
);
ensure!(crate::config::valid_id(id) && seen.insert(id), "invalid or duplicate ACL profile id");
}
Ok(())
}
@@ -221,7 +198,9 @@ pub fn allows_with_country(
&& (ip_action == Some(Action::Allow)
|| acl.is_none_or(|value| value.default == Action::Allow))
}
crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
crate::rate_limit::CountryMode::Allow => {
ip_action == Some(Action::Allow) || country_listed
}
}
}
/// Validate the already-decoded routing representation used internally after
@@ -266,8 +245,7 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
);
}
ensure!(
!raw.split('/')
.any(|segment| segment == "." || segment == ".."),
!raw.split('/').any(|segment| segment == "." || segment == ".."),
"ambiguous path segments"
);
Ok(std::borrow::Cow::Borrowed(raw))
+10
View File
@@ -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,
+1
View File
@@ -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"),
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+3 -14
View File
@@ -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()) },
+32 -109
View File
@@ -48,58 +48,29 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(
config.php_fpm_profiles.len() <= 128,
"too many PHP-FPM profiles (max 128)"
);
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles {
profile
.validate()
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(
php_profiles
.insert(profile.id.clone(), profile.php.clone())
.is_none(),
"duplicate PHP-FPM profile id"
);
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
"duplicate PHP-FPM profile id");
}
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
// workers. Different directive sets on one socket can leak to the next vhost.
// Report unsafe legacy configurations but keep them loadable for migration.
let mut php_endpoint_policies: HashMap<
String,
(
String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
),
> = HashMap::new();
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
service.validate().with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
let mut effective = service.clone();
if let Some(id) = &service.php_profile_id {
effective.php = php_profiles
.get(id)
.with_context(|| {
format!(
"Web Service {} references unknown PHP-FPM profile {}",
service.id, id
)
})?
.clone();
effective
.validate()
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
effective.php = php_profiles.get(id).with_context(|| {
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
if effective.php.enabled {
let php = &effective.php;
@@ -111,25 +82,13 @@ pub fn compile_with_active(
);
}
} else {
php_endpoint_policies.insert(
php.endpoint.clone(),
(
effective.id.clone(),
php.php_values.clone(),
php.php_admin_values.clone(),
),
);
php_endpoint_policies.insert(php.endpoint.clone(), (
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
));
}
}
let compiled = Arc::new(
effective
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
@@ -308,27 +267,19 @@ pub fn compile_with_active(
// Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents {
for profile_id in
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
{
for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}",
agent.id,
profile_id
agent.id, profile_id
);
}
for assignment in &agent.assignments {
for profile_id in crate::acl::profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
) {
for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}",
assignment.service_key,
agent.id,
profile_id
assignment.service_key, agent.id, profile_id
);
}
}
@@ -441,9 +392,7 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(
redirect: Option<&crate::vhost::LocationRedirect>,
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
@@ -459,50 +408,24 @@ fn validate_location_redirect(
);
// Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane.
let authority_end = redirect
.target
.find("://")
.and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
.map(|(offset, _)| start + 3 + offset)
.or(Some(redirect.target.len()))
})
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(
!redirect.target[..authority_end].contains('$'),
"location redirect captures are not allowed in the URL authority"
);
let authority_end = redirect.target.find("://").and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed =
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"location redirect target must use http/https and include a host"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"location redirect target cannot contain credentials"
);
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
"invalid location redirect rewrite regex"
);
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
let mut builder = regex::RegexBuilder::new(pattern);
builder
.size_limit(2 * 1024 * 1024)
.dfa_size_limit(2 * 1024 * 1024);
Some(
builder
.build()
.context("invalid location redirect rewrite regex")?,
)
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
+14 -21
View File
@@ -1,6 +1,9 @@
impl Snapshot {
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
fn find_host_pattern<T>(
host: &str,
mut lookup: impl FnMut(&str) -> Option<T>,
) -> Option<T> {
if let Some(value) = lookup(host) {
return Some(value);
}
@@ -133,7 +136,11 @@ impl Snapshot {
});
}
}
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
for (candidate, matched_by) in [
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
let Some(candidate) = candidate else {
continue;
};
@@ -205,10 +212,7 @@ impl Snapshot {
/// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
Self::find_host_pattern(host, |pattern| {
let fallback = self
.fallbacks
.get(pattern)
.and_then(|routes| routes.first())?;
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
self.routes
.get(pattern)
.and_then(|routes| select_route(routes, path, |_| true))
@@ -265,25 +269,17 @@ where
prefix
}
impl CompiledRoute {
pub fn redirect_location(
&self,
path: &str,
uri: &http::Uri,
) -> Result<Option<http::HeaderValue>> {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else {
return Ok(None);
};
let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(
regex.is_match(path),
"location redirect rewrite regex did not match"
);
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
regex.replace(path, spec.target.as_str()).into_owned()
} else {
spec.target.clone()
};
let mut target =
url::Url::parse(&target).context("invalid compiled location redirect target")?;
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
@@ -293,10 +289,7 @@ impl CompiledRoute {
target.set_query(Some(&merged));
}
}
Ok(Some(
http::HeaderValue::from_str(target.as_str())
.context("invalid redirect Location header")?,
))
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
}
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
+14 -9
View File
@@ -35,6 +35,8 @@ const MAX_SERVICES: usize = 512;
const MAX_ADDRESSES: usize = 64;
const MAX_BINDINGS: usize = 32;
fn yes() -> bool { true }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -113,6 +115,12 @@ pub struct Assignment {
/// the per-app redirect switch existed. New UI assignments persist true.
pub force_ssl: Option<bool>,
pub websockets: bool,
/// Frontend HTTP/2 opt-out. Old assignments inherit the enabled default.
#[serde(default = "yes")]
pub http2_enabled: bool,
/// Frontend HTTP/3 opt-out. Requires a configured QUIC listener to be offered.
#[serde(default = "yes")]
pub http3_enabled: bool,
/// Single Docker-vhost dynamic-compression switch. None inherits the global policy.
pub compression_enabled: Option<bool>,
/// Legacy pre-release 0.27.0 fields kept for saved-config compatibility. Docker
@@ -145,6 +153,8 @@ impl Default for Assignment {
preserve_host: true,
force_ssl: None,
websockets: true,
http2_enabled: true,
http3_enabled: true,
compression_enabled: None,
compression_gzip: None,
compression_br: None,
@@ -292,11 +302,8 @@ impl Settings {
if let Some(ip) = agent.default_target_ip {
usable_target_ip(ip).context("invalid Docker agent default target IP")?;
}
crate::acl::validate_profile_ids(
agent.acl_profile_id.as_deref(),
&agent.acl_profile_ids,
)
.context("invalid Docker agent ACL profiles")?;
crate::acl::validate_profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
.context("invalid Docker agent ACL profiles")?;
ensure!(
agent.listener_ids.len() <= 16,
"Docker agent allows at most 16 listener references"
@@ -333,10 +340,8 @@ impl Settings {
usable_target_ip(ip).context("invalid Docker assignment target IP")?;
}
crate::acl::validate_profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
)
.context("invalid Docker assignment ACL profiles")?;
assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids,
).context("invalid Docker assignment ACL profiles")?;
ensure!(
!assignment.allow_certificate_mismatch || agent.certificate_id.is_some(),
"Docker certificate mismatch override requires a selected agent certificate"
+7 -8
View File
@@ -67,11 +67,7 @@ pub fn effective_config(
// last successful inventory.
let inventory = agent
.enabled
.then(|| {
observed
.get(&agent.id)
.and_then(|item| item.inventory.as_ref())
})
.then(|| observed.get(&agent.id).and_then(|item| item.inventory.as_ref()))
.flatten();
for assignment in agent
.assignments
@@ -85,7 +81,9 @@ pub fn effective_config(
.services
.iter()
.find(|service| service.key == assignment.service_key)
.and_then(|service| route_target(agent, assignment, inventory, service))
.and_then(|service| {
route_target(agent, assignment, inventory, service)
})
})
.map(|(ip, port)| -> Result<_> {
usable_target_ip(ip)?;
@@ -115,8 +113,7 @@ pub fn effective_config(
.unwrap_or_default();
// A per-publication selection overrides the agent default; an empty
// selection inherits it, preserving legacy configuration behavior.
let override_profiles =
assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty();
let override_profiles = assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty();
let (acl_profile_id, acl_profile_ids) = if override_profiles {
(&assignment.acl_profile_id, &assignment.acl_profile_ids)
} else {
@@ -128,6 +125,8 @@ pub fn effective_config(
"certificate_id": agent.certificate_id.clone(),
"force_ssl": assignment.force_ssl.unwrap_or(false),
"websockets": assignment.websockets,
"http2_enabled": assignment.http2_enabled,
"http3_enabled": assignment.http3_enabled,
"backends": backends,
// Docker publications intentionally use one compression switch. The enabled
// codec set is inherited from global Response settings.
+63 -20
View File
@@ -44,6 +44,61 @@ mod tests {
}
}
#[test]
fn docker_protocol_flags_default_on_but_can_be_disabled_and_round_trip() {
let legacy: Assignment = serde_json::from_value(serde_json::json!({
"service_key": "container:app:8080/tcp", "hostname": "app"
})).unwrap();
assert!(legacy.http2_enabled && legacy.http3_enabled);
let mut configured = legacy;
configured.http2_enabled = false;
configured.http3_enabled = false;
let serialized = serde_json::to_value(&configured).unwrap();
assert_eq!(serialized["http2_enabled"], false);
assert_eq!(serialized["http3_enabled"], false);
let restored: Assignment = serde_json::from_value(serialized).unwrap();
assert!(!restored.http2_enabled && !restored.http3_enabled);
}
#[test]
fn docker_frontend_protocols_are_per_publication_and_do_not_change_acl() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
desired.vhosts.clear();
desired.docker_agents.agents.push(Agent {
id: "node1".into(),
name: "Node 1".into(),
endpoint: "http://10.0.0.2:9876".into(),
token: "012345678901234567890123".into(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
acl_profile_id: Some("office".into()),
assignments: vec![
Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
http2_enabled: false,
http3_enabled: false,
..Default::default()
},
Assignment {
service_key: "container:second:8080/tcp".into(),
hostname: "second".into(),
..Default::default()
},
],
..Default::default()
});
let result = effective_config(desired, &observation(service(), vec![])).unwrap();
assert_eq!(result.vhosts.len(), 2);
let first = &result.vhosts[0];
assert!(!first.http2_enabled && !first.http3_enabled);
assert!(first.websockets);
assert_eq!(first.acl_profile_id.as_deref(), Some("office"));
let other = &result.vhosts[1];
assert!(other.http2_enabled && other.http3_enabled);
assert_eq!(other.acl_profile_id.as_deref(), Some("office"));
}
#[test]
fn generated_vhost_supports_base_domain_custom_domain_aliases_and_selected_ip() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
@@ -198,6 +253,8 @@ mod tests {
.unwrap();
assert_eq!(assignment.force_ssl, None);
assert!(assignment.websockets);
assert!(assignment.http2_enabled);
assert!(assignment.http3_enabled);
assert!(!assignment.cache_enabled);
assert_eq!(assignment.compression_enabled, None);
assert_eq!(assignment.compression_zstd, None);
@@ -228,14 +285,7 @@ mod tests {
..Default::default()
});
let mut observed = observation(service(), vec![]);
observed
.get_mut("node1")
.unwrap()
.inventory
.as_mut()
.unwrap()
.services
.clear();
observed.get_mut("node1").unwrap().inventory.as_mut().unwrap().services.clear();
let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1);
assert!(effective.vhosts[0].backends.is_empty());
@@ -521,21 +571,14 @@ mod tests {
});
let observed = observation(service(), vec![]);
let inherited = effective_config(desired.clone(), &observed).unwrap();
assert_eq!(
inherited.vhosts[0].acl_profile_id.as_deref(),
Some("legacy")
);
assert_eq!(inherited.vhosts[0].acl_profile_id.as_deref(), Some("legacy"));
assert_eq!(inherited.vhosts[0].acl_profile_ids, vec!["work", "vpn"]);
assert!(inherited.vhosts[0].allow_certificate_mismatch);
desired.docker_agents.agents[0].assignments[0].acl_profile_ids =
vec!["guest".into(), "branch".into()];
desired.docker_agents.agents[0].assignments[0].acl_profile_ids = vec!["guest".into(), "branch".into()];
desired.docker_agents.agents[0].assignments[0].allow_certificate_mismatch = false;
let overridden = effective_config(desired, &observed).unwrap();
assert_eq!(overridden.vhosts[0].acl_profile_id, None);
assert_eq!(
overridden.vhosts[0].acl_profile_ids,
vec!["guest", "branch"]
);
assert_eq!(overridden.vhosts[0].acl_profile_ids, vec!["guest", "branch"]);
assert!(!overridden.vhosts[0].allow_certificate_mismatch);
}
@@ -543,10 +586,10 @@ mod tests {
fn older_docker_assignment_json_has_safe_defaults() {
let assignment: Assignment = serde_json::from_value(serde_json::json!({
"service_key": "container:app:8080/tcp", "hostname": "app", "acl_profile_id": "legacy"
}))
.unwrap();
})).unwrap();
assert_eq!(assignment.acl_profile_id.as_deref(), Some("legacy"));
assert!(assignment.acl_profile_ids.is_empty());
assert!(!assignment.allow_certificate_mismatch);
}
}
+15 -31
View File
@@ -88,7 +88,10 @@ async fn forward_request(
.get::<Arc<crate::config::Snapshot>>()
.cloned()
.unwrap_or_else(|| state.config.load_full());
let internal_dispatch = request.extensions().get::<InternalFallbackRoute>().cloned();
let internal_dispatch = request
.extensions()
.get::<InternalFallbackRoute>()
.cloned();
// Retain the original URI's shared backing bytes independently of `request`.
// A normalized, already-safe path can then be borrowed for the entire
// forwarding operation, even after the request URI/headers are rewritten.
@@ -178,9 +181,9 @@ async fn forward_request(
return response;
}
if !tls {
if let Some(response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
{
if let Some(response) = frontend.and_then(|frontend| {
force_ssl_redirect(frontend, &host, request.uri())
}) {
return response;
}
}
@@ -193,9 +196,7 @@ async fn forward_request(
&original_host,
request.uri(),
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
request
.extensions()
.get::<Arc<crate::forwarding::PeerFormatting>>()
request.extensions().get::<Arc<crate::forwarding::PeerFormatting>>()
.map(Arc::as_ref),
host_already_validated,
) {
@@ -236,10 +237,7 @@ async fn forward_request(
let mut country: Option<String> = None;
if let Some(frontend) = frontend {
if capture_country
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.profile_policies.iter().any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip()
{
country = cloudflare_country
@@ -273,10 +271,7 @@ async fn forward_request(
&frontend.id,
context.client_ip,
country.as_deref(),
request
.headers()
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()),
&frontend.rate_limit,
) {
let mut response = error(
@@ -300,17 +295,7 @@ async fn forward_request(
);
}
if cache_http_invalidation_method(request.method()) {
return cache_http_invalidation(
&state,
&snapshot,
frontend,
&host,
request,
context.client_ip,
peer.0.ip(),
context.scheme,
)
.await;
return cache_http_invalidation(&state, &snapshot, frontend, &host, request, context.client_ip, peer.0.ip(), context.scheme).await;
}
if let Some((_redirect, rule)) = redirect {
let authority = original_host.to_str().unwrap_or(&host);
@@ -449,11 +434,10 @@ async fn forward_request(
)
});
};
let fallback_body =
match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
Ok(body) => body,
Err(response) => return response,
};
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
Ok(body) => body,
Err(response) => return response,
};
let (
backend,
backend_lease,
+3 -10
View File
@@ -58,10 +58,7 @@ pub struct MethodPolicy {
}
impl MethodPolicy {
pub fn validate(&mut self) -> Result<()> {
ensure!(
self.methods.len() <= 32,
"method policy allows at most 32 methods"
);
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
let mut seen = std::collections::HashSet::new();
for method in &mut self.methods {
*method = method.trim().to_ascii_uppercase();
@@ -69,10 +66,7 @@ impl MethodPolicy {
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
"invalid HTTP method in method policy"
);
ensure!(
seen.insert(method.clone()),
"duplicate HTTP method in method policy"
);
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
}
ensure!(
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
@@ -322,8 +316,7 @@ pub struct Vhost {
pub headers: HeaderRules,
#[serde(default)]
pub security_headers: SecurityHeaders,
#[serde(default)]
pub user_agent_blocking: crate::user_agent::Policy,
#[serde(default)] pub user_agent_blocking: crate::user_agent::Policy,
/// Nginx-style upstream timeout controls. Empty values inherit process defaults.
#[serde(default)]
pub proxy_timeouts: ProxyTimeouts,
+13 -41
View File
@@ -71,10 +71,7 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost"
);
ensure!(
!self.frontend.hosts.is_empty(),
"vhost requires at least one hostname"
);
ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname");
let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?;
@@ -164,10 +161,7 @@ impl Vhost {
"location cannot define both a local response and a redirect"
);
if let Some(id) = &route.acl_profile_id {
ensure!(
crate::config::valid_id(id),
"invalid location ACL profile id"
);
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
}
route.country_access.validate()?;
route.method_policy.validate()?;
@@ -205,21 +199,13 @@ impl Vhost {
// Reject cycles at configuration time; a runtime depth guard remains as a
// second line of defense for hot-reload/state inconsistencies.
let mut starts = vec![None];
starts.extend(
self.routes
.iter()
.filter(|route| route.enabled)
.map(|route| Some(route.id.as_str())),
);
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
for start in starts {
let mut seen = std::collections::HashSet::new();
let mut current = start;
loop {
let key = current.unwrap_or("<main>");
ensure!(
seen.insert(key.to_owned()),
"recursive error fallback loop detected"
);
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
let fallback = match current {
None => self.error_fallback.as_ref(),
Some(id) => self
@@ -237,9 +223,7 @@ impl Vhost {
if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone();
settings.health_check = custom.clone();
settings
.validate()
.with_context(|| format!("backend {} health check", member.id))?;
settings.validate().with_context(|| format!("backend {} health check", member.id))?;
}
}
self.proxy_timeouts.validate()?;
@@ -260,8 +244,7 @@ impl Vhost {
"choose either Tinyauth or Basic authentication"
);
self.pages.validate()?;
self.transform
.resolve(&crate::transform::Settings::default())?;
self.transform.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?;
self.user_agent_blocking.compile()?;
self.headers.compile()?;
@@ -336,10 +319,8 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
/// counterpart must both be routable, even if only one was configured.
fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") {
ensure!(
!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname"
);
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname");
crate::config::normalize_host(bare)
} else {
crate::config::normalize_host(&format!("www.{host}"))
@@ -347,23 +328,14 @@ fn www_partner(host: &str) -> Result<String> {
}
impl Vhost {
fn www_canonical_host(&self) -> Result<&str> {
let canonical = self
.www_redirect_target
.as_deref()
let canonical = self.www_redirect_target.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(
!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname"
);
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname");
let partner = www_partner(canonical)?;
ensure!(
self.frontend
.hosts
.iter()
.any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost"
);
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost");
Ok(canonical)
}