v0.32.5
This commit is contained in:
1 parent
8b9ca4db44
commit
00218a72df
35 files changed
+790
-870
No files matched your search
+29
-12
@@ -4,6 +4,8 @@ use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub(crate) mod dynamic;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Action {
|
||||
@@ -42,22 +44,35 @@ pub struct Acl {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledAcl {
|
||||
default: Action,
|
||||
rules: Vec<(IpNet, Action)>,
|
||||
rules: Vec<(RuleMatch, Action)>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum RuleMatch {
|
||||
Ip(IpNet),
|
||||
Dns(String),
|
||||
}
|
||||
impl Acl {
|
||||
pub fn compile(&self) -> Result<CompiledAcl> {
|
||||
ensure!(self.rules.len() <= 128, "ACL supports at most 128 rules");
|
||||
let mut rules = Vec::with_capacity(self.rules.len());
|
||||
for rule in &self.rules {
|
||||
ensure!(rule.network.len() <= 64, "ACL network is too long");
|
||||
let network = parse_network(&rule.network)?;
|
||||
if let IpNet::V6(net) = network {
|
||||
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"
|
||||
);
|
||||
}
|
||||
RuleMatch::Ip(network)
|
||||
} else {
|
||||
ensure!(
|
||||
net.addr().to_ipv4_mapped().is_none(),
|
||||
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
|
||||
rule.action == Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
|
||||
);
|
||||
}
|
||||
rules.push((network, rule.action));
|
||||
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
|
||||
};
|
||||
rules.push((target, rule.action));
|
||||
}
|
||||
Ok(CompiledAcl {
|
||||
default: self.default,
|
||||
@@ -73,7 +88,11 @@ impl CompiledAcl {
|
||||
};
|
||||
let mut allowed = false;
|
||||
for (network, action) in &self.rules {
|
||||
if network.contains(&peer) {
|
||||
let matched = match network {
|
||||
RuleMatch::Ip(net) => net.contains(&peer),
|
||||
RuleMatch::Dns(host) => dynamic::matches(host, peer),
|
||||
};
|
||||
if matched {
|
||||
if *action == Action::Deny {
|
||||
return Some(Action::Deny);
|
||||
}
|
||||
@@ -124,9 +143,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
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path.
|
||||
use crate::config::Config;
|
||||
use anyhow::{ensure, Result};
|
||||
use arc_swap::ArcSwap;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
net::IpAddr,
|
||||
sync::{Arc, LazyLock, Weak},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{sync::Notify, time::MissedTickBehavior};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const REFRESH_SECONDS: u64 = 60;
|
||||
const MAX_AGE_SECONDS: u64 = 180;
|
||||
const MAX_NAMES: usize = 128;
|
||||
const MAX_ADDRESSES: usize = 32;
|
||||
const PARALLEL_LOOKUPS: usize = 8;
|
||||
const LOOKUP_SECONDS: u64 = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Addresses {
|
||||
ips: Vec<IpAddr>,
|
||||
valid_until: Instant,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<ArcSwap<HashMap<String, Addresses>>> =
|
||||
LazyLock::new(|| ArcSwap::from_pointee(HashMap::new()));
|
||||
static CHANGED: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||
|
||||
pub(crate) fn matches(name: &str, ip: IpAddr) -> bool {
|
||||
let cache = CACHE.load();
|
||||
cache.get(name).is_some_and(|entry| {
|
||||
entry.valid_until > Instant::now() && entry.ips.binary_search(&ip).is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Strict ASCII DNS hostnames only (IDNs can be supplied in punycode).
|
||||
/// A and AAAA results are eligible. IPs and CIDRs remain separate static rules.
|
||||
pub(crate) fn hostname(value: &str) -> Result<String> {
|
||||
let candidate = value.strip_suffix('.').unwrap_or(value);
|
||||
ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname");
|
||||
ensure!(candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'), "invalid ACL DNS name");
|
||||
let labels: Vec<_> = candidate.split('.').collect();
|
||||
ensure!(labels.last().is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())), "invalid ACL DNS name suffix");
|
||||
for label in labels {
|
||||
ensure!(!label.is_empty() && label.len() <= 63, "invalid ACL DNS label length");
|
||||
ensure!(label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
&& label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'), "invalid ACL DNS label");
|
||||
}
|
||||
Ok(candidate.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Walk every ACL scope, including standalone host policies, ACL profiles,
|
||||
/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work.
|
||||
pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
|
||||
let mut names = BTreeSet::new();
|
||||
let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> {
|
||||
if let Some(acl) = acl {
|
||||
for rule in &acl.rules {
|
||||
if rule.network.parse::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().is_ok() {
|
||||
continue;
|
||||
}
|
||||
// A slash is always a CIDR error, never a DNS name.
|
||||
let name = hostname(&rule.network)?;
|
||||
ensure!(rule.action == crate::acl::Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny");
|
||||
names.insert(name);
|
||||
ensure!(names.len() <= MAX_NAMES, "too many distinct dynamic DNS ACL names (max 128)");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for policy in &config.host_policies { inspect(Some(&policy.acl))?; }
|
||||
for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; }
|
||||
for vhost in &config.vhosts {
|
||||
inspect(vhost.acl.as_ref())?;
|
||||
for route in &vhost.routes { inspect(route.acl.as_ref())?; }
|
||||
}
|
||||
for agent in &config.docker_agents.agents {
|
||||
for assignment in &agent.assignments { inspect(assignment.acl.as_ref())?; }
|
||||
}
|
||||
Ok(names.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn resolve(name: String) -> (String, Option<Addresses>) {
|
||||
let answer = tokio::time::timeout(
|
||||
Duration::from_secs(LOOKUP_SECONDS),
|
||||
tokio::net::lookup_host((name.as_str(), 0u16)),
|
||||
).await;
|
||||
let mut ips = match answer {
|
||||
Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::<Vec<_>>(),
|
||||
_ => {
|
||||
tracing::warn!(hostname = %name, "dynamic ACL DNS lookup failed; no addresses permitted");
|
||||
return (name, None);
|
||||
}
|
||||
};
|
||||
ips.sort_unstable();
|
||||
ips.dedup();
|
||||
if ips.is_empty() || ips.len() > MAX_ADDRESSES || ips.iter().any(|ip| {
|
||||
matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some())
|
||||
}) {
|
||||
tracing::warn!(hostname = %name, "dynamic ACL DNS returned an invalid or excessive address set");
|
||||
return (name, None);
|
||||
}
|
||||
(name, Some(Addresses { ips, valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS) }))
|
||||
}
|
||||
|
||||
pub(crate) fn config_changed() { CHANGED.notify_one(); }
|
||||
|
||||
pub(crate) async fn poll_loop(
|
||||
app: Weak<crate::runtime::App>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(REFRESH_SECONDS));
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
_ = interval.tick() => {},
|
||||
_ = CHANGED.notified() => {},
|
||||
}
|
||||
let Some(state) = app.upgrade() else { break; };
|
||||
let snapshot = state.config.load();
|
||||
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
|
||||
drop(snapshot);
|
||||
// Resolve concurrently with hard bounds; write one immutable snapshot.
|
||||
let results: HashMap<_, _> = tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
result = stream::iter(names.iter().cloned())
|
||||
.map(resolve)
|
||||
.buffer_unordered(PARALLEL_LOOKUPS)
|
||||
.filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) })
|
||||
.collect() => result,
|
||||
};
|
||||
// A concurrently changed configuration must not publish obsolete DNS data.
|
||||
let current = state.config.load();
|
||||
if configured_hostnames(¤t.envelope.config).ok().as_ref() != Some(&names) {
|
||||
CHANGED.notify_one();
|
||||
continue;
|
||||
}
|
||||
CACHE.store(Arc::new(results));
|
||||
}
|
||||
// Do not retain permit decisions when the resolver lifecycle stops.
|
||||
CACHE.store(Arc::new(HashMap::new()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("dynamic/tests.rs");
|
||||
@@ -0,0 +1,41 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_names_without_accepting_cidr_lookalikes_or_urls() {
|
||||
assert_eq!(hostname("My-Office.DDNS.Example.").unwrap(), "my-office.ddns.example");
|
||||
for bad in ["localhost", "", "*.example.com", "http://ddns.example", "1.2.3.999", "foo..example", "-x.example", "a_.example", "a.example/24"] {
|
||||
assert!(hostname(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn resolver_expiry_fails_closed_and_address_sets_are_exact() {
|
||||
let name = "test-dynamic-unique.example";
|
||||
let ip: IpAddr = "198.51.100.4".parse().unwrap();
|
||||
let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) };
|
||||
assert!(entry.valid_until > Instant::now());
|
||||
// The request path accepts only cached exact addresses, never a subnet.
|
||||
let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry);
|
||||
CACHE.store(Arc::new(cache));
|
||||
assert!(matches(name, ip));
|
||||
assert!(!matches(name, "198.51.100.5".parse().unwrap()));
|
||||
// A formerly resolved IP cannot remain permitted after expiration.
|
||||
let mut expired = HashMap::new();
|
||||
expired.insert(name.to_owned(), Addresses {
|
||||
ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1),
|
||||
});
|
||||
CACHE.store(Arc::new(expired));
|
||||
assert!(!matches(name, ip));
|
||||
// IPv6 addresses have the same exact-match and rotation semantics.
|
||||
let new_ip: IpAddr = "2001:db8::8".parse().unwrap();
|
||||
let mut rotated = HashMap::new();
|
||||
rotated.insert(name.to_owned(), Addresses {
|
||||
ips: vec![new_ip], valid_until: Instant::now() + Duration::from_secs(10),
|
||||
});
|
||||
CACHE.store(Arc::new(rotated));
|
||||
assert!(matches(name, new_ip));
|
||||
assert!(!matches(name, ip));
|
||||
CACHE.store(Arc::new(HashMap::new()));
|
||||
assert!(!matches(name, new_ip));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,51 @@ mod tests {
|
||||
assert!(!acl.allows("2001:db8::8".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_dns_allow_is_fail_closed_until_resolved_and_old_cidrs_still_work() {
|
||||
let acl = Acl {
|
||||
default: Action::Deny,
|
||||
rules: vec![
|
||||
Rule { network: "Office.DDNS.Example".into(), action: Action::Allow },
|
||||
Rule { network: "192.0.2.0/24".into(), action: Action::Allow },
|
||||
Rule { network: "192.0.2.99".into(), action: Action::Deny },
|
||||
],
|
||||
}.compile().unwrap();
|
||||
assert!(!acl.allows("198.51.100.25".parse().unwrap()));
|
||||
assert!(acl.allows("192.0.2.42".parse().unwrap()));
|
||||
assert!(!acl.allows("192.0.2.99".parse().unwrap()));
|
||||
for rejected in ["example.com/24", "*.example.com", "https://example.com", "foo..example.org"] {
|
||||
assert!(Acl {
|
||||
default: Action::Deny,
|
||||
rules: vec![Rule { network: rejected.into(), action: Action::Allow }],
|
||||
}.compile().is_err(), "{rejected}");
|
||||
}
|
||||
assert!(Acl {
|
||||
default: Action::Allow,
|
||||
rules: vec![Rule { network: "example.ddns.net".into(), action: Action::Deny }],
|
||||
}.compile().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn configured_dns_names_are_deduplicated_across_acl_scopes() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.acl_profiles.push(crate::config::AclProfile {
|
||||
id: "home".into(),
|
||||
name: "Home".into(),
|
||||
acl: Some(Acl { default: Action::Deny, rules: vec![
|
||||
Rule { network: "Home.DDNS.Example".into(), action: Action::Allow },
|
||||
] }),
|
||||
..Default::default()
|
||||
});
|
||||
config.host_policies.push(crate::config::HostPolicy {
|
||||
hosts: vec!["example.org".into()],
|
||||
acl: Acl { default: Action::Deny, rules: vec![
|
||||
Rule { network: "home.ddns.example".into(), action: Action::Allow },
|
||||
Rule { network: "office.ddns.example".into(), action: Action::Allow },
|
||||
] },
|
||||
});
|
||||
let hosts = crate::acl::dynamic::configured_hostnames(&config).unwrap();
|
||||
assert_eq!(hosts, vec!["home.ddns.example", "office.ddns.example"]);
|
||||
}
|
||||
#[test]
|
||||
fn country_allow_and_ip_allow_are_alternatives_inside_one_scope() {
|
||||
let acl = Acl {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.32.4",
|
||||
"version": "0.32.5",
|
||||
"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."
|
||||
},
|
||||
"servers": [
|
||||
@@ -251,7 +251,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"network": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Client IPv4/IPv6 address, CIDR, or allow-only DNS/DDNS hostname (A/AAAA refreshed every 60s; failed lookups deny the hostname)."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
@@ -11871,4 +11872,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@
|
||||
const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules");
|
||||
const previewDefault = $("acl-profile-preview-default"), previewFlow = $("acl-profile-preview-flow"), previewNote = $("acl-profile-preview-note");
|
||||
const countryHelp = $("acl-profile-country-help"), countryLabel = $("acl-profile-country-label"), ipHelp = $("acl-profile-ip-help");
|
||||
const dnsHint = i18n("p", "aclDnsHelp", "fine"); ipHelp?.after(dnsHint);
|
||||
const modeInputs = [...form.querySelectorAll('input[name="acl-profile-mode"]')];
|
||||
const i18n = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const selectedMode = () => modeInputs.find(node => node.checked)?.value || "allow_exceptions";
|
||||
@@ -240,6 +241,13 @@
|
||||
if (network.includes(":")) return `${network}/128`;
|
||||
return network;
|
||||
}
|
||||
const ruleKind = value => /[a-z]/i.test(value) && value.includes(".") && !value.includes(":") && !value.includes("/") ? "dns" : "ip";
|
||||
function syncRuleKind(row) {
|
||||
const kind = row.querySelector(".acl-profile-rule-type").value;
|
||||
const network = row.querySelector(".acl-profile-rule-network");
|
||||
network.maxLength = kind === "dns" ? 253 : 64;
|
||||
network.placeholder = kind === "dns" ? "office.example.ddns.net" : "10.0.0.0/8 or 203.0.113.5";
|
||||
}
|
||||
function addRule(rule = null, mark = true) {
|
||||
if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; }
|
||||
const mode = selectedMode(), fallbackAction = mode === "custom" ? "allow" : guidedAction(mode);
|
||||
@@ -250,11 +258,18 @@
|
||||
for (const actionValue of ["allow", "deny"]) { const option = el("option", t(actionValue === "allow" ? "aclAllow" : "vhDeny")); option.value = actionValue; actionSelect.append(option); }
|
||||
actionSelect.value = value.action || fallbackAction; actionLabel.append(actionSelect);
|
||||
const actionBadge = el("span", "", "acl-profile-rule-action-badge"); actionLabel.append(actionBadge);
|
||||
const typeLabel = el("label"); typeLabel.append(i18n("span", "aclRuleType"));
|
||||
const kind = el("select"); kind.className = "acl-profile-rule-type";
|
||||
for (const [id, key] of [["ip", "aclRuleIp"], ["dns", "aclRuleDns"]]) {
|
||||
const item = i18n("option", key); item.value = id; kind.append(item);
|
||||
}
|
||||
kind.value = ruleKind(value.network || ""); typeLabel.append(kind);
|
||||
const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork"));
|
||||
const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8 or 203.0.113.5"; network.value = value.network || ""; networkLabel.append(network);
|
||||
const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.autocomplete = "off"; network.spellcheck = false; network.value = value.network || ""; networkLabel.append(network);
|
||||
network.addEventListener("blur", () => { const normalized = normalizeNetwork(network.value); if (normalized !== network.value) { network.value = normalized; syncRulesText(); renderPreview(); } });
|
||||
kind.addEventListener("change", () => { network.value = ""; syncRuleKind(row); syncRulesText(); dirty = true; syncPolicy(); network.focus(); });
|
||||
const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncPolicy(); }); remove.classList.add("acl-profile-rule-remove");
|
||||
row.append(actionLabel, networkLabel, remove); ruleRows.append(row);
|
||||
row.append(actionLabel, typeLabel, networkLabel, remove); ruleRows.append(row); syncRuleKind(row);
|
||||
if (mark) { syncRulesText(); dirty = true; }
|
||||
syncRuleRows();
|
||||
}
|
||||
@@ -370,6 +385,9 @@
|
||||
function refreshTranslations() {
|
||||
$("acl-profile-editing").textContent = t(editing ? "aclProfileEdit" : "aclProfileNew");
|
||||
for (const row of ruleRows.children) {
|
||||
const network = row.querySelector(".acl-profile-rule-network");
|
||||
const kind = row.querySelector(".acl-profile-rule-type");
|
||||
if (kind) { kind.value = ruleKind(network?.value || ""); syncRuleKind(row); }
|
||||
const select = row.querySelector(".acl-profile-rule-action");
|
||||
if (select?.options?.length >= 2) {
|
||||
select.options[0].textContent = t("aclAllow");
|
||||
@@ -433,6 +451,8 @@
|
||||
}
|
||||
const finalMode = selectedMode(), expectedAction = finalMode === "custom" ? null : guidedAction(finalMode);
|
||||
const aclRules = collectRuleRows().map(rule => ({ ...rule, action: expectedAction || rule.action }));
|
||||
if (aclRules.some(rule => rule.action === "deny" && ruleKind(rule.network) === "dns"))
|
||||
throw fieldError(ruleRows, t("aclDnsAllowOnly"));
|
||||
if (aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); }
|
||||
for (const [row, rule] of [...ruleRows.children].map((row, index) => [row, aclRules[index]])) row.querySelector(".acl-profile-rule-network").value = rule.network;
|
||||
const finalDefault = finalMode === "custom" ? aclDefault.value : guidedDefault(finalMode);
|
||||
|
||||
+183
-679
File diff suppressed because it is too large.
Load diff
+99
-28
@@ -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();
|
||||
@@ -264,6 +305,8 @@ pub fn compile_with_active(
|
||||
"duplicate ACL profile id"
|
||||
);
|
||||
}
|
||||
// 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() {
|
||||
ensure!(
|
||||
@@ -393,7 +436,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);
|
||||
};
|
||||
@@ -409,24 +454,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 {
|
||||
|
||||
@@ -146,7 +146,10 @@ async fn run(
|
||||
}
|
||||
let renewal_observations = store.load_certificate_renewals().await?;
|
||||
let geoip_format = snapshot.envelope.config.geoip_updates.format;
|
||||
let geoip = match rust_proxy::geoip::Database::load_format(&data_dir.join("geoip"), geoip_format) {
|
||||
let geoip = match rust_proxy::geoip::Database::load_format(
|
||||
&data_dir.join("geoip"),
|
||||
geoip_format,
|
||||
) {
|
||||
Ok(database) => std::sync::Arc::new(database),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed");
|
||||
@@ -258,6 +261,7 @@ async fn run(
|
||||
// socket has bound successfully so DNS/network failures can never obscure or
|
||||
// interfere with listener startup failures.
|
||||
state.start_geoip_updates();
|
||||
state.start_dns_acl_updates();
|
||||
state.start_update_check(update_check);
|
||||
let shutdown = state.shutdown.clone();
|
||||
let grace = Duration::from_secs(options.shutdown_timeout_secs);
|
||||
@@ -432,7 +436,10 @@ async fn run(
|
||||
grace
|
||||
};
|
||||
if managed_update {
|
||||
tracing::info!(budget_seconds = cache_flush_budget.as_secs(), "managed-update hot-to-cold cache handoff started");
|
||||
tracing::info!(
|
||||
budget_seconds = cache_flush_budget.as_secs(),
|
||||
"managed-update hot-to-cold cache handoff started"
|
||||
);
|
||||
}
|
||||
let flush_result = if managed_update {
|
||||
tokio::time::timeout(
|
||||
@@ -444,10 +451,9 @@ async fn run(
|
||||
tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await
|
||||
};
|
||||
match flush_result {
|
||||
Ok(moved) if managed_update => tracing::info!(
|
||||
moved,
|
||||
"managed-update hot-to-cold cache handoff completed"
|
||||
),
|
||||
Ok(moved) if managed_update => {
|
||||
tracing::info!(moved, "managed-update hot-to-cold cache handoff completed")
|
||||
}
|
||||
Ok(moved) if moved > 0 => tracing::info!(
|
||||
moved,
|
||||
"flushed hot cache objects to cold storage before shutdown"
|
||||
|
||||
@@ -77,6 +77,7 @@ impl App {
|
||||
})?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
|
||||
self.config.store(Arc::new(prepared));
|
||||
crate::acl::dynamic::config_changed();
|
||||
if let Some(certificate_id) = certificate_id {
|
||||
tracing::info!(
|
||||
revision,
|
||||
|
||||
@@ -37,6 +37,7 @@ impl App {
|
||||
})?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
|
||||
self.config.store(Arc::new(prepared));
|
||||
crate::acl::dynamic::config_changed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
impl App {
|
||||
pub fn start_dns_acl_updates(self: &Arc<Self>) {
|
||||
self.tunnels.spawn(crate::acl::dynamic::poll_loop(
|
||||
Arc::downgrade(self), self.shutdown.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn start_geoip_updates(self: &Arc<Self>) {
|
||||
self.tunnels.spawn(crate::geoip::update_poll_loop(
|
||||
Arc::downgrade(self),
|
||||
@@ -152,6 +158,7 @@ impl App {
|
||||
this.logs.configure(envelope.config.logging.clone());
|
||||
crate::logging::configure_error_log(envelope.config.error_log.clone());
|
||||
this.config.store(Arc::new(prepared));
|
||||
crate::acl::dynamic::config_changed();
|
||||
tracing::info!(revision, "configuration applied");
|
||||
Ok(envelope)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user