v0.32.5
This commit is contained in:
1 parent
00218a72df
commit
4a3e4bcf8f
10 files changed
+745
-331
No files matched your search
@@ -5,6 +5,7 @@
|
||||
|
||||
## 0.32.5
|
||||
|
||||
- Fix Rust `E0505` during dynamic ACL DNS resolution: give `lookup_host` an independently owned hostname, while returning the original normalized cache key. The resolver timeout, allowlist expiration and fail-closed behavior are unchanged.
|
||||
- Add bounded DNS/DDNS hostnames as **allow-only** client ACL rules in reusable
|
||||
profiles, host policies, vhosts, locations and Docker ACL assignments.
|
||||
- Refresh A/AAAA answers asynchronously every 60 seconds and after config
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
ad0382153576411fb19861072635c02bdf33de740d6f122d8f27bd9c52ce112a AGENTS.md
|
||||
91c6942b3a0d24d61bcfd43f9ee9fc8f3f5d889aeaae63dde118849d64a283c5 CHANGELOG.md
|
||||
d304d9da32d28b866176383b3b274e6ab020a54316d7e8ffe85cecd08e150f93 CHANGELOG.md
|
||||
2f1de87bae58761fc8690ec7225520e3a16213c77cbbb5981e34f12a95810668 Cargo.lock
|
||||
8c76ffaf4adba93df26e30f3dd163e0e5e201d069d1ae69c071ca42d7fc7264e Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
@@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.
|
||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||
25a06e7ddbb95d286e849b2fc5ba4adeedbddba0bad948e55bd91d962fd606de docs/SETTINGS.md
|
||||
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
|
||||
e79adbe06155b024adfe8d072abba36394b3c6ef2007920627c217e290c08a88 docs/TEST_REPORT.md
|
||||
9cf4c4e27ba11f40143f5a158f91d72b6c88a0a612a448390428d7103bbedfb3 docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
|
||||
@@ -132,8 +132,8 @@ f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_s
|
||||
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs
|
||||
a0690d0d48bb92e53f4b94ce430ac434736ec2860c03ac4d50bd94df49706458 src/acl/dynamic.rs
|
||||
2b452b2819f9af4f60c984f98b5fb1e4e79353dee4fad1add6ecb540b7be1c74 src/acl/dynamic/tests.rs
|
||||
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
|
||||
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs
|
||||
a22e5ea6381a1eacafe650be0f5cd6e653773e31f6491a562fe0459a309fb7cc src/acl/tests.rs
|
||||
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
|
||||
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs
|
||||
@@ -512,7 +512,7 @@ dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/d
|
||||
f6a6d12275bc8ba627b4616a546d075511094bb064cd9058231cc86ca489743e tools/checks/dependency_upgrade_0312.py
|
||||
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
||||
3396e5a510787e70e2ad8d1fad90356af461a88fd84429525d56fa82113da0f3 tools/checks/docs_check.py
|
||||
2d44f85ec083dedfee60dc15e65e040c6c208614411458b36a2fca8b5762f4f7 tools/checks/dynamic_acl_0325.py
|
||||
3fc765a7c2723ec783f269cfebc29ad2c09a90f33ea4af58285f883a026c4fa2 tools/checks/dynamic_acl_0325.py
|
||||
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
1b5ead59bb2ea0e1ff32bf085f791018149cb9cc29b071fae4fb98ea80010824 tools/checks/h3_navigation_0316.py
|
||||
@@ -541,7 +541,7 @@ e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/r
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
c8bdf822bfc972614b47caf3d6bfb392f1c4c96b43fc0113b963a8def73b7e7d tools/checks/security_logs_0315_ui_test.mjs
|
||||
518b6d271931df62fc61d21688399d3c9b1986b58201061ac566667a16334823 tools/checks/security_logs_0315_ui_test.mjs
|
||||
011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
|
||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
||||
bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs
|
||||
|
||||
+7
-9
@@ -59,17 +59,13 @@ impl Acl {
|
||||
ensure!(rule.network.len() <= 254, "ACL network is too long");
|
||||
let target = if let Ok(network) = parse_network(&rule.network) {
|
||||
if let IpNet::V6(net) = network {
|
||||
ensure!(
|
||||
net.addr().to_ipv4_mapped().is_none(),
|
||||
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
|
||||
);
|
||||
ensure!(net.addr().to_ipv4_mapped().is_none(),
|
||||
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules");
|
||||
}
|
||||
RuleMatch::Ip(network)
|
||||
} else {
|
||||
ensure!(
|
||||
rule.action == Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
|
||||
);
|
||||
ensure!(rule.action == Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny");
|
||||
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
|
||||
};
|
||||
rules.push((target, rule.action));
|
||||
@@ -143,7 +139,9 @@ pub fn allows_with_country(
|
||||
&& (ip_action == Some(Action::Allow)
|
||||
|| acl.is_none_or(|value| value.default == Action::Allow))
|
||||
}
|
||||
crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
|
||||
crate::rate_limit::CountryMode::Allow => {
|
||||
ip_action == Some(Action::Allow) || country_listed
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Validate the already-decoded routing representation used internally after
|
||||
|
||||
+4
-1
@@ -86,9 +86,12 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
async fn resolve(name: String) -> (String, Option<Addresses>) {
|
||||
// Keep the returned cache key owned independently of the DNS lookup future.
|
||||
// The lookup result can retain the borrowed hostname until it is dropped.
|
||||
let lookup_name = name.clone();
|
||||
let answer = tokio::time::timeout(
|
||||
Duration::from_secs(LOOKUP_SECONDS),
|
||||
tokio::net::lookup_host((name.as_str(), 0u16)),
|
||||
tokio::net::lookup_host((lookup_name.as_str(), 0u16)),
|
||||
).await;
|
||||
let mut ips = match answer {
|
||||
Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::<Vec<_>>(),
|
||||
|
||||
+692
-207
File diff suppressed because it is too large.
Load diff
+28
-97
@@ -48,58 +48,29 @@ pub fn compile_with_active(
|
||||
config.compression.validate()?;
|
||||
config.server_header.validate()?;
|
||||
let forwarding = config.forwarding.compile()?;
|
||||
ensure!(
|
||||
config.php_fpm_profiles.len() <= 128,
|
||||
"too many PHP-FPM profiles (max 128)"
|
||||
);
|
||||
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
|
||||
let mut php_profiles = HashMap::new();
|
||||
for profile in &mut config.php_fpm_profiles {
|
||||
profile
|
||||
.validate()
|
||||
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(
|
||||
php_profiles
|
||||
.insert(profile.id.clone(), profile.php.clone())
|
||||
.is_none(),
|
||||
"duplicate PHP-FPM profile id"
|
||||
);
|
||||
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
|
||||
"duplicate PHP-FPM profile id");
|
||||
}
|
||||
ensure!(
|
||||
config.web_services.len() <= 128,
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
|
||||
let mut web_services = HashMap::new();
|
||||
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
|
||||
// workers. Different directive sets on one socket can leak to the next vhost.
|
||||
// Report unsafe legacy configurations but keep them loadable for migration.
|
||||
let mut php_endpoint_policies: HashMap<
|
||||
String,
|
||||
(
|
||||
String,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
),
|
||||
> = HashMap::new();
|
||||
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
|
||||
for service in &mut config.web_services {
|
||||
service
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {}", service.id))?;
|
||||
service.validate().with_context(|| format!("Web Service {}", service.id))?;
|
||||
// A profile changes effective runtime settings, not the saved standalone
|
||||
// PHP configuration. Detaching a profile restores local settings exactly.
|
||||
let mut effective = service.clone();
|
||||
if let Some(id) = &service.php_profile_id {
|
||||
effective.php = php_profiles
|
||||
.get(id)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Web Service {} references unknown PHP-FPM profile {}",
|
||||
service.id, id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
effective
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
effective.php = php_profiles.get(id).with_context(|| {
|
||||
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
|
||||
})?.clone();
|
||||
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
}
|
||||
if effective.php.enabled {
|
||||
let php = &effective.php;
|
||||
@@ -111,25 +82,13 @@ pub fn compile_with_active(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
php_endpoint_policies.insert(
|
||||
php.endpoint.clone(),
|
||||
(
|
||||
effective.id.clone(),
|
||||
php.php_values.clone(),
|
||||
php.php_admin_values.clone(),
|
||||
),
|
||||
);
|
||||
php_endpoint_policies.insert(php.endpoint.clone(), (
|
||||
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let compiled = Arc::new(
|
||||
effective
|
||||
.compile()
|
||||
.with_context(|| format!("Web Service {}", service.id))?,
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id"
|
||||
);
|
||||
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
|
||||
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
let mut vhost_ids = HashSet::new();
|
||||
@@ -436,9 +395,7 @@ fn validate_route_rewrite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_location_redirect(
|
||||
redirect: Option<&crate::vhost::LocationRedirect>,
|
||||
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
let Some(redirect) = redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -454,50 +411,24 @@ fn validate_location_redirect(
|
||||
);
|
||||
// Replace capture references with a harmless segment so URL structure can
|
||||
// be validated before requests reach the data plane.
|
||||
let authority_end = redirect
|
||||
.target
|
||||
.find("://")
|
||||
.and_then(|start| {
|
||||
let rest = &redirect.target[start + 3..];
|
||||
rest.char_indices()
|
||||
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
|
||||
.map(|(offset, _)| start + 3 + offset)
|
||||
.or(Some(redirect.target.len()))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(
|
||||
!redirect.target[..authority_end].contains('$'),
|
||||
"location redirect captures are not allowed in the URL authority"
|
||||
);
|
||||
let authority_end = redirect.target.find("://").and_then(|start| {
|
||||
let rest = &redirect.target[start + 3..];
|
||||
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
|
||||
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
|
||||
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
|
||||
.expect("static capture regex")
|
||||
.replace_all(&redirect.target, "capture");
|
||||
let parsed =
|
||||
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(
|
||||
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
|
||||
"location redirect target must use http/https and include a host"
|
||||
);
|
||||
ensure!(
|
||||
parsed.username().is_empty() && parsed.password().is_none(),
|
||||
"location redirect target cannot contain credentials"
|
||||
);
|
||||
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
|
||||
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
|
||||
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(pattern) => {
|
||||
ensure!(
|
||||
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
|
||||
"invalid location redirect rewrite regex"
|
||||
);
|
||||
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
|
||||
let mut builder = regex::RegexBuilder::new(pattern);
|
||||
builder
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(
|
||||
builder
|
||||
.build()
|
||||
.context("invalid location redirect rewrite regex")?,
|
||||
)
|
||||
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid location redirect rewrite regex")?)
|
||||
}
|
||||
};
|
||||
Ok(Some(crate::config::CompiledLocationRedirect {
|
||||
|
||||
@@ -146,10 +146,7 @@ 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");
|
||||
@@ -436,10 +433,7 @@ 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(
|
||||
@@ -451,9 +445,10 @@ 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"
|
||||
|
||||
Regular → Executable
+1
@@ -18,6 +18,7 @@ checks = {
|
||||
]),
|
||||
'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']),
|
||||
'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl,
|
||||
'resolver owns the cache key independently of the DNS future (E0505)': 'let lookup_name = name.clone();' in dns and 'lookup_host((lookup_name.as_str(), 0u16))' in dns and 'lookup_host((name.as_str(), 0u16))' not in dns,
|
||||
'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns,
|
||||
'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']),
|
||||
'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'),
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Reference in new issue
Block a user