v0.32.5
This commit is contained in:
1 parent
8b9ca4db44
commit
00218a72df
35 files changed
+790
-870
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user