#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
const MAX_HOSTNAME_LEN: usize = 253;
const MAX_LABEL_LEN: usize = 63;
pub fn normalize_hostname(raw: &str) -> Result<String, String> {
let trimmed = raw.trim().trim_end_matches('.');
let without_port = trimmed.split(':').next().unwrap_or(trimmed);
let host = without_port.trim().to_ascii_lowercase();
if host.is_empty() {
return Err("hostname must not be empty".to_owned());
}
if host.len() > MAX_HOSTNAME_LEN {
return Err(format!(
"hostname is {} bytes; DNS allows at most {MAX_HOSTNAME_LEN}",
host.len()
));
}
if host.contains('*') {
return Err(
"wildcard hostnames are not supported for custom domains: a tenant proves control of \
one name, not a whole zone"
.to_owned(),
);
}
if host.parse::<IpAddr>().is_ok() {
return Err("an IP address cannot be a custom domain".to_owned());
}
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return Err(format!(
"{host} is a single label; a custom domain needs at least one dot"
));
}
for label in &labels {
if label.is_empty() {
return Err(format!("{host} has an empty DNS label"));
}
if label.len() > MAX_LABEL_LEN {
return Err(format!("label {label} exceeds {MAX_LABEL_LEN} bytes"));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(format!("label {label} must not start or end with '-'"));
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Err(format!(
"label {label} may contain only letters, digits and '-' (punycode an \
internationalised name before registering it)"
));
}
}
Ok(host)
}
#[must_use]
pub fn is_apex(hostname: &str) -> bool {
let host = hostname.trim_end_matches('.').to_ascii_lowercase();
psl::domain(host.as_bytes()).is_none_or(|domain| domain.as_bytes() == host.as_bytes())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExpectedIngress {
pub hostname: Option<String>,
pub ipv4: Vec<Ipv4Addr>,
pub ipv6: Vec<Ipv6Addr>,
}
impl ExpectedIngress {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.hostname.is_none() && self.ipv4.is_empty() && self.ipv6.is_empty()
}
#[must_use]
fn cname_target(&self) -> Option<String> {
self.hostname
.as_ref()
.map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RoutingRecord {
Cname {
name: String,
value: String,
},
Address {
name: String,
ipv4: Vec<String>,
ipv6: Vec<String>,
},
}
impl RoutingRecord {
pub fn for_hostname(hostname: &str, ingress: &ExpectedIngress) -> Result<Self, String> {
let host = normalize_hostname(hostname)?;
if is_apex(&host) {
if ingress.ipv4.is_empty() && ingress.ipv6.is_empty() {
return Err(format!(
"{host} is an apex domain, which cannot carry a CNAME, but no ingress \
addresses are configured; set [server.tls.acme.custom_domains] ingress_ipv4 \
/ ingress_ipv6"
));
}
return Ok(Self::Address {
name: host,
ipv4: ingress.ipv4.iter().map(ToString::to_string).collect(),
ipv6: ingress.ipv6.iter().map(ToString::to_string).collect(),
});
}
let value = ingress.cname_target().ok_or_else(|| {
format!(
"{host} needs a CNAME target, but no ingress hostname is configured; set \
[server.tls.acme.custom_domains] ingress_hostname"
)
})?;
Ok(Self::Cname { name: host, value })
}
#[must_use]
pub fn render(&self) -> String {
match self {
Self::Cname { name, value } => format!("{name}\tCNAME\t{value}"),
Self::Address { name, ipv4, ipv6 } => {
use std::fmt::Write as _;
let mut out = String::new();
for (kind, addrs) in [("A", ipv4), ("AAAA", ipv6)] {
for addr in addrs {
let _ = writeln!(out, "{name}\t{kind}\t{addr}");
}
}
out.trim_end().to_owned()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationRecord {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsInstructions {
pub routing: RoutingRecord,
pub verification: Option<VerificationRecord>,
}
impl DnsInstructions {
pub fn for_domain(domain: &CustomDomain, ingress: &ExpectedIngress) -> Result<Self, String> {
Ok(Self {
routing: RoutingRecord::for_hostname(&domain.hostname, ingress)?,
verification: domain
.verification_token
.as_ref()
.map(|token| VerificationRecord {
name: verification_record_name(&domain.hostname),
value: token.clone(),
}),
})
}
#[must_use]
pub fn render(&self) -> String {
use std::fmt::Write as _;
let mut out = self.routing.render();
if let Some(record) = &self.verification {
let _ = write!(out, "\n{}\tTXT\t{}", record.name, record.value);
}
out
}
}
pub const VERIFICATION_LABEL: &str = "_autumn-challenge";
#[must_use]
pub fn verification_record_name(hostname: &str) -> String {
format!("{VERIFICATION_LABEL}.{hostname}")
}
fn mint_verification_token() -> io::Result<String> {
use std::fmt::Write as _;
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes)
.map_err(|e| io::Error::other(format!("the OS random source failed: {e}")))?;
let mut out = String::with_capacity(32);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObservedTarget {
Cname(String),
Addresses(Vec<IpAddr>),
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerificationOutcome {
PointsHere,
PointsElsewhere {
detail: String,
},
Unresolved,
OwnershipUnproven {
record: String,
detail: String,
},
}
impl VerificationOutcome {
#[must_use]
pub const fn is_verified(&self) -> bool {
matches!(self, Self::PointsHere)
}
#[must_use]
pub fn reason(&self) -> Option<String> {
match self {
Self::PointsHere => None,
Self::PointsElsewhere { detail } => Some(format!(
"DNS does not point at this deployment yet: {detail}"
)),
Self::Unresolved => Some(
"DNS does not resolve yet. Publish the record above; propagation can take up to \
the record's TTL."
.to_owned(),
),
Self::OwnershipUnproven { record, detail } => Some(format!(
"DNS points here, but the TXT record {record} does not carry this registration's \
token yet: {detail}. Publish the TXT record above."
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObservedTxt {
Values(Vec<String>),
Unanswered(String),
}
#[must_use]
pub fn grade_ownership(domain: &CustomDomain, observed: &ObservedTxt) -> VerificationOutcome {
let record = verification_record_name(&domain.hostname);
let unproven = |detail: String| VerificationOutcome::OwnershipUnproven {
record: record.clone(),
detail,
};
let Some(token) = domain.verification_token.as_deref() else {
return unproven("this registration has no token yet".to_owned());
};
match observed {
ObservedTxt::Values(values) if values.iter().any(|v| v.trim() == token) => {
VerificationOutcome::PointsHere
}
ObservedTxt::Values(values) if values.is_empty() => {
unproven("no TXT record is published".to_owned())
}
ObservedTxt::Values(values) => unproven(format!(
"it carries {} other value(s), possibly a token from an earlier registration",
values.len()
)),
ObservedTxt::Unanswered(error) => unproven(format!("the TXT lookup failed ({error})")),
}
}
#[must_use]
pub fn grade_dns_verification(
observed: &ObservedTarget,
expected: &ExpectedIngress,
) -> VerificationOutcome {
match observed {
ObservedTarget::None => VerificationOutcome::Unresolved,
ObservedTarget::Cname(target) => {
let target_norm = target.trim().trim_end_matches('.').to_ascii_lowercase();
if expected.cname_target().as_deref() == Some(target_norm.as_str()) {
VerificationOutcome::PointsHere
} else {
VerificationOutcome::PointsElsewhere {
detail: format!("CNAME points at {target_norm}"),
}
}
}
ObservedTarget::Addresses(addrs) if addrs.is_empty() => VerificationOutcome::Unresolved,
ObservedTarget::Addresses(_) if expected.ipv4.is_empty() && expected.ipv6.is_empty() => {
VerificationOutcome::PointsElsewhere {
detail: "this deployment's ingress addresses are unknown, so the hostname cannot \
be confirmed to point here"
.to_owned(),
}
}
ObservedTarget::Addresses(addrs) => {
let unmatched: Vec<String> = addrs
.iter()
.filter(|addr| !ingress_contains(expected, **addr))
.map(ToString::to_string)
.collect();
if unmatched.is_empty() {
VerificationOutcome::PointsHere
} else {
VerificationOutcome::PointsElsewhere {
detail: format!("resolves to {}", unmatched.join(", ")),
}
}
}
}
}
fn ingress_contains(expected: &ExpectedIngress, addr: IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => expected.ipv4.contains(&v4),
IpAddr::V6(v6) => expected.ipv6.contains(&v6),
}
}
pub async fn apply_verification(
registry: &CustomDomainRegistry,
domain: &CustomDomain,
outcome: &VerificationOutcome,
now_unix: i64,
backoff_secs: i64,
) -> io::Result<bool> {
if outcome.is_verified() {
registry
.record_verified_for(
&domain.hostname,
&domain.tenant,
domain.verification_token.as_deref(),
now_unix,
)
.await
} else {
let reason = outcome
.reason()
.unwrap_or_else(|| "verification failed".to_owned());
let token = domain.verification_token.clone();
registry
.fail(
&domain.hostname,
|d| d.tenant == domain.tenant && d.verification_token == token,
now_unix,
reason,
backoff_secs,
)
.await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainStatus {
PendingDns,
Verified,
Issuing,
Active,
}
impl DomainStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PendingDns => "pending_dns",
Self::Verified => "verified",
Self::Issuing => "issuing",
Self::Active => "active",
}
}
}
impl std::fmt::Display for DomainStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomDomain {
pub hostname: String,
pub tenant: String,
pub status: DomainStatus,
pub failure_reason: Option<String>,
pub registered_at_unix: i64,
pub verified_at_unix: Option<i64>,
pub activated_at_unix: Option<i64>,
pub cert_not_after_unix: Option<i64>,
pub consecutive_failures: u32,
pub next_attempt_unix: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_token: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issuance_attempts_unix: Vec<i64>,
}
impl CustomDomain {
#[must_use]
const fn new(hostname: String, tenant: String, now_unix: i64, token: String) -> Self {
Self {
hostname,
tenant,
status: DomainStatus::PendingDns,
failure_reason: None,
registered_at_unix: now_unix,
verified_at_unix: None,
activated_at_unix: None,
cert_not_after_unix: None,
consecutive_failures: 0,
next_attempt_unix: None,
verification_token: Some(token),
issuance_attempts_unix: Vec::new(),
}
}
#[must_use]
pub fn verification_record_name(&self) -> String {
verification_record_name(&self.hostname)
}
#[must_use]
pub fn needs_verification_token(&self) -> bool {
self.verification_token.is_none() && self.status != DomainStatus::Active
}
#[must_use]
pub fn is_due(&self, now_unix: i64) -> bool {
self.next_attempt_unix.is_none_or(|at| now_unix >= at)
}
#[must_use]
pub const fn is_servable(&self) -> bool {
matches!(self.status, DomainStatus::Active)
}
}
pub trait CustomDomainStore: Send + Sync + std::fmt::Debug {
fn load_all(&self) -> StoreFuture<'_, io::Result<Vec<CustomDomain>>>;
fn save<'a>(&'a self, domain: &'a CustomDomain) -> StoreFuture<'a, io::Result<()>>;
fn delete<'a>(&'a self, hostname: &'a str) -> StoreFuture<'a, io::Result<()>>;
fn load_attempts(&self) -> StoreFuture<'_, io::Result<Vec<i64>>> {
Box::pin(async { Ok(Vec::new()) })
}
fn append_attempt(&self, at_unix: i64) -> StoreFuture<'_, io::Result<()>> {
let _ = at_unix;
Box::pin(async { Ok(()) })
}
fn merge_attempts(&self, attempts: Vec<i64>, now_unix: i64) -> StoreFuture<'_, io::Result<()>> {
let _ = (attempts, now_unix);
Box::pin(async { Ok(()) })
}
}
fn prune_ledger(ledger: &mut Vec<i64>, now_unix: i64) {
let cutoff = now_unix.saturating_sub(GLOBAL_WINDOW_SECS);
ledger.retain(|at| *at > cutoff && *at <= now_unix);
}
fn merge_into_ledger(ledger: &mut Vec<i64>, extra: &[i64], now_unix: i64) -> bool {
let before = ledger.len();
prune_ledger(ledger, now_unix);
let mut changed = ledger.len() != before;
let mut want: HashMap<i64, usize> = HashMap::new();
for at in extra {
*want.entry(*at).or_default() += 1;
}
let mut have: HashMap<i64, usize> = HashMap::new();
for at in ledger.iter() {
*have.entry(*at).or_default() += 1;
}
for (at, n) in want {
let missing = n.saturating_sub(have.get(&at).copied().unwrap_or(0));
if missing > 0 && at > now_unix.saturating_sub(GLOBAL_WINDOW_SECS) && at <= now_unix {
ledger.extend(std::iter::repeat_n(at, missing));
changed = true;
}
}
changed
}
const LEDGER_FILE: &str = "attempts.ledger";
#[derive(Debug, Default)]
pub struct MemoryCustomDomainStore {
records: RwLock<HashMap<String, CustomDomain>>,
ledger: RwLock<Vec<i64>>,
}
impl MemoryCustomDomainStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub fn load_all_blocking(&self) -> Vec<CustomDomain> {
read_lock(&self.records).values().cloned().collect()
}
}
impl CustomDomainStore for MemoryCustomDomainStore {
fn load_all(&self) -> StoreFuture<'_, io::Result<Vec<CustomDomain>>> {
Box::pin(async move { Ok(read_lock(&self.records).values().cloned().collect()) })
}
fn save<'a>(&'a self, domain: &'a CustomDomain) -> StoreFuture<'a, io::Result<()>> {
Box::pin(async move {
write_lock(&self.records).insert(domain.hostname.clone(), domain.clone());
Ok(())
})
}
fn delete<'a>(&'a self, hostname: &'a str) -> StoreFuture<'a, io::Result<()>> {
Box::pin(async move {
write_lock(&self.records).remove(hostname);
Ok(())
})
}
fn load_attempts(&self) -> StoreFuture<'_, io::Result<Vec<i64>>> {
Box::pin(async move { Ok(read_lock(&self.ledger).clone()) })
}
fn merge_attempts(&self, attempts: Vec<i64>, now_unix: i64) -> StoreFuture<'_, io::Result<()>> {
Box::pin(async move {
merge_into_ledger(&mut write_lock(&self.ledger), &attempts, now_unix);
Ok(())
})
}
fn append_attempt(&self, at_unix: i64) -> StoreFuture<'_, io::Result<()>> {
Box::pin(async move {
let mut ledger = write_lock(&self.ledger);
prune_ledger(&mut ledger, at_unix);
ledger.push(at_unix);
drop(ledger);
Ok(())
})
}
}
#[derive(Debug, Clone)]
pub struct FsCustomDomainStore {
dir: PathBuf,
ledger_lock: Arc<tokio::sync::Mutex<()>>,
}
impl FsCustomDomainStore {
#[must_use]
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
ledger_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
fn path_for(&self, hostname: &str) -> PathBuf {
self.dir.join(format!("{}.json", file_stem(hostname)))
}
}
fn file_stem(hostname: &str) -> String {
use sha2::{Digest as _, Sha256};
let digest = Sha256::digest(hostname.as_bytes());
let mut out = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
impl CustomDomainStore for FsCustomDomainStore {
fn load_all(&self) -> StoreFuture<'_, io::Result<Vec<CustomDomain>>> {
Box::pin(async move {
let mut entries = match tokio::fs::read_dir(&self.dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let bytes = tokio::fs::read(&path).await?;
match serde_json::from_slice::<CustomDomain>(&bytes) {
Ok(domain) => out.push(domain),
Err(e) => tracing::warn!(
path = %path.display(),
"skipping unreadable custom-domain record: {e}"
),
}
}
Ok(out)
})
}
fn save<'a>(&'a self, domain: &'a CustomDomain) -> StoreFuture<'a, io::Result<()>> {
let dir = self.dir.clone();
let path = self.path_for(&domain.hostname);
Box::pin(async move {
let bytes = serde_json::to_vec_pretty(domain)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
crate::fs_atomic::blocking(move || {
crate::fs_atomic::ensure_owner_only_dir(&dir)?;
crate::fs_atomic::write_owner_only(&path, &bytes)
})
.await
})
}
fn delete<'a>(&'a self, hostname: &'a str) -> StoreFuture<'a, io::Result<()>> {
Box::pin(async move {
match tokio::fs::remove_file(self.path_for(hostname)).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
})
}
fn load_attempts(&self) -> StoreFuture<'_, io::Result<Vec<i64>>> {
Box::pin(async move { read_ledger(&self.dir.join(LEDGER_FILE)).await })
}
fn append_attempt(&self, at_unix: i64) -> StoreFuture<'_, io::Result<()>> {
self.update_ledger(move |ledger| {
prune_ledger(ledger, at_unix);
ledger.push(at_unix);
true
})
}
fn merge_attempts(&self, attempts: Vec<i64>, now_unix: i64) -> StoreFuture<'_, io::Result<()>> {
self.update_ledger(move |ledger| merge_into_ledger(ledger, &attempts, now_unix))
}
}
impl FsCustomDomainStore {
fn update_ledger(
&self,
change: impl FnOnce(&mut Vec<i64>) -> bool + Send + 'static,
) -> StoreFuture<'_, io::Result<()>> {
let dir = self.dir.clone();
let path = self.dir.join(LEDGER_FILE);
let lock = Arc::clone(&self.ledger_lock);
Box::pin(async move {
let _guard = lock.lock().await;
let (mut ledger, corrupt) = read_ledger_checked(&path).await?;
if corrupt {
let _ = tokio::fs::rename(&path, path.with_extension("ledger.corrupt")).await;
}
if !change(&mut ledger) {
return Ok(());
}
let bytes = serde_json::to_vec(&ledger)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
crate::fs_atomic::blocking(move || {
crate::fs_atomic::ensure_owner_only_dir(&dir)?;
crate::fs_atomic::write_owner_only(&path, &bytes)
})
.await
})
}
}
async fn read_ledger(path: &std::path::Path) -> io::Result<Vec<i64>> {
let (ledger, corrupt) = read_ledger_checked(path).await?;
if corrupt {
tracing::warn!(path = %path.display(), "ignoring an unreadable attempt ledger");
}
Ok(ledger)
}
async fn read_ledger_checked(path: &std::path::Path) -> io::Result<(Vec<i64>, bool)> {
match tokio::fs::read(path).await {
Ok(bytes) => Ok(serde_json::from_slice(&bytes).map_or((Vec::new(), true), |l| (l, false))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok((Vec::new(), false)),
Err(e) => Err(e),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegisterError {
Invalid(String),
LimitReached {
max: usize,
},
Conflict {
tenant: String,
},
Reserved {
pattern: String,
},
Store(String),
NotReady,
}
impl std::fmt::Display for RegisterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(msg) => write!(f, "invalid custom domain: {msg}"),
Self::LimitReached { max } => write!(
f,
"custom-domain limit reached ({max}); raise \
[server.tls.acme.custom_domains] max_domains"
),
Self::Conflict { tenant } => {
write!(f, "hostname is already connected to tenant {tenant}")
}
Self::Reserved { pattern } => write!(
f,
"hostname is reserved by this deployment ({pattern}); a tenant cannot connect a \
name the operator already serves"
),
Self::Store(msg) => write!(f, "failed to persist the custom domain: {msg}"),
Self::NotReady => write!(
f,
"the custom-domain registry has not loaded, so a hostname cannot be connected \
without risking another tenant's record; check the server log for the load error"
),
}
}
}
impl std::error::Error for RegisterError {}
#[derive(Debug)]
pub struct CustomDomainRegistry {
store: Arc<dyn CustomDomainStore>,
index: RwLock<HashMap<String, CustomDomain>>,
writes: tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
max_domains: usize,
reserved: Vec<String>,
hydrated: std::sync::atomic::AtomicBool,
}
impl CustomDomainRegistry {
#[must_use]
pub fn new(store: Arc<dyn CustomDomainStore>, max_domains: usize) -> Self {
Self {
store,
index: RwLock::new(HashMap::new()),
writes: tokio::sync::Mutex::new(HashMap::new()),
max_domains,
reserved: Vec::new(),
hydrated: std::sync::atomic::AtomicBool::new(false),
}
}
#[must_use]
pub fn with_reserved(mut self, patterns: impl IntoIterator<Item = String>) -> Self {
self.reserved = patterns
.into_iter()
.map(|p| p.trim().trim_end_matches('.').to_ascii_lowercase())
.filter(|p| !p.is_empty())
.collect();
self
}
fn reserved_match(&self, host: &str) -> Option<String> {
self.reserved
.iter()
.find(|pattern| {
pattern.strip_prefix("*.").map_or_else(
|| host == *pattern || host.ends_with(&format!(".{pattern}")),
|suffix| host == suffix || host.ends_with(&format!(".{suffix}")),
)
})
.cloned()
}
#[must_use]
pub fn from_state(state: &crate::AppState) -> Option<Arc<Self>> {
state
.extension::<Arc<Self>>()
.map(|outer| Arc::clone(&*outer))
}
#[must_use]
pub fn is_hydrated(&self) -> bool {
self.hydrated.load(std::sync::atomic::Ordering::Acquire)
}
fn hydrate_index(&self, records: Vec<CustomDomain>) -> (usize, Vec<String>) {
let mut index = write_lock(&self.index);
index.clear();
for record in records {
if let Some(pattern) = self.reserved_match(&record.hostname) {
tracing::error!(
hostname = %record.hostname,
tenant = %record.tenant,
pattern = %pattern,
"refusing to load a custom domain the configuration now reserves; offboard it \
or drop the reservation"
);
continue;
}
index.insert(record.hostname.clone(), record);
}
let legacy: Vec<String> = index
.values()
.filter(|d| d.needs_verification_token())
.map(|d| d.hostname.clone())
.collect();
(index.len(), legacy)
}
pub async fn load(&self) -> io::Result<usize> {
let records = self.store.load_all().await?;
let (loaded, legacy) = self.hydrate_index(records);
self.hydrated
.store(true, std::sync::atomic::Ordering::Release);
let now = now_unix();
for hostname in legacy {
if let Err(e) = self.adopt_verification_token(&hostname, now).await {
tracing::warn!(
hostname = %hostname,
"could not give a stored custom domain its verification token: {e}"
);
}
}
Ok(loaded)
}
pub async fn load_without_migration(&self) -> io::Result<usize> {
let records = self.store.load_all().await?;
let (loaded, _legacy) = self.hydrate_index(records);
self.hydrated
.store(true, std::sync::atomic::Ordering::Release);
Ok(loaded)
}
pub async fn stored_records(&self) -> io::Result<Vec<CustomDomain>> {
self.store.load_all().await
}
pub async fn load_global_attempts(&self) -> io::Result<Vec<i64>> {
self.store.load_attempts().await
}
pub async fn merge_global_attempts(&self, attempts: Vec<i64>, now_unix: i64) -> io::Result<()> {
self.store.merge_attempts(attempts, now_unix).await
}
pub async fn record_global_attempt(&self, now_unix: i64) -> io::Result<()> {
self.store.append_attempt(now_unix).await
}
pub async fn adopt_verification_token(
&self,
hostname: &str,
now_unix: i64,
) -> io::Result<bool> {
let token = mint_verification_token()?;
self.mutate_if(hostname, CustomDomain::needs_verification_token, move |d| {
d.verification_token = Some(token);
d.status = DomainStatus::PendingDns;
d.registered_at_unix = now_unix;
d.failure_reason = Some(format!(
"ownership proof is now required: publish the {VERIFICATION_LABEL} TXT record \
shown in the DNS instructions"
));
d.consecutive_failures = 0;
d.next_attempt_unix = None;
})
.await
}
pub async fn register(
&self,
hostname: &str,
tenant: &str,
now_unix: i64,
) -> Result<CustomDomain, RegisterError> {
let host = normalize_hostname(hostname).map_err(RegisterError::Invalid)?;
if tenant.trim().is_empty() {
return Err(RegisterError::Invalid(
"tenant id must not be empty".to_owned(),
));
}
if let Some(pattern) = self.reserved_match(&host) {
return Err(RegisterError::Reserved { pattern });
}
if verification_record_name(&host).len() > MAX_HOSTNAME_LEN {
return Err(RegisterError::Invalid(format!(
"{host} is too long to carry its {VERIFICATION_LABEL} TXT record"
)));
}
if !self.is_hydrated() {
return Err(RegisterError::NotReady);
}
let full = {
let index = read_lock(&self.index);
index.len() >= self.max_domains && !index.contains_key(&host)
};
if full {
return Err(RegisterError::LimitReached {
max: self.max_domains,
});
}
let gate = self.write_gate(&host).await;
let _write = gate.lock().await;
let record = {
let mut index = write_lock(&self.index);
if let Some(existing) = index.get(&host) {
if existing.tenant == tenant {
return Ok(existing.clone());
}
return Err(RegisterError::Conflict {
tenant: existing.tenant.clone(),
});
}
if index.len() >= self.max_domains {
return Err(RegisterError::LimitReached {
max: self.max_domains,
});
}
let token = mint_verification_token().map_err(|e| {
RegisterError::Store(format!("could not mint a verification token: {e}"))
})?;
let record = CustomDomain::new(host.clone(), tenant.to_owned(), now_unix, token);
index.insert(host.clone(), record.clone());
record
};
if let Err(e) = self.store.save(&record).await {
write_lock(&self.index).remove(&host);
return Err(RegisterError::Store(e.to_string()));
}
Ok(record)
}
async fn write_gate(&self, host: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut gates = self.writes.lock().await;
if gates.len() >= self.gate_high_water() && !gates.contains_key(host) {
gates.retain(|_, gate| Arc::strong_count(gate) > 1);
}
Arc::clone(gates.entry(host.to_owned()).or_default())
}
const fn gate_high_water(&self) -> usize {
self.max_domains.saturating_add(64)
}
#[must_use]
pub fn get(&self, hostname: &str) -> Option<CustomDomain> {
let host = normalize_hostname(hostname).ok()?;
read_lock(&self.index).get(&host).cloned()
}
#[must_use]
pub fn tenant_for_host(&self, hostname: &str) -> Option<String> {
let host = normalize_hostname(hostname).ok()?;
read_lock(&self.index)
.get(&host)
.filter(|d| d.is_servable())
.map(|d| d.tenant.clone())
}
#[must_use]
pub fn is_servable(&self, hostname: &str) -> bool {
normalize_hostname(hostname).is_ok_and(|host| {
read_lock(&self.index)
.get(&host)
.is_some_and(CustomDomain::is_servable)
})
}
#[must_use]
pub fn list(&self) -> Vec<CustomDomain> {
let mut all: Vec<CustomDomain> = read_lock(&self.index).values().cloned().collect();
all.sort_by(|a, b| a.hostname.cmp(&b.hostname));
all
}
#[must_use]
pub fn list_for_tenant(&self, tenant: &str) -> Vec<CustomDomain> {
let mut all: Vec<CustomDomain> = read_lock(&self.index)
.values()
.filter(|d| d.tenant == tenant)
.cloned()
.collect();
all.sort_by(|a, b| a.hostname.cmp(&b.hostname));
all
}
#[must_use]
pub fn len(&self) -> usize {
read_lock(&self.index).len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn pending_verification(&self, now_unix: i64) -> Vec<CustomDomain> {
self.filter(|d| {
(d.status == DomainStatus::PendingDns || d.needs_verification_token())
&& d.is_due(now_unix)
})
}
#[must_use]
pub fn due_for_issuance(&self, now_unix: i64) -> Vec<CustomDomain> {
self.filter(|d| {
matches!(d.status, DomainStatus::Verified | DomainStatus::Issuing)
&& d.verification_token.is_some()
&& d.is_due(now_unix)
})
}
#[must_use]
pub fn due_for_renewal(&self, now_unix: i64, renew_before_days: u32) -> Vec<CustomDomain> {
self.filter(|d| {
d.status == DomainStatus::Active
&& d.is_due(now_unix)
&& d.cert_not_after_unix
.is_some_and(|not_after| needs_renewal(not_after, renew_before_days, now_unix))
})
}
fn filter(&self, predicate: impl Fn(&CustomDomain) -> bool) -> Vec<CustomDomain> {
let mut out: Vec<CustomDomain> = read_lock(&self.index)
.values()
.filter(|d| predicate(d))
.cloned()
.collect();
out.sort_by(|a, b| a.hostname.cmp(&b.hostname));
out
}
pub async fn record_verified(&self, hostname: &str, now_unix: i64) -> io::Result<()> {
self.mutate(hostname, |d| apply_verified_mutation(d, now_unix))
.await
}
pub async fn record_verified_for(
&self,
hostname: &str,
tenant: &str,
token: Option<&str>,
now_unix: i64,
) -> io::Result<bool> {
let Some(token) = token else {
return Ok(false);
};
self.mutate_if(
hostname,
|d| d.tenant == tenant && d.verification_token.as_deref() == Some(token),
|d| apply_verified_mutation(d, now_unix),
)
.await
}
pub async fn record_issuing(&self, hostname: &str) -> io::Result<()> {
self.mutate(hostname, |d| {
if d.status == DomainStatus::Verified {
d.status = DomainStatus::Issuing;
}
})
.await
}
pub async fn record_issuing_for(&self, hostname: &str, tenant: &str) -> io::Result<bool> {
self.mutate_if(
hostname,
|d| d.tenant == tenant && Self::is_orderable_state(d.status),
|d| {
if d.status == DomainStatus::Verified {
d.status = DomainStatus::Issuing;
}
},
)
.await
}
pub async fn record_active(
&self,
hostname: &str,
now_unix: i64,
cert_not_after_unix: i64,
) -> io::Result<()> {
self.activate(hostname, None, now_unix, cert_not_after_unix)
.await
.map(|_| ())
}
pub async fn record_active_for(
&self,
hostname: &str,
tenant: &str,
now_unix: i64,
cert_not_after_unix: i64,
) -> io::Result<bool> {
self.activate(hostname, Some(tenant), now_unix, cert_not_after_unix)
.await
}
const fn is_orderable_state(status: DomainStatus) -> bool {
!matches!(status, DomainStatus::PendingDns)
}
async fn activate(
&self,
hostname: &str,
tenant: Option<&str>,
now_unix: i64,
cert_not_after_unix: i64,
) -> io::Result<bool> {
self.mutate_if(
hostname,
|d| tenant.is_none_or(|t| d.tenant == t && Self::is_orderable_state(d.status)),
|d| {
d.status = DomainStatus::Active;
if d.verified_at_unix.is_none() {
d.verified_at_unix = Some(now_unix);
}
if d.activated_at_unix.is_none() {
d.activated_at_unix = Some(now_unix);
}
d.cert_not_after_unix = Some(cert_not_after_unix);
d.failure_reason = None;
d.consecutive_failures = 0;
d.next_attempt_unix = None;
},
)
.await
}
pub async fn record_failure(
&self,
hostname: &str,
now_unix: i64,
reason: impl Into<String>,
backoff_secs: i64,
) -> io::Result<()> {
self.fail(hostname, |_| true, now_unix, reason, backoff_secs)
.await
.map(|_| ())
}
pub async fn record_failure_for(
&self,
hostname: &str,
tenant: &str,
now_unix: i64,
reason: impl Into<String>,
backoff_secs: i64,
) -> io::Result<bool> {
self.fail(
hostname,
|d| d.tenant == tenant && Self::is_orderable_state(d.status),
now_unix,
reason,
backoff_secs,
)
.await
}
pub async fn record_failure_for_registration(
&self,
hostname: &str,
tenant: &str,
token: Option<&str>,
now_unix: i64,
reason: impl Into<String>,
backoff_secs: i64,
) -> io::Result<bool> {
self.fail(
hostname,
|d| {
d.tenant == tenant
&& d.verification_token.as_deref() == token
&& Self::is_orderable_state(d.status)
},
now_unix,
reason,
backoff_secs,
)
.await
}
pub async fn record_attempt_for_registration(
&self,
hostname: &str,
tenant: &str,
token: Option<&str>,
now_unix: i64,
) -> io::Result<bool> {
self.mutate_if(
hostname,
|d| d.tenant == tenant && d.verification_token.as_deref() == token,
move |d| {
let cutoff = now_unix.saturating_sub(PER_DOMAIN_WINDOW_SECS);
d.issuance_attempts_unix.retain(|at| *at > cutoff);
d.issuance_attempts_unix.push(now_unix);
},
)
.await
}
pub async fn defer_for_registration(
&self,
hostname: &str,
tenant: &str,
token: Option<&str>,
retry_at_unix: i64,
reason: impl Into<String>,
) -> io::Result<bool> {
let reason = reason.into();
self.mutate_if(
hostname,
|d| {
d.tenant == tenant
&& d.verification_token.as_deref() == token
&& Self::is_orderable_state(d.status)
},
move |d| {
if d.status == DomainStatus::Issuing {
d.status = DomainStatus::Verified;
}
d.failure_reason = Some(reason.clone());
d.next_attempt_unix = Some(
d.next_attempt_unix
.map_or(retry_at_unix, |at| at.max(retry_at_unix)),
);
},
)
.await
}
async fn fail(
&self,
hostname: &str,
guard: impl FnOnce(&CustomDomain) -> bool,
now_unix: i64,
reason: impl Into<String>,
backoff_secs: i64,
) -> io::Result<bool> {
let reason = reason.into();
self.mutate_if(hostname, guard, move |d| {
if d.status == DomainStatus::Issuing {
d.status = DomainStatus::Verified;
}
d.failure_reason = Some(reason.clone());
d.consecutive_failures = d.consecutive_failures.saturating_add(1);
d.next_attempt_unix = Some(now_unix.saturating_add(backoff_secs));
})
.await
}
pub async fn remove(&self, hostname: &str) -> io::Result<bool> {
self.remove_if(hostname, |_| true).await
}
pub async fn remove_if(
&self,
hostname: &str,
guard: impl FnOnce(&CustomDomain) -> bool,
) -> io::Result<bool> {
let Ok(host) = normalize_hostname(hostname) else {
return Ok(false);
};
let gate = self.write_gate(&host).await;
let _write = gate.lock().await;
if !read_lock(&self.index).get(&host).is_none_or(guard) {
return Ok(false);
}
self.store.delete(&host).await?;
Ok(write_lock(&self.index).remove(&host).is_some())
}
pub async fn remove_tenant(&self, tenant: &str) -> io::Result<usize> {
let hostnames: Vec<String> = self
.list_for_tenant(tenant)
.into_iter()
.map(|d| d.hostname)
.collect();
let mut removed = 0;
for hostname in hostnames {
if self
.remove_if(&hostname, |current| current.tenant == tenant)
.await?
{
removed += 1;
}
}
Ok(removed)
}
#[must_use]
pub fn health_report(&self, now_unix: i64) -> String {
let mut lines: Vec<String> = Vec::new();
for domain in self.list() {
let expiring = domain.cert_not_after_unix.is_some_and(|na| na <= now_unix);
if let Some(reason) = &domain.failure_reason {
lines.push(format!(
"{} (tenant {}, {}): {reason}",
domain.hostname, domain.tenant, domain.status
));
} else if expiring {
lines.push(format!(
"{} (tenant {}): certificate expired",
domain.hostname, domain.tenant
));
}
}
lines.join("; ")
}
async fn mutate(&self, hostname: &str, f: impl FnOnce(&mut CustomDomain)) -> io::Result<()> {
self.mutate_if(hostname, |_| true, f).await.map(|_| ())
}
async fn mutate_if(
&self,
hostname: &str,
guard: impl FnOnce(&CustomDomain) -> bool,
f: impl FnOnce(&mut CustomDomain),
) -> io::Result<bool> {
let Ok(host) = normalize_hostname(hostname) else {
return Ok(false);
};
let gate = self.write_gate(&host).await;
let _write = gate.lock().await;
let Some(current) = read_lock(&self.index).get(&host).cloned() else {
return Ok(false);
};
if !guard(¤t) {
return Ok(false);
}
let mut updated = current;
f(&mut updated);
self.store.save(&updated).await?;
let mut index = write_lock(&self.index);
Ok(index.get_mut(&host).is_some_and(|slot| {
*slot = updated;
true
}))
}
}
fn apply_verified_mutation(d: &mut CustomDomain, now_unix: i64) {
if d.status == DomainStatus::PendingDns {
d.status = DomainStatus::Verified;
}
if d.verified_at_unix.is_none() {
d.verified_at_unix = Some(now_unix);
}
d.failure_reason = None;
d.consecutive_failures = 0;
d.next_attempt_unix = None;
}
#[must_use]
pub const fn needs_renewal(not_after_unix: i64, renew_before_days: u32, now_unix: i64) -> bool {
let window = (renew_before_days as i64).saturating_mul(86_400);
not_after_unix.saturating_sub(now_unix) < window
}
#[must_use]
pub const fn backoff_secs(consecutive_failures: u32, base: u64, max: u64) -> u64 {
if consecutive_failures == 0 {
return 0;
}
let shift = if consecutive_failures > 32 {
32
} else {
consecutive_failures - 1
};
let scaled = base.saturating_mul(1_u64 << shift);
if scaled > max { max } else { scaled }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssuanceDecision {
Allow,
PerDomainLimit {
retry_after_secs: i64,
},
GlobalLimit {
retry_after_secs: i64,
},
}
impl IssuanceDecision {
#[must_use]
pub const fn is_allowed(&self) -> bool {
matches!(self, Self::Allow)
}
#[must_use]
pub const fn retry_after_secs(&self) -> Option<i64> {
match self {
Self::Allow => None,
Self::PerDomainLimit { retry_after_secs } | Self::GlobalLimit { retry_after_secs } => {
Some(*retry_after_secs)
}
}
}
#[must_use]
pub fn reason(&self) -> Option<String> {
match self {
Self::Allow => None,
Self::PerDomainLimit { retry_after_secs } => Some(format!(
"issuance budget for this domain is spent; retrying in {retry_after_secs}s"
)),
Self::GlobalLimit { retry_after_secs } => Some(format!(
"deployment-wide issuance budget is spent; retrying in {retry_after_secs}s"
)),
}
}
}
const PER_DOMAIN_WINDOW_SECS: i64 = 86_400;
const GLOBAL_WINDOW_SECS: i64 = 3_600;
#[derive(Debug)]
pub struct IssuanceLimiter {
per_domain_per_day: u32,
global_per_hour: u32,
base_backoff_secs: u64,
max_backoff_secs: u64,
attempts: RwLock<Attempts>,
}
#[derive(Debug, Default)]
struct Attempts {
per_domain: HashMap<String, Vec<i64>>,
global: Vec<i64>,
}
impl IssuanceLimiter {
#[must_use]
pub fn new(
per_domain_per_day: u32,
global_per_hour: u32,
base_backoff_secs: u64,
max_backoff_secs: u64,
) -> Self {
Self {
per_domain_per_day,
global_per_hour,
base_backoff_secs,
max_backoff_secs,
attempts: RwLock::new(Attempts::default()),
}
}
#[must_use]
pub const fn max_backoff(&self) -> u64 {
self.max_backoff_secs
}
#[must_use]
pub const fn backoff_for(&self, consecutive_failures: u32) -> u64 {
backoff_secs(
consecutive_failures,
self.base_backoff_secs,
self.max_backoff_secs,
)
}
#[must_use]
pub fn check(&self, hostname: &str, now_unix: i64) -> IssuanceDecision {
let (domain_hits, global_hits) = {
let attempts = read_lock(&self.attempts);
let domain_hits = attempts
.per_domain
.get(hostname)
.map(|hits| within(hits, now_unix, PER_DOMAIN_WINDOW_SECS))
.unwrap_or_default();
let global_hits = within(&attempts.global, now_unix, GLOBAL_WINDOW_SECS);
drop(attempts);
(domain_hits, global_hits)
};
if domain_hits.len() >= self.per_domain_per_day as usize {
return IssuanceDecision::PerDomainLimit {
retry_after_secs: retry_after(&domain_hits, now_unix, PER_DOMAIN_WINDOW_SECS),
};
}
if global_hits.len() >= self.global_per_hour as usize {
return IssuanceDecision::GlobalLimit {
retry_after_secs: retry_after(&global_hits, now_unix, GLOBAL_WINDOW_SECS),
};
}
IssuanceDecision::Allow
}
pub fn record_attempt(&self, hostname: &str, now_unix: i64) {
let mut attempts = write_lock(&self.attempts);
let global = within(&attempts.global, now_unix, GLOBAL_WINDOW_SECS);
attempts.global = global;
attempts.global.push(now_unix);
let hits = attempts.per_domain.entry(hostname.to_owned()).or_default();
let mut kept = within(hits, now_unix, PER_DOMAIN_WINDOW_SECS);
kept.push(now_unix);
*hits = kept;
drop(attempts);
}
pub fn hydrate(&self, attempts: impl IntoIterator<Item = (String, i64)>, now_unix: i64) {
let oldest = now_unix.saturating_sub(PER_DOMAIN_WINDOW_SECS);
let mut persisted: HashMap<(String, i64), usize> = HashMap::new();
for (hostname, at) in attempts {
if at > oldest {
*persisted.entry((hostname, at)).or_default() += 1;
}
}
let mut state = write_lock(&self.attempts);
for ((hostname, at), want) in persisted {
let hits = state.per_domain.entry(hostname).or_default();
let have = hits.iter().filter(|known| **known == at).count();
let missing = want.saturating_sub(have);
hits.extend(std::iter::repeat_n(at, missing));
state.global.extend(std::iter::repeat_n(at, missing));
}
drop(state);
}
pub fn hydrate_global(&self, attempts: impl IntoIterator<Item = i64>, now_unix: i64) {
let oldest = now_unix.saturating_sub(GLOBAL_WINDOW_SECS);
let mut persisted: HashMap<i64, usize> = HashMap::new();
for at in attempts {
if at > oldest && at <= now_unix {
*persisted.entry(at).or_default() += 1;
}
}
let mut state = write_lock(&self.attempts);
let mut have: HashMap<i64, usize> = HashMap::new();
for at in &state.global {
*have.entry(*at).or_default() += 1;
}
for (at, want) in persisted {
let known = have.get(&at).copied().unwrap_or(0);
state
.global
.extend(std::iter::repeat_n(at, want.saturating_sub(known)));
}
drop(state);
}
pub fn forget(&self, hostname: &str) {
write_lock(&self.attempts).per_domain.remove(hostname);
}
}
fn within(hits: &[i64], now_unix: i64, window: i64) -> Vec<i64> {
let cutoff = now_unix.saturating_sub(window);
hits.iter().copied().filter(|at| *at > cutoff).collect()
}
fn retry_after(hits: &[i64], now_unix: i64, window: i64) -> i64 {
hits.iter().min().map_or(0, |oldest| {
(oldest.saturating_add(window) - now_unix).max(1)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssuedCertificate {
pub chain_pem: String,
pub key_pem: String,
}
pub trait DomainIssuer: Send + Sync {
fn issue<'a>(
&'a self,
hostname: &'a str,
) -> futures::future::BoxFuture<'a, Result<IssuedCertificate, String>>;
}
pub trait DomainVerifier: Send + Sync {
fn observe<'a>(&'a self, hostname: &'a str) -> futures::future::BoxFuture<'a, ObservedTarget>;
fn observe_txt<'a>(&'a self, name: &'a str) -> futures::future::BoxFuture<'a, ObservedTxt>;
}
#[derive(Debug, Default, Clone)]
pub struct SystemDomainVerifier {
txt_resolvers: Vec<std::net::SocketAddr>,
}
impl SystemDomainVerifier {
#[must_use]
pub const fn new(txt_resolvers: Vec<std::net::SocketAddr>) -> Self {
Self { txt_resolvers }
}
}
impl DomainVerifier for SystemDomainVerifier {
fn observe<'a>(&'a self, hostname: &'a str) -> futures::future::BoxFuture<'a, ObservedTarget> {
Box::pin(async move {
let host = hostname.to_owned();
let resolved = crate::time::spawn_blocking(move || {
use std::net::ToSocketAddrs as _;
(host.as_str(), 0_u16)
.to_socket_addrs()
.map(|addrs| addrs.map(|s| s.ip()).collect::<Vec<_>>())
})
.await;
match resolved {
Ok(Ok(addrs)) if !addrs.is_empty() => ObservedTarget::Addresses(addrs),
_ => ObservedTarget::None,
}
})
}
fn observe_txt<'a>(&'a self, name: &'a str) -> futures::future::BoxFuture<'a, ObservedTxt> {
Box::pin(async move {
if self.txt_resolvers.is_empty() {
return ObservedTxt::Unanswered("no TXT resolvers are configured".to_owned());
}
#[cfg(feature = "acme")]
{
use crate::acme::dns::resolver::{UdpDnsLookup, txt_values};
let lookup = UdpDnsLookup::new(std::time::Duration::from_secs(3));
let deadline = std::time::Duration::from_secs(6);
match txt_values(name, &self.txt_resolvers, &lookup, deadline).await {
Ok(values) => ObservedTxt::Values(values),
Err(e) => ObservedTxt::Unanswered(e),
}
}
#[cfg(not(feature = "acme"))]
{
let _ = name;
ObservedTxt::Unanswered("TXT lookups need the `acme` feature".to_owned())
}
})
}
}
#[derive(Debug)]
pub struct CustomDomainHealthIndicator {
registry: Arc<CustomDomainRegistry>,
now_unix: fn() -> i64,
}
impl CustomDomainHealthIndicator {
#[must_use]
pub fn new(registry: Arc<CustomDomainRegistry>) -> Self {
Self { registry, now_unix }
}
#[must_use]
pub fn grade(&self, now_unix: i64) -> crate::actuator::HealthCheckOutput {
use crate::actuator::{HealthCheckOutput, HealthStatus};
let mut details = std::collections::HashMap::new();
let all = self.registry.list();
let active = all.iter().filter(|d| d.is_servable()).count();
details.insert("registered".to_owned(), serde_json::json!(all.len()));
details.insert("active".to_owned(), serde_json::json!(active));
let expired: Vec<String> = all
.iter()
.filter(|d| d.cert_not_after_unix.is_some_and(|na| na <= now_unix))
.map(|d| format!("{} (tenant {})", d.hostname, d.tenant))
.collect();
let failing: Vec<String> = all
.iter()
.filter(|d| d.failure_reason.is_some())
.map(|d| {
format!(
"{} (tenant {}): {}",
d.hostname,
d.tenant,
d.failure_reason.as_deref().unwrap_or_default()
)
})
.collect();
if !failing.is_empty() {
details.insert("failing".to_owned(), serde_json::json!(failing));
}
if !expired.is_empty() {
details.insert("expired".to_owned(), serde_json::json!(expired));
}
HealthCheckOutput {
status: if expired.is_empty() {
HealthStatus::Up
} else {
HealthStatus::Down
},
details,
}
}
}
impl crate::actuator::HealthIndicator for CustomDomainHealthIndicator {
fn check(&self) -> futures::future::BoxFuture<'_, crate::actuator::HealthCheckOutput> {
let now = (self.now_unix)();
Box::pin(async move { self.grade(now) })
}
fn group(&self) -> crate::actuator::IndicatorGroup {
crate::actuator::IndicatorGroup::HealthOnly
}
}
#[allow(
clippy::disallowed_methods,
reason = "grades real X.509 expiry dates and paces real ACME orders"
)]
pub(crate) fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
pub trait CustomDomainPruner: Send + Sync {
fn prune(
&self,
cutoff_unix: i64,
dry_run: bool,
) -> futures::future::BoxFuture<'_, Result<u64, String>>;
fn offboard_domain<'a>(
&'a self,
hostname: &'a str,
) -> futures::future::BoxFuture<'a, io::Result<bool>>;
fn offboard_tenant_domains<'a>(
&'a self,
tenant: &'a str,
) -> futures::future::BoxFuture<'a, io::Result<usize>>;
}
impl dyn CustomDomainPruner {
#[must_use]
pub fn from_state(state: &crate::AppState) -> Option<Arc<Self>> {
state
.extension::<Arc<Self>>()
.map(|outer| Arc::clone(&*outer))
}
}
#[cfg(feature = "tls")]
mod sni {
use super::{CustomDomainRegistry, read_lock, write_lock};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, RwLock};
#[derive(Debug)]
pub struct CustomDomainCertCache {
capacity: usize,
inner: RwLock<CacheInner>,
}
#[derive(Debug, Default)]
struct CacheInner {
certs: HashMap<String, Arc<CertifiedKey>>,
order: VecDeque<String>,
}
impl CustomDomainCertCache {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
capacity: capacity.max(1),
inner: RwLock::new(CacheInner::default()),
}
}
#[must_use]
pub fn get(&self, hostname: &str) -> Option<Arc<CertifiedKey>> {
read_lock(&self.inner).certs.get(hostname).cloned()
}
pub fn insert(&self, hostname: &str, key: Arc<CertifiedKey>) {
let mut inner = write_lock(&self.inner);
if inner.certs.insert(hostname.to_owned(), key).is_none() {
inner.order.push_back(hostname.to_owned());
}
while inner.order.len() > self.capacity {
if let Some(oldest) = inner.order.pop_front() {
inner.certs.remove(&oldest);
}
}
drop(inner);
}
pub fn remove(&self, hostname: &str) {
let mut inner = write_lock(&self.inner);
inner.certs.remove(hostname);
inner.order.retain(|h| h != hostname);
}
#[must_use]
pub const fn capacity(&self) -> usize {
self.capacity
}
#[must_use]
pub fn len(&self) -> usize {
read_lock(&self.inner).certs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug)]
pub struct SniCertResolver {
base: Arc<crate::tls::ReloadableCertResolver>,
base_names: Vec<String>,
registry: Arc<CustomDomainRegistry>,
cache: Arc<CustomDomainCertCache>,
source: Option<Arc<dyn SniCertSource>>,
}
pub trait SniCertSource: Send + Sync + std::fmt::Debug {
fn load(&self, hostname: &str) -> Option<Arc<CertifiedKey>>;
}
impl SniCertResolver {
#[must_use]
pub fn new(
base: Arc<crate::tls::ReloadableCertResolver>,
base_names: Vec<String>,
registry: Arc<CustomDomainRegistry>,
cache: Arc<CustomDomainCertCache>,
) -> Self {
Self {
base,
base_names: base_names
.into_iter()
.map(|n| n.trim().to_ascii_lowercase())
.collect(),
registry,
cache,
source: None,
}
}
#[must_use]
pub fn with_source(mut self, source: Arc<dyn SniCertSource>) -> Self {
self.source = Some(source);
self
}
#[must_use]
pub fn certificate_for(&self, server_name: &str) -> Option<Arc<CertifiedKey>> {
let name = server_name.trim_end_matches('.').to_ascii_lowercase();
if self.base_covers(&name) {
return Some(self.base.current());
}
if !self.registry.is_servable(&name) {
return None;
}
if let Some(cached) = self.cache.get(&name) {
return Some(cached);
}
let loaded = self.source.as_ref()?.load(&name)?;
self.cache.insert(&name, Arc::clone(&loaded));
Some(loaded)
}
fn base_covers(&self, name: &str) -> bool {
self.base_names.iter().any(|pattern| {
pattern
.strip_prefix("*.")
.map_or(pattern == name, |suffix| {
name.strip_suffix(suffix)
.and_then(|prefix| prefix.strip_suffix('.'))
.is_some_and(|label| !label.is_empty() && !label.contains('.'))
})
})
}
}
impl ResolvesServerCert for SniCertResolver {
fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
client_hello.server_name().map_or_else(
|| Some(self.base.current()),
|name| self.certificate_for(name),
)
}
}
}
#[cfg(feature = "tls")]
pub use sni::{CustomDomainCertCache, SniCertResolver, SniCertSource};
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
lock.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
lock.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(kani)]
mod kani_proofs {
use super::*;
#[kani::proof]
fn backoff_is_capped_and_monotonic() {
let failures: u32 = kani::any();
let base: u64 = kani::any();
let max: u64 = kani::any();
let delay = backoff_secs(failures, base, max);
if failures == 0 {
assert_eq!(delay, 0);
} else {
assert!(delay <= max);
}
if failures < u32::MAX {
assert!(delay <= backoff_secs(failures + 1, base, max));
}
}
#[kani::proof]
fn first_failure_waits_the_base() {
let base: u64 = kani::any();
let max: u64 = kani::any();
assert_eq!(backoff_secs(1, base, max), base.min(max));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn write_gates_are_bounded_by_the_registry_not_by_hostnames_ever_seen() {
let registry = CustomDomainRegistry::new(Arc::new(MemoryCustomDomainStore::new()), 2);
registry.load().await.unwrap();
for host in ["a.clientco.com", "b.clientco.com"] {
registry.register(host, "tenant-a", 0).await.unwrap();
}
for i in 0..500 {
let host = format!("flood-{i}.clientco.com");
assert!(matches!(
registry.register(&host, "tenant-evil", 0).await,
Err(RegisterError::LimitReached { .. })
));
}
assert_eq!(
registry.writes.lock().await.len(),
2,
"a refused registration must not leave a gate behind"
);
for host in ["a.clientco.com", "b.clientco.com"] {
assert!(registry.remove(host).await.unwrap());
}
for i in 0..300 {
let host = format!("churn-{i}.clientco.com");
registry.register(&host, "tenant-a", 0).await.unwrap();
assert!(registry.remove(&host).await.unwrap());
}
let gates = registry.writes.lock().await.len();
assert!(
gates <= registry.gate_high_water(),
"idle gates must be swept, but {gates} are held"
);
}
#[test]
fn apex_detection() {
assert!(is_apex("clientco.com"));
assert!(!is_apex("app.clientco.com"));
assert!(!is_apex("a.b.clientco.com"));
}
#[test]
fn apex_detection_uses_the_public_suffix_list() {
for apex in ["clientco.co.uk", "customer.com.au", "tenant.co.jp"] {
assert!(is_apex(apex), "{apex} is an apex");
let sub = format!("app.{apex}");
assert!(!is_apex(&sub), "{sub} is a subdomain");
}
assert!(is_apex("foo.bar.ck"));
assert!(is_apex("www.ck"));
assert!(!is_apex("app.www.ck"));
assert!(is_apex("clientco.eu.org"));
assert!(is_apex("clientco.internal"));
assert!(!is_apex("app.clientco.internal"));
assert!(is_apex("co.uk"));
}
#[test]
fn apex_detection_ignores_case_and_a_trailing_dot() {
assert!(is_apex("ClientCo.CO.UK"));
assert!(is_apex("clientco.co.uk."));
assert!(!is_apex("APP.ClientCo.co.uk"));
}
#[test]
fn backoff_doubles_and_caps() {
assert_eq!(backoff_secs(0, 300, 3600), 0);
assert_eq!(backoff_secs(1, 300, 3600), 300);
assert_eq!(backoff_secs(4, 300, 3600), 2400);
assert_eq!(backoff_secs(5, 300, 3600), 3600);
assert_eq!(backoff_secs(u32::MAX, 300, 3600), 3600);
}
#[test]
fn a_cname_target_is_compared_case_and_dot_insensitively() {
let expected = ExpectedIngress {
hostname: Some("Ingress.MyApp.com".to_owned()),
..ExpectedIngress::default()
};
assert_eq!(
grade_dns_verification(
&ObservedTarget::Cname("ingress.myapp.com.".to_owned()),
&expected
),
VerificationOutcome::PointsHere
);
}
#[test]
fn a_partial_address_match_is_not_a_pass() {
let expected = ExpectedIngress {
ipv4: vec!["203.0.113.10".parse().unwrap()],
..ExpectedIngress::default()
};
let observed = ObservedTarget::Addresses(vec![
"203.0.113.10".parse().unwrap(),
"198.51.100.7".parse().unwrap(),
]);
match grade_dns_verification(&observed, &expected) {
VerificationOutcome::PointsElsewhere { detail } => {
assert!(detail.contains("198.51.100.7"), "{detail}");
assert!(!detail.contains("203.0.113.10"), "{detail}");
}
other => panic!("expected a mismatch, got {other:?}"),
}
}
#[tokio::test]
async fn the_filesystem_store_round_trips_and_deletes() {
let dir = tempfile::tempdir().unwrap();
let store = FsCustomDomainStore::new(dir.path());
let domain = CustomDomain::new(
"app.clientco.com".to_owned(),
"t1".to_owned(),
100,
"token".to_owned(),
);
store.save(&domain).await.unwrap();
let loaded = store.load_all().await.unwrap();
assert_eq!(loaded, vec![domain.clone()]);
store.delete("app.clientco.com").await.unwrap();
assert!(store.load_all().await.unwrap().is_empty());
store.delete("app.clientco.com").await.unwrap();
}
#[tokio::test]
async fn the_memory_ledger_keeps_recent_attempts_and_prunes_old_ones() {
let store = MemoryCustomDomainStore::new();
store.append_attempt(1000).await.unwrap();
store.append_attempt(1000).await.unwrap();
let later = 1000 + GLOBAL_WINDOW_SECS + 1;
store.append_attempt(later).await.unwrap();
assert_eq!(store.load_attempts().await.unwrap(), vec![later]);
}
#[tokio::test]
async fn the_filesystem_ledger_survives_a_new_store_and_prunes() {
let dir = tempfile::tempdir().unwrap();
let store = FsCustomDomainStore::new(dir.path());
assert!(store.load_attempts().await.unwrap().is_empty());
store.append_attempt(1000).await.unwrap();
store.append_attempt(1000).await.unwrap();
let reopened = FsCustomDomainStore::new(dir.path());
assert_eq!(reopened.load_attempts().await.unwrap(), vec![1000, 1000]);
let later = 1000 + GLOBAL_WINDOW_SECS + 1;
reopened.append_attempt(later).await.unwrap();
assert_eq!(reopened.load_attempts().await.unwrap(), vec![later]);
}
#[tokio::test]
async fn the_ledger_file_is_not_read_as_a_domain_record() {
let dir = tempfile::tempdir().unwrap();
let store = FsCustomDomainStore::new(dir.path());
store.append_attempt(1000).await.unwrap();
assert!(store.load_all().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_corrupt_filesystem_ledger_reads_as_empty() {
let dir = tempfile::tempdir().unwrap();
let store = FsCustomDomainStore::new(dir.path());
store.append_attempt(1000).await.unwrap();
std::fs::write(dir.path().join(LEDGER_FILE), b"not json").unwrap();
assert!(store.load_attempts().await.unwrap().is_empty());
store.append_attempt(2000).await.unwrap();
assert!(dir.path().join("attempts.ledger.corrupt").exists());
assert_eq!(store.load_attempts().await.unwrap(), vec![2000]);
}
#[test]
fn hydrating_the_global_ledger_is_idempotent_and_never_double_counts() {
let limiter = IssuanceLimiter::new(5, 2, 300, 3600);
limiter.hydrate([("a.test".to_owned(), 1000)], 1100);
limiter.hydrate_global([1000, 1010], 1100);
limiter.hydrate_global([1000, 1010], 1100);
assert!(matches!(
limiter.check("c.test", 1100),
IssuanceDecision::GlobalLimit { .. }
));
assert_eq!(
limiter.check("c.test", 1000 + GLOBAL_WINDOW_SECS + 1),
IssuanceDecision::Allow
);
}
#[test]
fn hydrating_the_global_ledger_ignores_future_attempts() {
let limiter = IssuanceLimiter::new(5, 1, 300, 3600);
limiter.hydrate_global([i64::MAX, 2000], 1000);
assert_eq!(limiter.check("a.test", 1000), IssuanceDecision::Allow);
}
#[tokio::test]
async fn a_future_ledger_entry_is_dropped_on_the_next_write() {
let store = MemoryCustomDomainStore::new();
store.append_attempt(5000).await.unwrap();
store.append_attempt(1000).await.unwrap();
assert_eq!(store.load_attempts().await.unwrap(), vec![1000]);
}
#[tokio::test]
async fn merging_adds_only_missing_in_window_orders_in_both_stores() {
let dir = tempfile::tempdir().unwrap();
let fs = FsCustomDomainStore::new(dir.path());
let mem = MemoryCustomDomainStore::new();
let now = 5000;
let old = now - GLOBAL_WINDOW_SECS - 1;
for store in [&fs as &dyn CustomDomainStore, &mem] {
store.append_attempt(4000).await.unwrap();
store
.merge_attempts(vec![4000, 4500, old, now + 9], now)
.await
.unwrap();
store.merge_attempts(vec![4000, 4500], now).await.unwrap();
let mut got = store.load_attempts().await.unwrap();
got.sort_unstable();
assert_eq!(got, vec![4000, 4500]);
}
}
#[tokio::test]
async fn concurrent_filesystem_appends_lose_no_entry() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(FsCustomDomainStore::new(dir.path()));
let tasks: Vec<_> = (0..16)
.map(|_| {
let store = Arc::clone(&store);
tokio::spawn(async move { store.append_attempt(1000).await.unwrap() })
})
.collect();
for t in tasks {
t.await.unwrap();
}
assert_eq!(store.load_attempts().await.unwrap().len(), 16);
}
#[cfg(unix)]
#[tokio::test]
async fn the_filesystem_ledger_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let store = FsCustomDomainStore::new(dir.path());
store.append_attempt(1000).await.unwrap();
let mode = std::fs::metadata(dir.path().join(LEDGER_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn hydrating_the_global_ledger_ignores_attempts_outside_the_window() {
let limiter = IssuanceLimiter::new(5, 1, 300, 3600);
limiter.hydrate_global([1000], 1000 + GLOBAL_WINDOW_SECS + 1);
assert_eq!(
limiter.check("a.test", 1000 + GLOBAL_WINDOW_SECS + 1),
IssuanceDecision::Allow
);
}
#[tokio::test]
async fn a_registry_hydrates_from_its_store() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(FsCustomDomainStore::new(dir.path()));
let first = CustomDomainRegistry::new(store.clone(), 10);
first.load().await.unwrap();
first.register("app.clientco.com", "t1", 100).await.unwrap();
first
.record_active("app.clientco.com", 100, 200)
.await
.unwrap();
let second = CustomDomainRegistry::new(store, 10);
assert_eq!(second.load().await.unwrap(), 1);
assert_eq!(
second.tenant_for_host("app.clientco.com").as_deref(),
Some("t1")
);
}
#[tokio::test]
async fn re_registering_the_same_pair_is_idempotent() {
let registry = CustomDomainRegistry::new(Arc::new(MemoryCustomDomainStore::new()), 10);
registry.load().await.unwrap();
registry
.register("app.clientco.com", "t1", 100)
.await
.unwrap();
registry
.record_active("app.clientco.com", 100, 200)
.await
.unwrap();
let again = registry
.register("app.clientco.com", "t1", 500)
.await
.unwrap();
assert_eq!(
again.status,
DomainStatus::Active,
"re-registering must not reset a live domain"
);
}
#[tokio::test]
async fn is_servable_only_for_an_active_domain_in_any_spelling() {
let registry = CustomDomainRegistry::new(Arc::new(MemoryCustomDomainStore::new()), 10);
registry.load().await.unwrap();
registry
.register("app.clientco.com", "t1", 100)
.await
.unwrap();
assert!(
!registry.is_servable("app.clientco.com"),
"pending_dns must not serve"
);
assert!(registry.tenant_for_host("app.clientco.com").is_none());
registry
.record_active("app.clientco.com", 100, 200)
.await
.unwrap();
for spelling in [
"app.clientco.com",
"APP.ClientCo.com",
"app.clientco.com.",
"app.clientco.com:443",
] {
assert!(registry.is_servable(spelling), "{spelling}");
assert_eq!(
registry.is_servable(spelling),
registry.tenant_for_host(spelling).is_some(),
"{spelling}"
);
}
assert_eq!(
registry.tenant_for_host("APP.ClientCo.com").as_deref(),
Some("t1")
);
assert!(!registry.is_servable("other.clientco.com"));
assert!(!registry.is_servable(""));
assert!(!registry.is_servable("*.clientco.com"));
assert!(registry.remove("app.clientco.com").await.unwrap());
assert!(!registry.is_servable("app.clientco.com"));
}
#[tokio::test]
async fn a_failed_order_returns_to_verified_not_pending() {
let registry = CustomDomainRegistry::new(Arc::new(MemoryCustomDomainStore::new()), 10);
registry.load().await.unwrap();
registry
.register("app.clientco.com", "t1", 100)
.await
.unwrap();
registry
.record_verified("app.clientco.com", 100)
.await
.unwrap();
registry.record_issuing("app.clientco.com").await.unwrap();
registry
.record_failure("app.clientco.com", 100, "CA said no", 300)
.await
.unwrap();
let record = registry.get("app.clientco.com").unwrap();
assert_eq!(record.status, DomainStatus::Verified);
assert_eq!(record.next_attempt_unix, Some(400));
assert!(!record.is_due(399));
assert!(record.is_due(400));
}
#[test]
fn a_spent_budget_reports_when_it_rolls() {
let limiter = IssuanceLimiter::new(1, 10, 300, 3600);
limiter.record_attempt("a.test", 1000);
match limiter.check("a.test", 1500) {
IssuanceDecision::PerDomainLimit { retry_after_secs } => {
assert_eq!(retry_after_secs, 1000 + PER_DOMAIN_WINDOW_SECS - 1500);
}
other => panic!("expected a per-domain refusal, got {other:?}"),
}
assert_eq!(
limiter.check("a.test", 1000 + PER_DOMAIN_WINDOW_SECS + 1),
IssuanceDecision::Allow
);
limiter.forget("a.test");
assert_eq!(limiter.check("a.test", 1500), IssuanceDecision::Allow);
}
#[test]
fn hydrating_restores_both_windows_and_is_idempotent() {
let limiter = IssuanceLimiter::new(2, 2, 300, 3600);
let persisted = vec![
("a.test".to_owned(), 1000),
("b.test".to_owned(), 1100),
("a.test".to_owned(), 1000 - PER_DOMAIN_WINDOW_SECS),
];
let now = 1200;
limiter.hydrate(persisted.clone(), now);
limiter.hydrate(persisted, now);
assert!(matches!(
limiter.check("c.test", now),
IssuanceDecision::GlobalLimit { .. }
));
assert_eq!(
limiter.check("c.test", 1000 + GLOBAL_WINDOW_SECS + 1),
IssuanceDecision::Allow
);
}
#[test]
fn hydrating_counts_two_attempts_in_the_same_second_twice() {
let limiter = IssuanceLimiter::new(5, 2, 300, 3600);
let now = 2000;
let twice = vec![("a.test".to_owned(), 1500), ("a.test".to_owned(), 1500)];
limiter.hydrate(twice.clone(), now);
limiter.hydrate(twice, now);
assert!(matches!(
limiter.check("b.test", now),
IssuanceDecision::GlobalLimit { .. }
));
}
#[test]
fn a_record_stored_before_attempts_were_persisted_still_loads() {
let old = r#"{"hostname":"a.test","tenant":"t","status":"active","failure_reason":null,
"registered_at_unix":1,"verified_at_unix":null,"activated_at_unix":null,
"cert_not_after_unix":null,"consecutive_failures":0,"next_attempt_unix":null}"#;
let record: CustomDomain = serde_json::from_str(old).unwrap();
assert!(record.issuance_attempts_unix.is_empty());
}
#[test]
fn the_budget_says_nothing_about_backoff() {
let limiter = IssuanceLimiter::new(10, 10, 300, 3600);
assert_eq!(limiter.check("a.test", 1000), IssuanceDecision::Allow);
assert_eq!(limiter.backoff_for(3), 1200);
}
}