v0.34.4
This commit is contained in:
1 parent
ee174bc283
commit
52e7ce7b46
24 files changed
+672
-418
No files matched your search
+14
-36
@@ -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))
|
||||
|
||||
@@ -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()) },
|
||||
|
||||
+32
-109
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user