This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:09:50 +02:00
1 parent 1fd81eaf5f
commit ee174bc283
36 files changed
+908 -183

No files matched your search

+89 -8
View File
@@ -59,13 +59,17 @@ 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));
@@ -76,6 +80,84 @@ impl Acl {
})
}
}
/// One reusable profile. Only the set of profiles attached to the same Vhost
/// is combined; local Vhost ACLs, locations and global host rules remain
/// independent restrictions.
#[derive(Debug, Clone)]
pub struct ProfileAccess {
pub acl: Option<CompiledAcl>,
pub countries: crate::rate_limit::CountryAccess,
}
/// Keep the single-profile rules unchanged. With multiple profiles, explicit
/// IP or country denies prevail across all profiles, explicit allows are a
/// union, and unmatched defaults allow only when every profile allows by
/// default. This avoids interpreting an unmatched default-deny as a veto of
/// another profile's explicit exception.
pub fn allows_profiles_with_country(
profiles: &[ProfileAccess],
peer: IpAddr,
country: Option<&str>,
) -> bool {
if profiles.len() <= 1 {
return profiles.first().is_none_or(|profile| {
allows_with_country(profile.acl.as_ref(), &profile.countries, peer, 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 country_listed = country.is_some_and(|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)
{
return false;
}
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);
}
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> {
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"
);
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"
);
}
Ok(())
}
impl CompiledAcl {
fn explicit_action(&self, peer: IpAddr) -> Option<Action> {
let peer = match peer {
@@ -139,9 +221,7 @@ 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
@@ -186,7 +266,8 @@ 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))
+88
View File
@@ -272,4 +272,92 @@ mod tests {
assert!(matches!(routing_path_view("/%61").unwrap(), Cow::Owned(_)));
assert!(routing_path_view(&format!("/{}", "x".repeat(8192))).is_err());
}
fn profile(default: Action, rules: &[(&str, Action)], mode: crate::rate_limit::CountryMode, countries: &[&str]) -> ProfileAccess {
ProfileAccess {
acl: Some(Acl {
default,
rules: rules.iter().map(|(network, action)| Rule {
network: (*network).into(), action: *action,
}).collect(),
}.compile().unwrap()),
countries: crate::rate_limit::CountryAccess {
mode,
countries: countries.iter().map(|s| (*s).into()).collect(),
},
}
}
#[test]
fn two_acl_profiles_union_explicit_allow_but_retain_default_deny() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![
profile(Action::Deny, &[("10.0.0.0/8", Action::Allow)], Off, &[]),
profile(Action::Deny, &[("192.0.2.0/24", Action::Allow)], Off, &[]),
];
assert!(allows_profiles_with_country(&profiles, "10.1.2.3".parse().unwrap(), None));
assert!(allows_profiles_with_country(&profiles, "192.0.2.1".parse().unwrap(), None));
assert!(!allows_profiles_with_country(&profiles, "203.0.113.9".parse().unwrap(), None));
}
#[test]
fn explicit_deny_has_priority_over_any_matching_allow() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![
profile(Action::Allow, &[("0.0.0.0/0", Action::Allow)], Off, &[]),
profile(Action::Deny, &[("198.51.100.0/24", Action::Deny)], Off, &[]),
];
assert!(!allows_profiles_with_country(&profiles, "198.51.100.5".parse().unwrap(), Some("PL")));
assert!(allows_profiles_with_country(&profiles, "203.0.113.5".parse().unwrap(), Some("PL")));
let conflicting = vec![
profiles[0].clone(),
profile(Action::Deny, &[("0.0.0.0/0", Action::Deny)], Off, &[]),
];
assert!(!allows_profiles_with_country(&conflicting, "203.0.113.5".parse().unwrap(), None));
}
#[test]
fn country_block_wins_across_profiles_and_geo_allow_merges_with_ip_allow() {
use crate::rate_limit::CountryMode::{Allow, Block};
let profiles = vec![
profile(Action::Deny, &[("10.0.0.0/8", Action::Allow)], Allow, &["PL"]),
profile(Action::Deny, &[], Block, &["CN"]),
];
assert!(allows_profiles_with_country(&profiles, "203.0.113.7".parse().unwrap(), Some("pl")));
assert!(allows_profiles_with_country(&profiles, "10.2.3.4".parse().unwrap(), Some("US")));
assert!(!allows_profiles_with_country(&profiles, "10.2.3.4".parse().unwrap(), Some("CN")));
assert!(!allows_profiles_with_country(&profiles, "203.0.113.7".parse().unwrap(), Some("US")));
}
#[test]
fn defaults_require_consensus_and_inline_restrictions_still_deny() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![profile(Action::Allow, &[], Off, &[]), profile(Action::Deny, &[], Off, &[])];
let peer = "203.0.113.5".parse().unwrap();
assert!(!allows_profiles_with_country(&profiles, peer, None));
let all_allow = vec![profile(Action::Allow, &[], Off, &[]), profile(Action::Allow, &[], Off, &[])];
assert!(allows_profiles_with_country(&all_allow, peer, None));
let inline = Acl { default: Action::Deny, rules: vec![] }.compile().unwrap();
assert!(!(allows_profiles_with_country(&all_allow, peer, None) && inline.allows(peer)));
assert!(allows_profiles_with_country(&[], peer, None));
}
#[test]
fn singular_profile_keeps_legacy_behavior_and_ids_validate() {
use crate::rate_limit::CountryMode::Allow;
let single = profile(Action::Allow, &[], Allow, &["DE"]);
for country in [None, Some("DE"), Some("PL")] {
for peer in ["10.1.2.3", "203.0.113.5"] {
let peer = peer.parse().unwrap();
assert_eq!(
allows_profiles_with_country(&[single.clone()], peer, country),
allows_with_country(single.acl.as_ref(), &single.countries, peer, country),
);
}
}
assert!(validate_profile_ids(Some("old"), &["new".into()]).is_ok());
assert!(validate_profile_ids(Some("same"), &["same".into()]).is_err());
assert!(validate_profile_ids(None, &vec!["id".into(); 2]).is_err());
assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::<Vec<_>>()).is_err());
}
}
+39 -2
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.3",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. Version 0.34.3 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
"version": "0.34.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
{
@@ -672,6 +672,20 @@
},
"rate_limit": {
"$ref": "#/components/schemas/VhostRateLimit"
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
},
"allow_certificate_mismatch": {
"type": "boolean",
"default": false,
"description": "Explicit, persistent administrator opt-in: allow assigning a named public certificate to hostnames not covered by its DNS SAN. This does not suppress TLS client warnings, validate the certificate less during agent polling, or disable backend TLS verification. Requires a named certificate."
}
},
"additionalProperties": true,
@@ -2884,6 +2898,20 @@
"default": null,
"description": "Legacy compatibility field accepted when loading older 0.27.0 pre-release configuration. Ignored for Docker vhosts; deflate availability is controlled by global Response settings.",
"deprecated": true
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
},
"allow_certificate_mismatch": {
"type": "boolean",
"default": false,
"description": "Explicit, persistent administrator opt-in: allow assigning a named public certificate to hostnames not covered by its DNS SAN. This does not suppress TLS client warnings, validate the certificate less during agent polling, or disable backend TLS verification. Requires a named certificate."
}
},
"required": [
@@ -2966,6 +2994,15 @@
"nullable": true,
"maxLength": 64,
"description": "Optional default ACL profile inherited by generated Docker vhosts whose assignment does not select its own ACL profile."
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
}
},
"required": [
+60 -15
View File
@@ -50,6 +50,25 @@
function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; }
function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; }
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;
}
function readProfileChoices(scope, selector) {
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
}
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; };
choices.addEventListener("change", sync); sync(); return warning;
}
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"));
@@ -145,10 +164,11 @@
const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto");
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 = el("select", undefined, "docker-agent-acl-profile"), noAcl = el("option", t("dockerDefaultAclNone")); noAcl.value = ""; defaultAcl.append(noAcl);
for (const profile of envelope?.config?.acl_profiles || []) { const item = el("option", profile.name || profile.id); item.value = profile.id; defaultAcl.append(item); } defaultAcl.value = value.acl_profile_id || "";
const defaultAcl = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
const defaultAclField = labeled("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")), labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp")));
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);
const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label"));
const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField);
const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint);
@@ -167,7 +187,7 @@
id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked,
endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value,
tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(),
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: row.querySelector(".docker-agent-acl-profile").value || null, default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-agent-acl-profile"), default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
listener_ids: listenerIds, assignments: structuredClone(row._assignments || [])
};
})
@@ -236,15 +256,15 @@
}
function aclEditor(assignment, agent) {
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 = el("select", undefined, "docker-acl-profile");
const inheritedProfile = agent?.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(item => item.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : "";
const none = el("option", inheritedProfile ? `${t("dockerAclProfileInherit")}: ${inheritedProfile}` : t("dockerAclProfileNone")); none.value = ""; profile.append(none);
for (const item of envelope?.config?.acl_profiles || []) { const option = el("option", item.name || item.id); option.value = item.id; profile.append(option); } profile.value = assignment?.acl_profile_id || "";
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 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(labeled("dockerAclProfile", profile), inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
grid.append(profileField, 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);
@@ -260,7 +280,9 @@
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,
// 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: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
};
@@ -268,7 +290,13 @@
async function saveAssignment(agentId, row, publish) {
const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false;
const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key);
if (publish) { const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired")); if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment); }
if (publish) {
const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired"));
const previous = index >= 0 ? agent.assignments[index] : null;
const consentNames = item => JSON.stringify([item?.hostname || "", ...(item?.aliases || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort());
if (assignment.allow_certificate_mismatch && (!previous?.allow_certificate_mismatch || consentNames(assignment) !== consentNames(previous)) && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return false;
if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment);
}
else if (index >= 0) agent.assignments.splice(index, 1);
const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved;
}
@@ -304,7 +332,12 @@
const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled);
const compressionEnabled = input("", "checkbox", "docker-compression-enabled"); compressionEnabled.checked = assignment?.compression_enabled !== false; const compressionField = checkboxField("dockerCompression", compressionEnabled); compressionField.title = t("dockerCompressionHelp");
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, compressionField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
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 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();
@@ -335,8 +368,8 @@
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary");
const defaultAclName = agent.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(profile => profile.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const defaultAclName = selectedProfileIds(agent).map(id => ((envelope?.config?.acl_profiles || []).find(profile => profile.id === id)?.name || id)).join(", ") || t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono docker-fingerprint")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
}
applyViewFilters();
@@ -407,7 +440,19 @@
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
$("docker-agents-form")?.addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try {
await withFormSubmitState(form, event.submitter, async () => {
const next = collectSettings();
const changingAcceptedCertificate = next.agents.some(agent => {
const previous = (cfg().agents || []).find(old => old.id === agent.id);
return previous && previous.certificate_id !== agent.certificate_id && agent.assignments.some(item => item.allow_certificate_mismatch);
});
if (changingAcceptedCertificate && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return;
if (await saveSection("docker-agents", next)) { setSettingsDirty("docker-agents-form", false); await refresh(true); }
});
} catch (error) { toast(error.message, true); }
});
rustProxyRefresh.register("docker", {
interval: () => 5,
// Even with local Live view off, observe the first startup result.
+4 -3
View File
@@ -406,10 +406,11 @@
const box = el("div", undefined, "entity acl-profile-card");
const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top);
const pieces = friendlySummary(profile);
const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id);
const usesProfile = value => value.acl_profile_id === profile.id || (value.acl_profile_ids || []).includes(profile.id);
const users = (envelope.config.vhosts || []).filter(usesProfile).map(v => v.id);
for (const agent of envelope.config.docker_agents?.agents || []) {
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`);
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
if (usesProfile(agent)) users.push(`docker-agent:${agent.id}`);
for (const assignment of agent.assignments || []) if (usesProfile(assignment)) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
}
box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details"));
if (users.length) {
+83
View File
@@ -12314,3 +12314,86 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
color: var(--muted);
font: 400 10px var(--mono);
}
.docker-agent-summary>span,
.docker-agent-security-hint,
.docker-fingerprint {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.vh-acl-profile-picker,
.docker-acl-choices {
min-width: 0;
}
.vh-acl-profile-picker>strong {
display: block;
margin-bottom: 8px;
}
.vh-acl-profile-choices,
.docker-acl-choices {
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;
font-size: 12px;
}
.vh-acl-profile-choice input,
.docker-acl-choice input {
flex: 0 0 auto;
width: auto !important;
min-height: 0 !important;
height: auto !important;
}
.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;
width: 100%;
box-sizing: border-box;
}
+1 -1
View File
@@ -45,7 +45,7 @@ const defaults = () => ({
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
+22 -3
View File
@@ -238,8 +238,22 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
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"));
profileBox.append(choices);
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
choices.addEventListener("change", syncMultiWarning); syncMultiWarning();
profileBox.append(multiWarning); g.append(profileBox); note(g, "vhAclProfileHelp");
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhMethodHelp");
@@ -273,6 +287,10 @@ function load(value = defaults(), migrationIds = [], show = true) {
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
const mismatchHint = note(g, "vhCertificateMismatchWarning");
const syncMismatch = () => { mismatch.disabled = !selector.value; if (!selector.value) mismatch.checked = false; mismatchHint.hidden = !mismatch.checked; };
selector.addEventListener("change", syncMismatch); mismatch.addEventListener("change", syncMismatch); syncMismatch();
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
@@ -445,7 +463,8 @@ function collect() {
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
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"), 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: nullable("acl-profile-id"),
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),
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()) },
+9
View File
@@ -561,6 +561,15 @@ form.addEventListener("submit", async event => {
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing);
// A previous acceptance must not silently authorize new hostnames or a different certificate.
const certificateScope = item => JSON.stringify({
certificate: item?.certificate_id || null,
hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(),
wwwRedirect: item?.www_redirect === true,
wwwTarget: item?.www_redirect_target || null
});
if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { danger: true }))) return;
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
+104 -30
View File
@@ -48,29 +48,58 @@ 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;
@@ -82,13 +111,25 @@ 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();
@@ -267,7 +308,9 @@ 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 {
if let Some(profile_id) = agent.acl_profile_id.as_deref() {
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 {}",
@@ -276,7 +319,10 @@ pub fn compile_with_active(
);
}
for assignment in &agent.assignments {
if let Some(profile_id) = assignment.acl_profile_id.as_deref() {
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 {}",
@@ -395,7 +441,9 @@ 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);
};
@@ -411,24 +459,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
);
// 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 {
+2
View File
@@ -263,6 +263,8 @@ pub struct CompiledFrontend {
pub acl_profile_id: Option<String>,
pub profile_acl: Option<CompiledAcl>,
pub profile_country_access: crate::rate_limit::CountryAccess,
/// All Vhost profile scopes, checked before the inline ACL.
pub profile_policies: Vec<crate::acl::ProfileAccess>,
pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders,
pub security: crate::security::CompiledSecurity,
+11 -5
View File
@@ -173,12 +173,17 @@ fn compile_routing(
.as_ref()
.map(|base| crate::tls::variant(base, &tls_policy, false))
.transpose()?;
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
let profile_policies: Vec<crate::acl::ProfileAccess> = crate::acl::profile_ids(
host.acl_profile_id.as_deref(), &host.acl_profile_ids,
).map(|id| {
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
})?,
None => (None, crate::rate_limit::CountryAccess::default()),
};
})?;
Ok(crate::acl::ProfileAccess { acl, countries })
}).collect::<Result<_>>()?;
let (profile_acl, profile_country_access) = profile_policies.first()
.map(|entry| (entry.acl.clone(), entry.countries.clone()))
.unwrap_or_else(|| (None, crate::rate_limit::CountryAccess::default()));
let frontend = Arc::new(CompiledFrontend {
metrics,
websockets: host.websockets,
@@ -215,6 +220,7 @@ fn compile_routing(
acl_profile_id: host.acl_profile_id.clone(),
profile_acl,
profile_country_access,
profile_policies,
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
headers: host.headers.compile()?,
security: host.security_headers.compile()?,
+21 -14
View File
@@ -1,9 +1,6 @@
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);
}
@@ -136,11 +133,7 @@ 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;
};
@@ -212,7 +205,10 @@ 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))
@@ -269,17 +265,25 @@ 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() {
@@ -289,7 +293,10 @@ 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.
+38
View File
@@ -1054,4 +1054,42 @@ fn stack_host_lookup_preserves_long_internal_host_fallback() {
assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260)));
assert_eq!(patterns[2], "*.sub.example.test");
}
#[test]
fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([
{"id":"office","name":"Office","acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}},
{"id":"home","name":"Home","acl":{"default":"deny","rules":[{"network":"192.0.2.0/24","action":"allow"}]}}
])).unwrap();
c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()];
c.vhosts[0].acl = Some(crate::acl::Acl {
default: crate::acl::Action::Deny,
rules: vec![crate::acl::Rule { network: "10.0.0.0/8".into(), action: crate::acl::Action::Allow }],
});
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.profile_policies.len(), 2);
assert!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None));
assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap()));
c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()];
assert!(compile(c.clone(), 2, &policy()).is_err());
c.vhosts[0].acl_profile_ids = vec!["office".into(), "office".into()];
assert!(compile(c, 3, &policy()).is_err());
}
#[test]
fn old_vhost_profile_field_survives_json_roundtrip() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap();
c.vhosts[0].acl_profile_id = Some("office".into());
let json = serde_json::to_string(&c).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap();
assert!(decoded.vhosts[0].acl_profile_ids.is_empty());
let snapshot = compile(decoded, 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.profile_policies.len(), 1);
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
}
}
+23 -12
View File
@@ -54,6 +54,8 @@ pub struct Agent {
pub poll_interval_seconds: u64,
pub certificate_id: Option<String>,
pub acl_profile_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
#[serde(alias = "target_ip_override")]
pub default_target_ip: Option<IpAddr>,
pub listener_ids: Vec<String>,
@@ -72,6 +74,7 @@ impl Default for Agent {
poll_interval_seconds: 30,
certificate_id: None,
acl_profile_id: None,
acl_profile_ids: Vec::new(),
default_target_ip: None,
listener_ids: Vec::new(),
assignments: Vec::new(),
@@ -121,6 +124,10 @@ pub struct Assignment {
pub cache_enabled: bool,
pub transform_enabled: bool,
pub acl_profile_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
/// Opt in to a deliberately mismatched public certificate for this hostname.
pub allow_certificate_mismatch: bool,
pub acl: Option<crate::acl::Acl>,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
pub health_check: crate::balance::HealthCheck,
@@ -146,6 +153,8 @@ impl Default for Assignment {
cache_enabled: false,
transform_enabled: false,
acl_profile_id: None,
acl_profile_ids: Vec::new(),
allow_certificate_mismatch: false,
acl: None,
proxy_timeouts: crate::vhost::ProxyTimeouts::default(),
health_check: crate::balance::HealthCheck::default(),
@@ -283,12 +292,11 @@ impl Settings {
if let Some(ip) = agent.default_target_ip {
usable_target_ip(ip).context("invalid Docker agent default target IP")?;
}
if let Some(profile_id) = agent.acl_profile_id.as_deref() {
ensure!(
crate::config::valid_id(profile_id),
"invalid Docker agent ACL profile id"
);
}
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"
@@ -324,12 +332,15 @@ impl Settings {
if let Some(ip) = assignment.target_ip {
usable_target_ip(ip).context("invalid Docker assignment target IP")?;
}
if let Some(profile_id) = assignment.acl_profile_id.as_deref() {
ensure!(
crate::config::valid_id(profile_id),
"invalid Docker assignment ACL profile id"
);
}
crate::acl::validate_profile_ids(
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"
);
if assignment.force_ssl == Some(true) {
let mut has_http = false;
let mut tls_ports = HashSet::new();
+18 -5
View File
@@ -67,7 +67,11 @@ 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
@@ -81,9 +85,7 @@ 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)?;
@@ -111,6 +113,15 @@ 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 (acl_profile_id, acl_profile_ids) = if override_profiles {
(&assignment.acl_profile_id, &assignment.acl_profile_ids)
} else {
(&agent.acl_profile_id, &agent.acl_profile_ids)
};
let value = serde_json::json!({
"id": generated_id(&agent.id, &assignment.service_key),
"frontend": {"hosts": hosts, "listener_ids": agent.listener_ids},
@@ -123,7 +134,9 @@ pub fn effective_config(
"compression": {"enabled": assignment.compression_enabled},
"cache": {"enabled": assignment.cache_enabled},
"transform": {"enabled": assignment.transform_enabled},
"acl_profile_id": assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone()),
"acl_profile_id": acl_profile_id,
"acl_profile_ids": acl_profile_ids,
"allow_certificate_mismatch": assignment.allow_certificate_mismatch,
"acl": assignment.acl.clone(),
"proxy_timeouts": assignment.proxy_timeouts.clone(),
"load_balancing": {
+59 -1
View File
@@ -58,6 +58,7 @@ mod tests {
poll_interval_seconds: 30,
certificate_id: Some("docker-cert".into()),
acl_profile_id: None,
acl_profile_ids: Vec::new(),
default_target_ip: None,
listener_ids: vec!["http".into()],
assignments: vec![Assignment {
@@ -227,7 +228,14 @@ 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());
@@ -491,4 +499,54 @@ mod tests {
"generated Docker vhost id is invalid: {id}"
);
}
#[test]
fn docker_assignments_inherit_or_override_acl_sets_and_force_tls_only_on_consent() {
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(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
certificate_id: Some("selected-cert".into()),
acl_profile_id: Some("legacy".into()),
acl_profile_ids: vec!["work".into(), "vpn".into()],
assignments: vec![Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
allow_certificate_mismatch: true,
..Default::default()
}],
..Default::default()
});
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_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].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!(!overridden.vhosts[0].allow_certificate_mismatch);
}
#[test]
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();
assert_eq!(assignment.acl_profile_id.as_deref(), Some("legacy"));
assert!(assignment.acl_profile_ids.is_empty());
assert!(!assignment.allow_certificate_mismatch);
}
}
+33 -18
View File
@@ -88,10 +88,7 @@ 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.
@@ -181,9 +178,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;
}
}
@@ -196,7 +193,9 @@ 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,
) {
@@ -237,7 +236,10 @@ async fn forward_request(
let mut country: Option<String> = None;
if let Some(frontend) = frontend {
if capture_country
|| frontend.profile_country_access.needs_geoip()
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip()
{
country = cloudflare_country
@@ -250,9 +252,8 @@ async fn forward_request(
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
}
}
if !crate::acl::allows_with_country(
frontend.profile_acl.as_ref(),
&frontend.profile_country_access,
if !crate::acl::allows_profiles_with_country(
&frontend.profile_policies,
context.client_ip,
country.as_deref(),
) || !crate::acl::allows_with_country(
@@ -272,7 +273,10 @@ 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(
@@ -296,7 +300,17 @@ 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);
@@ -435,10 +449,11 @@ 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,
+5
View File
@@ -285,6 +285,11 @@ pub fn for_all_frontends(
if let Some(id) = &vhost.certificate_id {
let hosts = vhost.routing_hosts()?;
assign(&hosts, id, "vhost")?;
if vhost.allow_certificate_mismatch {
for host in hosts {
result.forced_mismatches.insert((id.clone(), host));
}
}
}
}
for redirect in redirects {
+9 -4
View File
@@ -372,10 +372,15 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
.strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone());
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}"))?;
if !settings.forced_mismatches.contains(&(cert.id.clone(), host.clone())) {
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
} else {
tracing::warn!(certificate = %cert.id, hostname = %host,
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
}
names.insert(host, Arc::new(key.clone()));
}
if settings.default_certificate.as_deref() == Some(cert.id.as_str()) {
+42
View File
@@ -431,4 +431,46 @@ mod tests {
);
assert!(certificate_covered_hosts(dir.path(), "../legacy.pem").is_err());
}
#[test]
fn mismatched_named_certificate_requires_explicit_per_vhost_consent() {
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
generate(dir.path(), "named", vec!["right.example.test".into()]).unwrap();
let settings = Settings {
certificates: vec![Certificate {
id: "named".into(),
issuer: None,
covered_hosts: vec!["right.example.test".into()],
hosts: vec![],
cert_file: "named.pem".into(),
key_file: "named.key".into(),
}],
..Default::default()
};
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let vhost = &mut config.vhosts[0];
vhost.certificate_id = Some("named".into());
vhost.frontend.hosts = vec!["other.example.test".into()];
let blocked = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(compile(&blocked, Some(dir.path())).is_err());
assert!(blocked.forced_mismatches.is_empty());
vhost.allow_certificate_mismatch = true;
let forced = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(forced.forced_mismatches.contains(&("named".into(), "other.example.test".into())));
assert!(compile(&forced, Some(dir.path())).is_ok());
let saved = serde_json::to_value(&forced).unwrap();
assert!(saved.get("forced_mismatches").is_none());
let restored: Settings = serde_json::from_value(saved).unwrap();
assert!(restored.forced_mismatches.is_empty());
assert!(compile(&restored, Some(dir.path())).is_err());
let mut other = config.vhosts[0].clone();
other.id = "another".into();
other.frontend.hosts = vec!["third.example.test".into()];
other.allow_certificate_mismatch = false;
config.vhosts.push(other);
let partial = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(compile(&partial, Some(dir.path())).is_err());
}
}
+4
View File
@@ -25,6 +25,9 @@ pub struct Settings {
#[serde(default)]
pub certificates: Vec<Certificate>,
pub policy: ProtocolPolicy,
/// Derived from explicitly confirmed Vhosts; never accepted from TLS JSON.
#[serde(skip)]
pub forced_mismatches: HashSet<(String, String)>,
}
impl Default for Settings {
fn default() -> Self {
@@ -32,6 +35,7 @@ impl Default for Settings {
default_certificate: None,
certificates: Vec::new(),
policy: ProtocolPolicy::default(),
forced_mismatches: HashSet::new(),
}
}
}
+18 -3
View File
@@ -58,7 +58,10 @@ 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();
@@ -66,7 +69,10 @@ 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(),
@@ -276,6 +282,10 @@ pub struct Vhost {
pub tinyauth: crate::tinyauth::Settings,
#[serde(default)]
pub certificate_id: Option<String>,
/// Explicitly accept serving an SNI name with a certificate that does not
/// contain it in DNS SAN. Clients will still report a TLS name mismatch.
#[serde(default)]
pub allow_certificate_mismatch: bool,
/// Optional protocol/cipher override. None inherits the global TLS policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_policy: Option<crate::tls::ProtocolPolicy>,
@@ -302,13 +312,18 @@ pub struct Vhost {
/// Optional reusable ACL profile. The profile and the inline ACL are both enforced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acl_profile_id: Option<String>,
/// Additional reusable profiles. Explicit denies outrank all allows across
/// selected profiles. Inline ACL remains an independent restriction.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
#[serde(default)]
pub acl: Option<Acl>,
#[serde(default)]
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,
+46 -16
View File
@@ -71,7 +71,10 @@ 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)?;
@@ -93,9 +96,11 @@ impl Vhost {
let _ = self.www_redirect_pair()?;
let _ = self.routing_hosts()?;
}
if let Some(id) = &self.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
}
crate::acl::validate_profile_ids(self.acl_profile_id.as_deref(), &self.acl_profile_ids)?;
ensure!(
!self.allow_certificate_mismatch || self.certificate_id.is_some(),
"certificate mismatch override requires an explicitly selected certificate"
);
self.method_policy.validate()?;
ensure!(
!self.frontend.listener_ids.is_empty(),
@@ -159,7 +164,10 @@ 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()?;
@@ -197,13 +205,21 @@ 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
@@ -221,7 +237,9 @@ 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()?;
@@ -242,7 +260,8 @@ 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()?;
@@ -317,8 +336,10 @@ 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}"))
@@ -326,14 +347,23 @@ 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)
}