use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
sync::Arc,
time::Duration,
};
use bestool_canopy::{
certificates::{self as certs, KeyPair, KeyStore},
names::Entitlement,
schema::{RegisterNameArgs, RegisteredName, RequestCertificateArgs},
};
use futures::future::BoxFuture;
use jiff::Timestamp;
use miette::{IntoDiagnostic as _, Result, WrapErr as _, miette};
use serde_json::{Value, json};
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
use crate::alertd::{
BackgroundTask, TaskContext, TaskEndpoint, TaskEndpointResponse, tasks::TaskEndpointHandler,
};
fn why(err: &miette::Report) -> String {
err.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": ")
}
pub mod delivery;
pub mod peer;
pub const TASK_NAME: &str = "canopy-names";
const TICK: Duration = Duration::from_secs(60);
const STEADY_INTERVAL: Duration = Duration::from_secs(30 * 60);
const WANTED_LIMIT: usize = 64;
const PENDING_RETRY: Duration = Duration::from_secs(120);
#[derive(Clone, Debug, Default)]
pub struct Order {
pub state: String,
pub last_error: Option<String>,
pub not_after: Option<Timestamp>,
pub revoked: bool,
pub key_must_be_replaced: bool,
pub asked_at: Option<Timestamp>,
}
impl Order {
fn pending(&self) -> bool {
self.state == "pending"
}
}
#[derive(Clone)]
pub struct Held {
pub chain: String,
pub key_pem: String,
pub not_after: Option<Timestamp>,
pub usable: bool,
pub revoked: bool,
}
impl std::fmt::Debug for Held {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Held")
.field("not_after", &self.not_after)
.field("usable", &self.usable)
.field("revoked", &self.revoked)
.finish_non_exhaustive()
}
}
pub struct CertificateState {
dir: PathBuf,
permitted: peer::Permitted,
held: RwLock<BTreeMap<String, Held>>,
entitlement: RwLock<Option<Entitlement>>,
orders: RwLock<BTreeMap<String, Order>>,
wanted: Mutex<BTreeSet<String>>,
keys: Mutex<Option<KeyStore>>,
last_pass: RwLock<Option<Timestamp>>,
stood_down: RwLock<Option<Timestamp>>,
last_error: RwLock<Option<String>>,
}
impl CertificateState {
pub fn new(dir: PathBuf, permitted: peer::Permitted) -> Self {
Self {
dir,
permitted,
held: RwLock::new(BTreeMap::new()),
entitlement: RwLock::new(None),
orders: RwLock::new(BTreeMap::new()),
wanted: Mutex::new(BTreeSet::new()),
keys: Mutex::new(None),
last_pass: RwLock::new(None),
stood_down: RwLock::new(None),
last_error: RwLock::new(None),
}
}
pub fn permitted(&self) -> peer::Permitted {
self.permitted
}
pub async fn serve(&self, name: &str) -> Option<(String, String)> {
let name = name.trim_end_matches('.').to_ascii_lowercase();
if let Some(entitlement) = self.entitlement.read().await.as_ref()
&& !entitlement.covers(&name)
{
return None;
}
let held = self.held.read().await;
let chain = held.get(&name)?;
let live = chain.usable && chain.not_after.is_none_or(|at| at > Timestamp::now());
live.then(|| (chain.chain.clone(), chain.key_pem.clone()))
}
pub async fn note_wanted(&self, name: &str) {
let name = name.trim_end_matches('.').to_ascii_lowercase();
if certs::plausible_name(&name).is_err() {
return;
}
if self.held.read().await.contains_key(&name) {
return;
}
let mut wanted = self.wanted.lock().await;
if wanted.len() >= WANTED_LIMIT && !wanted.contains(&name) {
debug!(
name,
"not recording a name asked for during a handshake; too many are already waiting"
);
return;
}
if wanted.insert(name.clone()) {
debug!(name, "recorded a name asked for during a handshake");
}
}
async fn load_from_disk(&self) -> Result<()> {
let mut keys = self.keys.lock().await;
if keys.is_some() {
return Ok(());
}
let store = certs::load_keys(&self.dir).await?;
let chains = certs::load_chains(&self.dir).await?;
let mut held = self.held.write().await;
for (name, chain) in chains {
let Some(key_pem) = store.key_pem(&name) else {
debug!(name, "holding a chain with no key; it will be re-ordered");
continue;
};
let not_after =
certs::chain_not_after(&chain).and_then(|secs| Timestamp::from_second(secs).ok());
held.insert(
name,
Held {
chain,
key_pem: key_pem.to_owned(),
not_after,
usable: not_after.is_none_or(|at| at > Timestamp::now()),
revoked: false,
},
);
}
info!(names = held.len(), "loaded the chains this host holds");
*keys = Some(store);
Ok(())
}
async fn pass_due(&self) -> bool {
let now = Timestamp::now();
let steady_due = match *self.last_pass.read().await {
None => true,
Some(last) => (now - last).get_seconds() >= STEADY_INTERVAL.as_secs() as i64,
};
if steady_due {
return true;
}
if let Some(at) = *self.stood_down.read().await
&& (now - at).get_seconds() < STEADY_INTERVAL.as_secs() as i64
{
return false;
}
let wanted: Vec<String> = self.wanted.lock().await.iter().cloned().collect();
for name in &wanted {
if self.name_due(name, now).await {
return true;
}
}
self.orders.read().await.values().any(|order| {
order.pending()
&& order
.asked_at
.is_none_or(|at| (now - at).get_seconds() >= PENDING_RETRY.as_secs() as i64)
})
}
async fn refresh_entitlement(&self, ctx: &TaskContext) -> Result<Entitlement> {
let client = ctx
.canopy_client
.as_ref()
.ok_or_else(|| miette!("no canopy client; this host is not enrolled"))?;
let wire = client
.names_entitlements()
.await
.into_diagnostic()
.wrap_err("asking canopy what this server may do")?;
let entitlement = Entitlement::from_wire(&wire);
*self.entitlement.write().await = Some(entitlement.clone());
Ok(entitlement)
}
async fn entitlement(&self, ctx: &TaskContext) -> Result<Entitlement> {
if let Some(held) = self.entitlement.read().await.clone() {
return Ok(held);
}
self.refresh_entitlement(ctx).await
}
async fn pass(&self, ctx: &TaskContext, steady: bool) -> Result<()> {
self.load_from_disk().await?;
let entitlement = self.refresh_entitlement(ctx).await?;
if let Some(reason) = stands_down(&entitlement) {
debug!(reason, "not requesting certificates");
self.stand_down().await;
return Ok(());
}
*self.stood_down.write().await = None;
let names = self.target_names(ctx, &entitlement).await;
self.prune_orders(&names).await;
if names.is_empty() {
debug!("no name to certify on this host");
return Ok(());
}
let now = Timestamp::now();
for name in names {
let due = steady || self.name_due(&name, now).await;
if !due {
continue;
}
match self.collect_one(ctx, &name).await {
Ok(()) => {
self.wanted.lock().await.remove(&name);
}
Err(err) => {
warn!(name, %err, "could not collect a certificate");
let mut orders = self.orders.write().await;
let order = orders.entry(name.clone()).or_default();
order.last_error = Some(why(&err));
order.asked_at = Some(now);
}
}
}
Ok(())
}
async fn stand_down(&self) {
self.wanted.lock().await.clear();
*self.stood_down.write().await = Some(Timestamp::now());
}
async fn prune_orders(&self, targets: &[String]) {
let held = self.held.read().await;
let targets: BTreeSet<&String> = targets.iter().collect();
self.orders
.write()
.await
.retain(|name, _| targets.contains(name) || held.contains_key(name));
}
async fn name_due(&self, name: &str, now: Timestamp) -> bool {
let wanted = self.wanted.lock().await.contains(name);
match self.orders.read().await.get(name) {
None => true,
Some(order) if wanted || order.pending() => order
.asked_at
.is_none_or(|at| (now - at).get_seconds() >= PENDING_RETRY.as_secs() as i64),
Some(_) => false,
}
}
async fn target_names(&self, ctx: &TaskContext, entitlement: &Entitlement) -> Vec<String> {
let mut names: BTreeSet<String> = delivery::caddy_subjects(&ctx.http_client)
.await
.unwrap_or_else(|err| {
debug!(%err, "could not read Caddy's active subjects");
BTreeSet::new()
});
{
let mut wanted = self.wanted.lock().await;
wanted.retain(|name| entitlement.may_certify(name));
names.extend(wanted.iter().cloned());
}
names
.into_iter()
.filter(|name| certs::plausible_name(name).is_ok() && entitlement.may_certify(name))
.collect()
}
async fn collect_one(&self, ctx: &TaskContext, name: &str) -> Result<()> {
let client = ctx
.canopy_client
.as_ref()
.ok_or_else(|| miette!("no canopy client; this host is not enrolled"))?;
let key = self.key_for(name).await?;
let csr = certs::signing_request(name, &key)?;
let answer = client
.certificates_request(
&RequestCertificateArgs::builder()
.csr(csr)
.name(name.to_owned())
.build(),
)
.await
.into_diagnostic()
.wrap_err_with(|| format!("asking canopy to certify {name}"))?;
let not_after = answer.not_after.as_deref().and_then(certs::parse_not_after);
let order = Order {
state: answer.state.clone(),
last_error: answer.last_error.clone(),
not_after,
revoked: answer.revoked,
key_must_be_replaced: answer.key_must_be_replaced,
asked_at: Some(Timestamp::now()),
};
if let Some(ref err) = order.last_error {
warn!(name, error = %err, state = %order.state, "canopy reports this order failing");
}
self.orders
.write()
.await
.insert(name.to_owned(), order.clone());
match disposition(&order, answer.chain.as_deref()) {
Disposition::ReplaceKey => {
info!(name, "canopy condemned this key; generating a replacement");
self.replace_key(name).await?;
}
Disposition::DropHeld => {
info!(
name,
"canopy reports this certificate revoked; taking it out of service"
);
self.drop_held(name).await?;
}
Disposition::TakeChain => {
let chain = answer.chain.unwrap_or_default();
self.take_chain(name, chain, not_after, answer.usable)
.await?;
}
Disposition::Wait => debug!(name, state = %answer.state, "no chain yet"),
}
Ok(())
}
async fn key_for(&self, name: &str) -> Result<KeyPair> {
let mut keys = self.keys.lock().await;
let store = keys.get_or_insert_with(KeyStore::default);
if let Some(key) = store.key(name) {
return key;
}
let key = certs::generate_key()?;
store.replace(name, &key);
certs::store_keys(&self.dir, store).await?;
info!(name, "generated a key for this name");
Ok(key)
}
async fn replace_key(&self, name: &str) -> Result<()> {
let key = certs::generate_key()?;
{
let mut keys = self.keys.lock().await;
let store = keys.get_or_insert_with(KeyStore::default);
store.replace(name, &key);
certs::store_keys(&self.dir, store).await?;
}
self.drop_held(name).await
}
async fn take_chain(
&self,
name: &str,
chain: String,
not_after: Option<Timestamp>,
usable: bool,
) -> Result<()> {
let key_pem = {
let keys = self.keys.lock().await;
keys.as_ref()
.and_then(|store| store.key_pem(name).map(ToOwned::to_owned))
.ok_or_else(|| miette!("collected a chain for {name} with no key to serve it"))?
};
let replacing = self
.held
.read()
.await
.get(name)
.is_some_and(|held| held.chain != chain);
certs::store_chain(&self.dir, name, &chain).await?;
self.held.write().await.insert(
name.to_owned(),
Held {
chain,
key_pem,
not_after,
usable,
revoked: false,
},
);
if replacing {
info!(name, "a renewed chain replaced the one held");
} else {
info!(name, "collected a chain");
}
Ok(())
}
async fn drop_held(&self, name: &str) -> Result<()> {
self.held.write().await.remove(name);
certs::forget_chain(&self.dir, name).await?;
Ok(())
}
async fn report(&self) -> Value {
let entitlement = self.entitlement.read().await.clone();
let held = self.held.read().await;
let orders = self.orders.read().await;
let names: Vec<Value> = held
.iter()
.map(|(name, chain)| {
json!({
"name": name,
"notAfter": chain.not_after.map(|at| at.to_string()),
"usable": chain.usable,
"revoked": chain.revoked,
})
})
.collect();
let orders: Vec<Value> = orders
.iter()
.map(|(name, order)| {
json!({
"name": name,
"state": order.state,
"lastError": order.last_error,
"notAfter": order.not_after.map(|at| at.to_string()),
"revoked": order.revoked,
"keyMustBeReplaced": order.key_must_be_replaced,
"askedAt": order.asked_at.map(|at| at.to_string()),
})
})
.collect();
json!({
"entitled": entitlement.as_ref().is_some_and(Entitlement::holds_tls_grant),
"dnsEntitled": entitlement.as_ref().is_some_and(Entitlement::holds_dns_grant),
"paused": entitlement.as_ref().is_some_and(Entitlement::fully_paused),
"domains": entitlement.as_ref().map(Entitlement::domains).unwrap_or_default(),
"registeredNames": entitlement
.as_ref()
.map(Entitlement::registered_names)
.unwrap_or_default(),
"canopyHolds": entitlement.as_ref().map(|e| e
.certificates()
.iter()
.map(|held| json!({
"name": held.name,
"keyFingerprint": held.key_fingerprint,
"notAfter": held.not_after,
"usable": held.usable,
"revoked": held.revoked,
"keyMustBeReplaced": held.key_must_be_replaced,
}))
.collect::<Vec<_>>())
.unwrap_or_default(),
"held": names,
"orders": orders,
"wanted": self.wanted.lock().await.iter().cloned().collect::<Vec<_>>(),
"lastPass": self.last_pass.read().await.map(|at| at.to_string()),
"lastError": self.last_error.read().await.clone(),
})
}
async fn register_name(
&self,
ctx: &TaskContext,
name: &str,
addresses: Vec<String>,
) -> Result<RegisteredName> {
certs::plausible_name(name)?;
let addresses = addresses
.iter()
.map(|address| {
address
.parse::<std::net::IpAddr>()
.map(|parsed| parsed.to_string())
.map_err(|_| miette!("{address:?} is not an IP address"))
})
.collect::<Result<Vec<String>>>()?;
let client = ctx
.canopy_client
.as_ref()
.ok_or_else(|| miette!("no canopy client; this host is not enrolled"))?;
let entitlement = self.entitlement(ctx).await?;
if !entitlement.holds_dns_grant() {
return Err(miette!("this server may not manage its own DNS records"));
}
if !entitlement.covers(name) {
return Err(miette!(
"{name} is not within a domain this server's group controls"
));
}
let answer = client
.names_register(
&RegisterNameArgs::builder()
.addresses(addresses)
.name(name.to_owned())
.build(),
)
.await
.into_diagnostic()
.wrap_err_with(|| format!("registering {name} with canopy"))?;
let _ = self.refresh_entitlement(ctx).await;
Ok(answer)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Disposition {
ReplaceKey,
DropHeld,
TakeChain,
Wait,
}
fn disposition(order: &Order, chain: Option<&str>) -> Disposition {
if order.key_must_be_replaced {
Disposition::ReplaceKey
} else if order.revoked {
Disposition::DropHeld
} else if chain.is_some_and(|chain| !chain.trim().is_empty()) {
Disposition::TakeChain
} else {
Disposition::Wait
}
}
fn stands_down(entitlement: &Entitlement) -> Option<&'static str> {
if !entitlement.holds_tls_grant() {
Some("this server holds no TLS grant")
} else if entitlement.fully_paused() {
Some("canopy reports this server paused")
} else {
None
}
}
pub struct CanopyNames {
state: Arc<CertificateState>,
}
impl CanopyNames {
pub fn new(state: Arc<CertificateState>) -> Self {
Self { state }
}
}
impl BackgroundTask for CanopyNames {
fn name(&self) -> &'static str {
TASK_NAME
}
fn interval(&self) -> Duration {
TICK
}
fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
if let Err(err) = self.state.load_from_disk().await {
warn!(%err, "could not load the chains this host holds");
}
if !self.state.pass_due().await {
return Ok(());
}
let steady = match *self.state.last_pass.read().await {
None => true,
Some(last) => {
(Timestamp::now() - last).get_seconds() >= STEADY_INTERVAL.as_secs() as i64
}
};
match self.state.pass(ctx, steady).await {
Ok(()) => {
*self.state.last_error.write().await = None;
}
Err(err) => {
warn!(%err, "certificate collection pass failed");
*self.state.last_error.write().await = Some(why(&err));
}
}
if steady {
*self.state.last_pass.write().await = Some(Timestamp::now());
}
Ok(())
})
}
fn http_endpoints(&self) -> Vec<TaskEndpoint> {
vec![
endpoint("status", self.state.clone(), |state, _ctx| {
Box::pin(async move { Ok(state.report().await) })
}),
guarded_endpoint("collect", self.state.clone(), |state, ctx| {
Box::pin(async move {
state.pass(&ctx, true).await?;
*state.last_pass.write().await = Some(Timestamp::now());
Ok(state.report().await)
})
}),
guarded_endpoint("request", self.state.clone(), |state, ctx| {
Box::pin(async move {
let name = required(&ctx, "name")?;
certs::plausible_name(&name)?;
let name = name.to_ascii_lowercase();
if !state.entitlement(&ctx).await?.covers(&name) {
return Err(miette!(
"{name} is not within a domain this server's group controls"
));
}
state.wanted.lock().await.insert(name);
state.pass(&ctx, false).await?;
Ok(state.report().await)
})
}),
guarded_endpoint("dns-register", self.state.clone(), |state, ctx| {
Box::pin(async move {
let name = required(&ctx, "name")?;
let addresses = ctx
.query
.get("addresses")
.map(|list| {
list.split(',')
.map(str::trim)
.filter(|a| !a.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if addresses.is_empty() {
return Err(miette!(
"registering {name} needs at least one address; use dns-withdraw to take its records down"
));
}
let answer = state.register_name(&ctx, &name, addresses).await?;
Ok(registered(&answer))
})
}),
guarded_endpoint("dns-withdraw", self.state.clone(), |state, ctx| {
Box::pin(async move {
let name = required(&ctx, "name")?;
let answer = state.register_name(&ctx, &name, Vec::new()).await?;
Ok(registered(&answer))
})
}),
]
}
}
fn registered(answer: &RegisteredName) -> Value {
json!({
"name": answer.name,
"addresses": answer.addresses,
"publishedAddresses": answer.published_addresses,
"published": answer.published,
"lastError": answer.last_error,
})
}
fn required(ctx: &TaskContext, key: &str) -> Result<String> {
ctx.query
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| miette!("this endpoint needs a `{key}` query parameter"))
}
fn endpoint(
name: &'static str,
state: Arc<CertificateState>,
run: fn(Arc<CertificateState>, TaskContext) -> BoxFuture<'static, Result<Value>>,
) -> TaskEndpoint {
TaskEndpoint::open(name, wrap(state, run))
}
fn guarded_endpoint(
name: &'static str,
state: Arc<CertificateState>,
run: fn(Arc<CertificateState>, TaskContext) -> BoxFuture<'static, Result<Value>>,
) -> TaskEndpoint {
TaskEndpoint::guarded(name, wrap(state, run))
}
fn wrap(
state: Arc<CertificateState>,
run: fn(Arc<CertificateState>, TaskContext) -> BoxFuture<'static, Result<Value>>,
) -> TaskEndpointHandler {
Arc::new(move |ctx: TaskContext| {
let state = state.clone();
Box::pin(async move {
match run(state, ctx).await {
Ok(value) => TaskEndpointResponse::Json(value),
Err(err) => TaskEndpointResponse::Error {
status: 400,
message: why(&err),
},
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> (tempfile::TempDir, CertificateState) {
let dir = tempfile::tempdir().unwrap();
let state = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
(dir, state)
}
async fn with_entitlement(state: &CertificateState, domains: &[&str], tls: bool, paused: bool) {
let wire = bestool_canopy::schema::Entitlements::builder()
.applications(vec![])
.certificates(vec![])
.domains(domains.iter().map(|d| (*d).to_string()).collect::<Vec<_>>())
.may_manage_dns(false)
.may_manage_tls(tls)
.paused(paused)
.registered_names(vec![])
.build();
*state.entitlement.write().await = Some(Entitlement::from_wire(&wire));
}
fn detached_ctx() -> TaskContext {
TaskContext {
http_client: reqwest::Client::new(),
canopy_client: None,
reload: tokio::sync::watch::channel(0u64).1,
#[cfg(windows)]
restart: None,
query: Default::default(),
}
}
async fn hold(state: &CertificateState, name: &str, usable: bool) {
hold_until(state, name, usable, None).await;
}
async fn hold_until(
state: &CertificateState,
name: &str,
usable: bool,
not_after: Option<Timestamp>,
) {
state.held.write().await.insert(
name.to_owned(),
Held {
chain: format!("chain for {name}"),
key_pem: format!("key for {name}"),
not_after,
usable,
revoked: !usable,
},
);
}
#[tokio::test]
async fn a_held_usable_chain_is_served_with_its_key() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
hold(&state, "app.example.com", true).await;
let (chain, key) = state.serve("app.example.com").await.unwrap();
assert_eq!(chain, "chain for app.example.com");
assert_eq!(key, "key for app.example.com");
assert!(state.serve("APP.example.com.").await.is_some());
}
#[tokio::test]
async fn every_case_with_no_chain_to_offer_declines() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
assert!(state.serve("app.example.com").await.is_none());
hold(&state, "app.elsewhere.test", true).await;
assert!(state.serve("app.elsewhere.test").await.is_none());
hold(&state, "old.example.com", false).await;
assert!(state.serve("old.example.com").await.is_none());
}
#[tokio::test]
async fn a_held_chain_keeps_being_served_when_the_grant_goes_and_while_paused() {
let (_dir, state) = state();
hold(&state, "app.example.com", true).await;
with_entitlement(&state, &["example.com"], false, false).await;
assert!(state.serve("app.example.com").await.is_some());
with_entitlement(&state, &["example.com"], true, true).await;
assert!(state.serve("app.example.com").await.is_some());
}
#[tokio::test]
async fn a_handshake_for_a_name_with_nothing_held_records_it() {
let (_dir, state) = state();
state.note_wanted("app.example.com").await;
assert!(state.wanted.lock().await.contains("app.example.com"));
hold(&state, "other.example.com", true).await;
state.note_wanted("other.example.com").await;
assert!(!state.wanted.lock().await.contains("other.example.com"));
state.note_wanted("not a name/../etc").await;
assert_eq!(state.wanted.lock().await.len(), 1);
}
#[tokio::test]
async fn a_chain_that_has_expired_since_it_was_collected_is_declined() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let hour = jiff::SignedDuration::from_hours(1);
hold_until(
&state,
"app.example.com",
true,
Some(Timestamp::now() + hour),
)
.await;
assert!(state.serve("app.example.com").await.is_some());
hold_until(
&state,
"app.example.com",
true,
Some(Timestamp::now() - hour),
)
.await;
assert!(state.serve("app.example.com").await.is_none());
}
#[tokio::test]
async fn a_name_the_entitlement_does_not_cover_is_dropped_from_the_record() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
state.note_wanted("app.elsewhere.test").await;
assert!(state.pass_due().await);
let entitlement = state.entitlement.read().await.clone().unwrap();
assert!(
state
.target_names(&detached_ctx(), &entitlement)
.await
.is_empty()
);
assert!(state.wanted.lock().await.is_empty());
*state.last_pass.write().await = Some(Timestamp::now());
assert!(!state.pass_due().await);
}
#[tokio::test]
async fn a_recorded_name_is_still_subject_to_the_entitlement() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
state.note_wanted("app.elsewhere.test").await;
state.note_wanted("app.example.com").await;
let entitlement = state.entitlement.read().await.clone().unwrap();
let names = state.target_names(&detached_ctx(), &entitlement).await;
assert_eq!(names, vec!["app.example.com".to_string()]);
}
#[test]
fn a_condemned_key_is_replaced_and_nothing_else_in_the_answer_is_taken() {
let condemned = Order {
state: "issued".into(),
key_must_be_replaced: true,
..Order::default()
};
assert_eq!(
disposition(&condemned, Some("-----BEGIN CERTIFICATE-----")),
Disposition::ReplaceKey
);
let both = Order {
revoked: true,
..condemned.clone()
};
assert_eq!(
disposition(&both, Some("-----BEGIN CERTIFICATE-----")),
Disposition::ReplaceKey
);
}
#[test]
fn a_revocation_drops_what_is_held_and_a_chain_is_otherwise_taken() {
let revoked = Order {
revoked: true,
..Order::default()
};
assert_eq!(disposition(&revoked, Some("chain")), Disposition::DropHeld);
let issued = Order {
state: "issued".into(),
..Order::default()
};
assert_eq!(disposition(&issued, Some("chain")), Disposition::TakeChain);
let pending = Order {
state: "pending".into(),
..Order::default()
};
assert_eq!(disposition(&pending, None), Disposition::Wait);
assert_eq!(disposition(&pending, Some(" ")), Disposition::Wait);
}
#[tokio::test]
async fn a_failed_attempt_is_retried_on_the_backoff_rather_than_every_tick() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
state.note_wanted("app.example.com").await;
*state.last_pass.write().await = Some(Timestamp::now());
let now = Timestamp::now();
assert!(state.pass_due().await);
assert!(state.name_due("app.example.com", now).await);
{
let mut orders = state.orders.write().await;
let order = orders.entry("app.example.com".to_string()).or_default();
order.last_error = Some("canopy unreachable".into());
order.asked_at = Some(now);
}
assert!(state.wanted.lock().await.contains("app.example.com"));
assert!(!state.name_due("app.example.com", now).await);
assert!(!state.pass_due().await);
let later = now + jiff::SignedDuration::from_secs(PENDING_RETRY.as_secs() as i64 + 1);
assert!(state.name_due("app.example.com", later).await);
}
#[tokio::test]
async fn registering_with_no_address_is_refused_rather_than_withdrawing() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let task = CanopyNames::new(Arc::new(state));
let endpoints = task.http_endpoints();
let register = endpoints
.iter()
.find(|endpoint| endpoint.name == "dns-register")
.expect("the task exposes dns-register");
for query in [vec![], vec![("addresses", "")], vec![("addresses", " , ")]] {
let mut ctx = detached_ctx();
ctx.query.insert("name".into(), "app.example.com".into());
for (key, value) in &query {
ctx.query.insert((*key).into(), (*value).into());
}
match (register.handler)(ctx).await {
TaskEndpointResponse::Error { status, message } => {
assert_eq!(status, 400);
assert!(
message.contains("dns-withdraw"),
"the refusal must point at the endpoint that does withdraw: {message}"
);
}
_ => panic!("expected a refusal for {query:?}"),
}
}
}
#[tokio::test]
async fn a_server_standing_down_waits_rather_than_asking_every_tick() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], false, false).await;
*state.last_pass.write().await = Some(Timestamp::now());
state.note_wanted("app.example.com").await;
assert!(state.pass_due().await, "a name asked for makes a pass due");
let entitlement = state.entitlement.read().await.clone().unwrap();
assert_eq!(
stands_down(&entitlement),
Some("this server holds no TLS grant")
);
state.stand_down().await;
assert!(state.wanted.lock().await.is_empty());
assert!(!state.pass_due().await);
state.note_wanted("other.example.com").await;
assert!(
!state.pass_due().await,
"a name recorded under a stand-down must not wake a pass"
);
}
#[tokio::test]
async fn a_stream_of_invented_names_cannot_grow_the_set_without_bound() {
let (_dir, state) = state();
state.note_wanted("real.example.com").await;
for n in 0..WANTED_LIMIT * 2 {
state
.note_wanted(&format!("invented-{n}.example.com"))
.await;
}
let wanted = state.wanted.lock().await;
assert_eq!(wanted.len(), WANTED_LIMIT);
assert!(
wanted.contains("real.example.com"),
"the name a real client asked for must not be pushed out"
);
}
#[tokio::test]
async fn what_canopy_said_about_a_name_no_longer_in_play_is_dropped() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let entitlement = state.entitlement.read().await.clone().unwrap();
hold(&state, "held.example.com", true).await;
for name in ["gone.example.com", "held.example.com", "app.example.com"] {
state.orders.write().await.insert(
name.to_string(),
Order {
state: "issued".into(),
..Order::default()
},
);
}
state.note_wanted("app.example.com").await;
let targets = state.target_names(&detached_ctx(), &entitlement).await;
assert_eq!(targets, vec!["app.example.com".to_string()]);
state.prune_orders(&targets).await;
let orders = state.orders.read().await;
assert!(orders.contains_key("app.example.com"), "a target is kept");
assert!(
orders.contains_key("held.example.com"),
"a name still holding a chain is kept"
);
assert!(
!orders.contains_key("gone.example.com"),
"a name in neither is dropped"
);
}
#[tokio::test]
async fn an_address_that_is_not_an_ip_is_refused_before_canopy_sees_it() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let err = state
.register_name(
&detached_ctx(),
"app.example.com",
vec!["203.0.113.5".into(), "not-an-address".into()],
)
.await
.unwrap_err();
assert!(
why(&err).contains("not-an-address"),
"the offending value must be named: {}",
why(&err)
);
let err = state
.register_name(&detached_ctx(), "app.example.com", vec!["::1".into()])
.await
.unwrap_err();
assert!(!why(&err).contains("is not an IP address"), "{}", why(&err));
}
#[tokio::test]
async fn a_withdrawn_grant_and_a_pause_both_stop_requesting_without_dropping_what_is_held() {
let (_dir, state) = state();
hold(&state, "app.example.com", true).await;
with_entitlement(&state, &["example.com"], false, false).await;
let withdrawn = state.entitlement.read().await.clone().unwrap();
assert_eq!(
stands_down(&withdrawn),
Some("this server holds no TLS grant")
);
assert!(state.serve("app.example.com").await.is_some());
with_entitlement(&state, &["example.com"], true, true).await;
let paused = state.entitlement.read().await.clone().unwrap();
assert_eq!(
stands_down(&paused),
Some("canopy reports this server paused")
);
assert!(state.serve("app.example.com").await.is_some());
with_entitlement(&state, &["example.com"], true, false).await;
let entitled = state.entitlement.read().await.clone().unwrap();
assert_eq!(stands_down(&entitled), None);
}
#[tokio::test]
async fn a_name_meeting_one_test_and_not_the_other_is_not_ordered_for() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let entitlement = state.entitlement.read().await.clone().unwrap();
assert!(
state
.target_names(&detached_ctx(), &entitlement)
.await
.is_empty()
);
state.note_wanted("app.elsewhere.test").await;
assert!(
state
.target_names(&detached_ctx(), &entitlement)
.await
.is_empty()
);
}
#[tokio::test]
async fn a_pending_order_is_retried_sooner_than_the_steady_pass_and_stops_once_it_resolves() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
state.orders.write().await.insert(
"app.example.com".into(),
Order {
state: "pending".into(),
asked_at: Some(Timestamp::now()),
..Order::default()
},
);
assert!(!state.pass_due().await, "just asked; not due yet");
let stale = Timestamp::now() - std::time::Duration::from_secs(300);
state
.orders
.write()
.await
.get_mut("app.example.com")
.unwrap()
.asked_at = Some(stale);
assert!(state.pass_due().await, "a pending order is retried sooner");
state
.orders
.write()
.await
.get_mut("app.example.com")
.unwrap()
.state = "issued".into();
assert!(
!state.pass_due().await,
"an order that resolved stops being retried"
);
}
#[tokio::test]
async fn a_name_asked_for_makes_a_pass_due() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
assert!(!state.pass_due().await);
state.note_wanted("app.example.com").await;
assert!(state.pass_due().await);
}
#[tokio::test]
async fn a_restart_serves_what_it_held_and_keeps_the_key_that_covers_it() {
let dir = tempfile::tempdir().unwrap();
let key = certs::generate_key().unwrap();
let mut store = KeyStore::default();
store.replace("app.example.com", &key);
certs::store_keys(dir.path(), &store).await.unwrap();
certs::store_chain(
dir.path(),
"app.example.com",
"-----BEGIN CERTIFICATE-----\n",
)
.await
.unwrap();
let state = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
state.load_from_disk().await.unwrap();
let (chain, served_key) = state.serve("app.example.com").await.unwrap();
assert!(chain.contains("BEGIN CERTIFICATE"));
assert_eq!(
certs::key_fingerprint(&KeyPair::from_pem(&served_key).unwrap()),
certs::key_fingerprint(&key)
);
}
#[tokio::test]
async fn a_chain_whose_key_is_gone_is_not_served() {
let dir = tempfile::tempdir().unwrap();
certs::store_chain(
dir.path(),
"app.example.com",
"-----BEGIN CERTIFICATE-----\n",
)
.await
.unwrap();
let state = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
state.load_from_disk().await.unwrap();
assert!(state.serve("app.example.com").await.is_none());
}
}