This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 23:42:58 +01:00
1 parent 2c03fcf248
commit d3b492874b
95 files changed
+1628 -743

No files matched your search

+74 -27
View File
@@ -37,15 +37,34 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool {
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");
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");
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");
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())
}
@@ -55,27 +74,42 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
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() {
if rule.network.parse::<ipnet::IpNet>().is_ok()
|| rule.network.parse::<IpAddr>().is_ok()
{
continue;
}
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");
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)");
ensure!(
names.len() <= MAX_NAMES,
"too many distinct dynamic DNS ACL names (max 128)"
);
}
}
Ok(())
};
inspect(config.global_security.acl.as_ref())?;
for policy in &config.host_policies { inspect(Some(&policy.acl))?; }
for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; }
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 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())?; }
for assignment in &agent.assignments {
inspect(assignment.acl.as_ref())?;
}
}
Ok(names.into_iter().collect())
}
@@ -85,7 +119,8 @@ async fn resolve(name: String) -> (String, Option<Addresses>) {
let answer = tokio::time::timeout(
Duration::from_secs(LOOKUP_SECONDS),
tokio::net::lookup_host((lookup_name.as_str(), 0u16)),
).await;
)
.await;
let mut ips = match answer {
Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::<Vec<_>>(),
_ => {
@@ -95,21 +130,29 @@ async fn resolve(name: String) -> (String, Option<Addresses>) {
};
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())
}) {
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) }))
(
name,
Some(Addresses {
ips,
valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS),
}),
)
}
pub(crate) fn config_changed() { CHANGED.notify_one(); }
pub(crate) fn config_changed() {
CHANGED.notify_one();
}
pub(crate) async fn poll_loop(
app: Weak<crate::runtime::App>,
shutdown: CancellationToken,
) {
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 {
@@ -118,9 +161,13 @@ pub(crate) async fn poll_loop(
_ = interval.tick() => {},
_ = CHANGED.notified() => {},
}
let Some(state) = app.upgrade() else { break; };
let Some(state) = app.upgrade() else {
break;
};
let snapshot = state.config.load();
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else {
continue;
};
drop(snapshot);
let results: HashMap<_, _> = tokio::select! {
_ = shutdown.cancelled() => break,
+21 -3
View File
@@ -207,13 +207,29 @@ mod tests {
routing_path("/static/js//events.js").unwrap(),
"/static/js/events.js"
);
for (path, expected) in [
("/x%2Fy", "/x/y"),
("/x%2fy", "/x/y"),
("/x%2F%2Fy", "/x/y"),
("/x//y", "/x/y"),
("/%2Ffoo", "/foo"),
] {
assert_eq!(routing_path(path).unwrap(), expected, "{path}");
}
for path in [
"/%2e%2e/secret",
"/a/%2E%2E/b",
"/a%2fb",
"/x%2F..%2Fy",
"/x%2F%2E%2E%2Fy",
"/a%5cb",
"/%5Cadmin",
"/%25admin",
"/%3Fadmin",
"/%23admin",
"/%3Badmin",
"/bad%",
"/bad%2",
"/%00",
] {
assert!(routing_path(path).is_err(), "{path}");
}
@@ -239,10 +255,12 @@ mod tests {
"/static/js/events.js"
);
assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users");
assert_eq!(routing_path("/%2fadmin").unwrap(), "/admin");
for path in [
"/api/../admin",
"/api/%2e%2e/admin",
"/%2fadmin",
"/api%2F..%2Fadmin",
"/api%2F%2E%2E%2Fadmin",
"/%5cadmin",
"/%2561dmin",
"/%00",
@@ -349,7 +367,7 @@ mod tests {
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_profiles_with_country(std::slice::from_ref(&single), peer, country),
allows_with_country(single.acl.as_ref(), &single.countries, peer, country),
);
}