use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
sync::Arc,
time::Duration,
};
use bestool_alertd::ownership::{self, CaddySites, HostApplication, Ownership};
use bestool_canopy::{
certificates::{self as certs, KeyPair, KeyStore, StoredRefusal},
names::{AppEntitlement, Entitlement, Refusal, RefusalKind},
schema::{ApplicationType, 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 EXPLICIT_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>,
pub application_type: Option<String>,
pub refusal: Option<Refusal>,
}
impl Order {
fn pending(&self) -> bool {
self.state == "pending"
}
fn reason(&self) -> Option<&str> {
self.refusal
.as_ref()
.map(|refusal| refusal.reason.as_str())
.or(self.last_error.as_deref())
}
}
fn explicit_orderable(entitlement: &Entitlement, name: &str, application_type: &str) -> bool {
entitlement
.for_type(application_type)
.is_some_and(|app| app.may_certify(name))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Grant {
Tls,
Dns,
}
impl Grant {
fn held_by(self, app: &AppEntitlement) -> bool {
match self {
Self::Tls => app.may_manage_tls,
Self::Dns => app.may_manage_dns,
}
}
fn held_by_any(self, entitlement: &Entitlement) -> bool {
match self {
Self::Tls => entitlement.holds_tls_grant(),
Self::Dns => entitlement.holds_dns_grant(),
}
}
fn describe(self) -> &'static str {
match self {
Self::Tls => "obtain certificates from canopy",
Self::Dns => "manage its own DNS records",
}
}
}
fn permit(
entitlement: &Entitlement,
name: &str,
application_type: Option<&str>,
grant: Grant,
) -> Result<()> {
let named = match application_type {
Some(t) => match entitlement.for_type(t) {
Some(app) => Some((t, app)),
None => {
let hosted: Vec<&str> = entitlement
.applications
.iter()
.filter_map(|app| app.type_slug.as_deref())
.collect();
return Err(miette!(
"this machine hosts no {t} application; it hosts {}",
if hosted.is_empty() {
"none canopy knows of".to_owned()
} else {
hosted.join(", ")
}
));
}
},
None => None,
};
match named {
Some((application_type, app)) => {
if app.paused {
return Err(miette!(
"the {application_type} application is paused in canopy; nothing is asked for until the pause is lifted"
));
}
if !grant.held_by(app) {
return Err(miette!(
"the {application_type} application may not {}",
grant.describe()
));
}
if !app.covers(name) {
return Err(miette!(
"{name} is not within a domain the {application_type} application's group controls"
));
}
}
None => {
if !grant.held_by_any(entitlement) {
return Err(miette!("this server may not {}", grant.describe()));
}
if !entitlement.covers(name) {
return Err(miette!(
"{name} is not within a domain this server's group controls"
));
}
}
}
Ok(())
}
fn refusal_state(kind: RefusalKind) -> &'static str {
match kind {
RefusalKind::Undeclared => "undeclared",
RefusalKind::Denied => "denied",
RefusalKind::Conflict | RefusalKind::Other => "refused",
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Target {
name: String,
application_type: String,
}
struct Failure {
refusal: Option<Refusal>,
report: miette::Report,
}
impl From<miette::Report> for Failure {
fn from(report: miette::Report) -> Self {
Self {
refusal: None,
report,
}
}
}
#[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>>,
explicit: Mutex<BTreeMap<String, String>>,
persisted_requests: Mutex<BTreeMap<String, String>>,
persisted_refusals: Mutex<BTreeMap<String, StoredRefusal>>,
applications: RwLock<Option<Vec<HostApplication>>>,
keys: Mutex<Option<KeyStore>>,
last_pass: RwLock<Option<Timestamp>>,
stood_down: RwLock<Option<Timestamp>>,
incomplete: 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()),
explicit: Mutex::new(BTreeMap::new()),
persisted_requests: Mutex::new(BTreeMap::new()),
persisted_refusals: Mutex::new(BTreeMap::new()),
applications: RwLock::new(None),
keys: Mutex::new(None),
last_pass: RwLock::new(None),
stood_down: RwLock::new(None),
incomplete: 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 note_explicit(&self, name: &str, application_type: String) -> Result<()> {
self.load_from_disk().await?;
{
let mut explicit = self.explicit.lock().await;
if explicit.len() >= EXPLICIT_LIMIT && !explicit.contains_key(name) {
return Err(miette!(
"{EXPLICIT_LIMIT} DNS names requested by command are already being collected; no more can be requested until some are"
));
}
explicit.insert(name.to_owned(), application_type);
}
self.persist_requests().await;
Ok(())
}
async fn persist_requests(&self) {
let now = self.explicit.lock().await.clone();
let mut persisted = self.persisted_requests.lock().await;
if *persisted == now {
return;
}
match certs::store_requests(&self.dir, &now).await {
Ok(()) => *persisted = now,
Err(err) => warn!(%err, "could not keep the DNS names requested by command"),
}
}
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");
drop(held);
let kept = certs::load_refusals(&self.dir).await;
{
let mut orders = self.orders.write().await;
for (name, kept) in &kept {
orders.entry(name.clone()).or_insert_with(|| Order {
state: refusal_state(kept.kind).to_owned(),
asked_at: kept.asked_at.as_deref().and_then(|at| at.parse().ok()),
application_type: Some(kept.application_type.clone()),
refusal: Some(Refusal {
kind: kept.kind,
reason: kept.reason.clone(),
}),
..Order::default()
});
}
}
*self.persisted_refusals.lock().await = kept;
let requests = certs::load_requests(&self.dir).await;
{
let mut explicit = self.explicit.lock().await;
for (name, application_type) in &requests {
explicit
.entry(name.clone())
.or_insert_with(|| application_type.clone());
}
}
*self.persisted_requests.lock().await = requests;
*keys = Some(store);
Ok(())
}
async fn persist_refusals(&self) {
let now: BTreeMap<String, StoredRefusal> = self
.orders
.read()
.await
.iter()
.filter_map(|(name, order)| {
let refusal = order.refusal.as_ref()?;
Some((
name.clone(),
StoredRefusal {
kind: refusal.kind,
reason: refusal.reason.clone(),
application_type: order.application_type.clone().unwrap_or_default(),
asked_at: order.asked_at.map(|at| at.to_string()),
},
))
})
.collect();
let mut persisted = self.persisted_refusals.lock().await;
if *persisted == now {
return;
}
match certs::store_refusals(&self.dir, &now).await {
Ok(()) => *persisted = now,
Err(err) => warn!(%err, "could not keep the refusals canopy gave"),
}
}
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;
}
if let Some(at) = *self.incomplete.read().await
&& (now - at).get_seconds() < PENDING_RETRY.as_secs() as i64
{
return false;
}
let mut wanted: Vec<String> = self.wanted.lock().await.iter().cloned().collect();
{
let entitlement = self.entitlement.read().await;
let held = self.held.read().await;
wanted.extend(
self.explicit
.lock()
.await
.iter()
.filter(|(name, application_type)| {
!held.contains_key(*name)
&& entitlement
.as_ref()
.is_none_or(|e| explicit_orderable(e, name, application_type))
})
.map(|(name, _)| name.clone()),
);
}
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, asked: Option<&str>) -> 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 (targets, complete) = self.targets(&entitlement, steady).await;
*self.incomplete.write().await = (!complete).then(Timestamp::now);
if complete {
let names: Vec<String> = targets.iter().map(|t| t.name.clone()).collect();
self.prune_orders(&names).await;
}
self.persist_refusals().await;
self.persist_requests().await;
if targets.is_empty() {
debug!("no DNS name to certify on this host");
return Ok(());
}
let now = Timestamp::now();
for target in targets {
if !self.target_due(&target.name, steady, asked, now).await {
continue;
}
match self.collect_one(ctx, &target).await {
Ok(()) => {
self.wanted.lock().await.remove(&target.name);
}
Err(failure) => {
self.record_failure(&target, failure, now).await;
}
}
}
self.persist_refusals().await;
self.persist_requests().await;
Ok(())
}
async fn record_failure(&self, target: &Target, failure: Failure, now: Timestamp) {
let Failure { refusal, report } = failure;
match refusal.as_ref().map(|refusal| refusal.kind) {
Some(kind) if kind.awaits_operator() => {
info!(
name = target.name,
kind = kind.as_str(),
"canopy needs an operator to act on this DNS name"
);
}
_ => warn!(name = target.name, err = %why(&report), "could not collect a certificate"),
}
let mut orders = self.orders.write().await;
let order = orders.entry(target.name.clone()).or_default();
order.application_type = Some(target.application_type.clone());
order.asked_at = Some(now);
order.last_error = Some(why(&report));
let Some(refusal) = refusal else {
return;
};
order.state = refusal_state(refusal.kind).to_owned();
if refusal.kind.awaits_operator() {
order.last_error = None;
} else if refusal.kind == RefusalKind::Other {
self.explicit.lock().await.remove(&target.name);
}
order.refusal = Some(refusal);
}
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 target_due(
&self,
name: &str,
steady: bool,
asked: Option<&str>,
now: Timestamp,
) -> bool {
steady || asked == Some(name) || self.name_due(name, now).await
}
async fn name_due(&self, name: &str, now: Timestamp) -> bool {
let handshake = self.wanted.lock().await.contains(name);
let requested = self.explicit.lock().await.contains_key(name);
let wanted = handshake || (requested && !self.held.read().await.contains_key(name));
match self.orders.read().await.get(name) {
None => true,
Some(order) if order.refusal.is_some() => false,
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 targets(&self, entitlement: &Entitlement, steady: bool) -> (Vec<Target>, bool) {
let sites = match delivery::caddy_sites().await {
Ok(sites) => sites,
Err(err) => {
warn!(%err, "could not read Caddy's configuration; ordering only DNS names requested by command");
return (self.explicit_targets(entitlement).await, false);
}
};
let Some(applications) = self.host_applications(steady).await else {
return (self.explicit_targets(entitlement).await, false);
};
(
self.targets_from(entitlement, &sites, &applications).await,
true,
)
}
async fn host_applications(&self, steady: bool) -> Option<Vec<HostApplication>> {
let known = self.applications.read().await.clone();
if !steady && known.is_some() {
return known;
}
match ownership::discover_host_applications().await {
Ok(found) => {
*self.applications.write().await = Some(found.clone());
Some(found)
}
Err(err) => {
warn!(%err, "could not discover the applications on this host");
known
}
}
}
async fn explicit_targets(&self, entitlement: &Entitlement) -> Vec<Target> {
self.explicit
.lock()
.await
.iter()
.filter(|(name, application_type)| {
explicit_orderable(entitlement, name, application_type)
})
.map(|(name, application_type)| Target {
name: name.clone(),
application_type: application_type.clone(),
})
.collect()
}
async fn targets_from(
&self,
entitlement: &Entitlement,
sites: &CaddySites,
applications: &[HostApplication],
) -> Vec<Target> {
let ownership = Ownership::resolve(sites, applications, entitlement);
let certifiable = |name: &str| {
let owner = ownership.owner(name)?;
entitlement
.for_type(owner)
.is_some_and(|app| app.may_certify(name))
.then(|| owner.to_owned())
};
let mut names: BTreeSet<String> = sites.hooked_addresses();
{
let mut wanted = self.wanted.lock().await;
wanted.retain(|name| certifiable(name).is_some());
names.extend(wanted.iter().cloned());
}
let mut targets: BTreeMap<String, Target> = names
.into_iter()
.filter(|name| certs::plausible_name(name).is_ok())
.filter_map(|name| {
let application_type = certifiable(&name)?;
Some((
name.clone(),
Target {
name,
application_type,
},
))
})
.collect();
{
let held = self.held.read().await;
self.explicit.lock().await.retain(|name, application_type| {
let wanted_still = !held.contains_key(name) || sites.serves(name);
let within = entitlement
.for_type(application_type)
.is_some_and(|app| app.covers(name));
wanted_still && within
});
}
for target in self.explicit_targets(entitlement).await {
targets.insert(target.name.clone(), target);
}
targets.into_values().collect()
}
async fn collect_one(&self, ctx: &TaskContext, target: &Target) -> Result<(), Failure> {
let name = target.name.as_str();
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 = match client
.certificates_request(
&RequestCertificateArgs::builder()
.csr(csr)
.name(name.to_owned())
.application_type(ApplicationType::from(target.application_type.clone()))
.build(),
)
.await
{
Ok(answer) => answer,
Err(err) => {
let refusal = Refusal::from_error(&err);
let report = Result::<(), _>::Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("asking canopy to certify {name}"))
.unwrap_err();
return Err(Failure { refusal, report });
}
};
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()),
application_type: Some(target.application_type.clone()),
refusal: None,
};
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()),
"applicationType": order.application_type,
"refusal": order.refusal.as_ref().map(|refusal| refusal.kind.as_str()),
"reason": order.reason(),
})
})
.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,
application_type: Option<&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?;
permit(&entitlement, name, application_type, Grant::Dns)?;
let answer = client
.names_register(
&RegisterNameArgs::builder()
.addresses(addresses)
.name(name.to_owned())
.maybe_application_type(
application_type.map(|t| ApplicationType::from(t.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, None).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, None).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();
let application_type = required(&ctx, "type")?;
let entitlement = state.entitlement(&ctx).await?;
permit(&entitlement, &name, Some(&application_type), Grant::Tls)?;
state.note_explicit(&name, application_type).await?;
state.pass(&ctx, false, Some(&name)).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 application_type = required(&ctx, "type")?;
let answer = state
.register_name(&ctx, &name, Some(&application_type), 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, None, 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 awaits_operator(order: &Order) -> bool {
order
.refusal
.as_ref()
.is_some_and(|refusal| refusal.kind.awaits_operator())
}
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(),
}
}
fn central() -> HostApplication {
HostApplication {
type_slug: "tamanu-central".into(),
canonical_hosts: BTreeSet::new(),
service_names: ["api.central.tamanu.internal".to_owned()].into(),
shared_names: BTreeSet::new(),
local_ports: BTreeSet::new(),
}
}
fn sites(all: &[&str], hooked: &[&str]) -> CaddySites {
let routes: Vec<Value> = all
.iter()
.map(|host| {
json!({
"match": [{ "host": [host] }],
"handle": [{ "handler": "reverse_proxy", "dynamic_upstreams": {
"source": "a", "name": "api.central.tamanu.internal", "port": "3000" } }],
})
})
.collect();
let policies = if hooked.is_empty() {
json!([])
} else {
json!([{
"subjects": hooked,
"get_certificate": [{ "via": "http", "url": "http://127.0.0.1:8271/certificate" }],
}])
};
CaddySites::from_config(&json!({"apps": {
"http": { "servers": { "s": { "routes": routes } } },
"tls": { "automation": { "policies": policies } },
}}))
}
fn applications_wire() -> bestool_canopy::schema::Entitlements {
use bestool_canopy::schema::{ApplicationEntitlements, Entitlements};
let app = |type_slug: &str, domain: &str, tls: bool| {
ApplicationEntitlements::builder()
.certificates(vec![])
.domains(vec![domain.to_string()])
.may_manage_dns(false)
.may_manage_tls(tls)
.paused(false)
.registered_names(vec![])
.type_(type_slug.parse().unwrap())
.build()
};
Entitlements::builder()
.applications(vec![
app("tamanu-central", "one.test", true),
app("tamanu-facility", "two.test", false),
])
.certificates(vec![])
.domains(vec![])
.may_manage_dns(false)
.may_manage_tls(false)
.paused(false)
.registered_names(vec![])
.build()
}
fn target(name: &str) -> Target {
Target {
name: name.into(),
application_type: "tamanu-central".into(),
}
}
fn refused(kind: RefusalKind, reason: &str) -> Failure {
Failure {
refusal: Some(Refusal {
kind,
reason: reason.into(),
}),
report: miette!("asking canopy to certify: canopy returned 403 Forbidden: {reason}"),
}
}
fn names(targets: &[Target]) -> Vec<&str> {
targets.iter().map(|t| t.name.as_str()).collect()
}
async fn entitled(state: &CertificateState) -> Entitlement {
state.entitlement.read().await.clone().unwrap()
}
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 none = sites(&["app.elsewhere.test"], &[]);
let targets = state
.targets_from(&entitled(&state).await, &none, &[central()])
.await;
assert!(targets.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_and_to_ownership() {
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;
state.note_wanted("nobody.example.com").await;
let seen = sites(&["app.elsewhere.test", "app.example.com"], &[]);
let targets = state
.targets_from(&entitled(&state).await, &seen, &[central()])
.await;
assert_eq!(
targets,
vec![Target {
name: "app.example.com".into(),
application_type: "tamanu-central".into(),
}]
);
}
#[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 = entitled(&state).await;
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()
},
);
}
let seen = sites(&["app.example.com"], &["app.example.com"]);
let targets = state.targets_from(&entitlement, &seen, &[central()]).await;
assert_eq!(names(&targets), vec!["app.example.com"]);
let kept: Vec<String> = targets.into_iter().map(|t| t.name).collect();
state.prune_orders(&kept).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",
Some("tamanu-central"),
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",
Some("tamanu-central"),
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_failing_any_test_is_not_ordered_for() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let entitlement = entitled(&state).await;
let outside = sites(&["app.elsewhere.test"], &["app.elsewhere.test"]);
assert!(
state
.targets_from(&entitlement, &outside, &[central()])
.await
.is_empty()
);
let unhooked = sites(&["app.example.com"], &[]);
assert!(
state
.targets_from(&entitlement, &unhooked, &[central()])
.await
.is_empty()
);
let hooked = sites(&["app.example.com"], &["app.example.com"]);
assert!(
state
.targets_from(&entitlement, &hooked, &[])
.await
.is_empty()
);
assert!(
state
.targets_from(&entitlement, &CaddySites::default(), &[central()])
.await
.is_empty()
);
let targets = state
.targets_from(&entitlement, &hooked, &[central()])
.await;
assert_eq!(names(&targets), vec!["app.example.com"]);
assert_eq!(targets[0].application_type, "tamanu-central");
}
#[tokio::test]
async fn an_explicit_request_is_kept_until_its_first_chain_and_then_while_caddy_serves_it() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let entitlement = entitled(&state).await;
state
.explicit
.lock()
.await
.insert("pre.example.com".into(), "msupply".into());
let targets = state
.targets_from(&entitlement, &CaddySites::default(), &[central()])
.await;
assert_eq!(
targets,
vec![Target {
name: "pre.example.com".into(),
application_type: "msupply".into(),
}]
);
hold(&state, "pre.example.com", true).await;
let unhooked = sites(&["pre.example.com"], &[]);
let targets = state
.targets_from(&entitlement, &unhooked, &[central()])
.await;
assert_eq!(names(&targets), vec!["pre.example.com"]);
assert_eq!(targets[0].application_type, "msupply");
let targets = state
.targets_from(&entitlement, &CaddySites::default(), &[central()])
.await;
assert!(targets.is_empty());
assert!(state.explicit.lock().await.is_empty());
}
#[tokio::test]
async fn an_explicit_request_is_held_to_the_application_it_names() {
let (_dir, state) = state();
*state.entitlement.write().await = Some(Entitlement::from_wire(&applications_wire()));
let entitlement = entitled(&state).await;
let mut explicit = state.explicit.lock().await;
explicit.insert("a.one.test".into(), "tamanu-central".into());
explicit.insert("c.one.test".into(), "nonesuch".into());
explicit.insert("b.two.test".into(), "tamanu-facility".into());
drop(explicit);
let targets = state
.targets_from(&entitlement, &CaddySites::default(), &[])
.await;
assert_eq!(names(&targets), vec!["a.one.test"]);
let explicit = state.explicit.lock().await;
assert!(explicit.contains_key("b.two.test"));
assert!(!explicit.contains_key("c.one.test"));
}
#[tokio::test]
async fn an_unreadable_caddy_configuration_orders_only_requested_names_and_drops_nothing() {
if delivery::caddy_sites().await.is_ok() {
return;
}
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
let entitlement = entitled(&state).await;
state.note_wanted("hooked.example.com").await;
state
.record_failure(
&target("wait.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
state
.note_explicit("pre.example.com", "tamanu-central".into())
.await
.unwrap();
let (targets, complete) = state.targets(&entitlement, false).await;
assert!(!complete);
assert_eq!(names(&targets), vec!["pre.example.com"]);
assert!(state.wanted.lock().await.contains("hooked.example.com"));
assert!(state.orders.read().await.contains_key("wait.example.com"));
}
#[tokio::test]
async fn a_request_by_command_survives_a_restart() {
let dir = tempfile::tempdir().unwrap();
let before = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
before
.note_explicit("pre.example.com", "msupply".into())
.await
.unwrap();
let after = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
after
.note_explicit("other.example.com", "msupply".into())
.await
.unwrap();
let explicit = after.explicit.lock().await.clone();
assert_eq!(explicit["pre.example.com"], "msupply");
assert!(explicit.contains_key("other.example.com"));
assert_eq!(certs::load_requests(dir.path()).await, explicit);
}
#[tokio::test]
async fn a_requested_name_with_a_chain_is_not_pressing() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
state
.note_explicit("pre.example.com", "tamanu-central".into())
.await
.unwrap();
let long_ago = Timestamp::now() - std::time::Duration::from_secs(3600);
state.orders.write().await.insert(
"pre.example.com".into(),
Order {
state: "issued".into(),
asked_at: Some(long_ago),
..Order::default()
},
);
assert!(state.name_due("pre.example.com", Timestamp::now()).await);
hold(&state, "pre.example.com", true).await;
assert!(!state.name_due("pre.example.com", Timestamp::now()).await);
assert!(!state.pass_due().await);
}
#[tokio::test]
async fn a_pass_that_could_not_read_what_the_host_serves_backs_off_the_next() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
state.note_wanted("app.example.com").await;
assert!(state.pass_due().await);
*state.incomplete.write().await = Some(Timestamp::now());
assert!(!state.pass_due().await);
*state.incomplete.write().await =
Some(Timestamp::now() - std::time::Duration::from_secs(PENDING_RETRY.as_secs() + 1));
assert!(state.pass_due().await);
}
#[tokio::test]
async fn requests_by_command_are_bounded() {
let (_dir, state) = state();
for n in 0..EXPLICIT_LIMIT {
state
.note_explicit(&format!("n{n}.example.com"), "msupply".into())
.await
.unwrap();
}
assert!(
state
.note_explicit("over.example.com", "msupply".into())
.await
.is_err()
);
state
.note_explicit("n0.example.com", "tamanu-central".into())
.await
.unwrap();
assert_eq!(
state.explicit.lock().await["n0.example.com"],
"tamanu-central"
);
}
#[tokio::test]
async fn an_explicit_request_waits_out_its_applications_pause() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
let mut wire = applications_wire();
wire.applications[0].paused = true;
*state.entitlement.write().await = Some(Entitlement::from_wire(&wire));
let paused = entitled(&state).await;
let type_slug = paused.applications[0].type_slug.clone().unwrap();
let name = format!("pre.{}", paused.applications[0].domains[0]);
state
.explicit
.lock()
.await
.insert(name.clone(), type_slug.clone());
assert!(!state.pass_due().await);
let targets = state
.targets_from(&paused, &CaddySites::default(), &[])
.await;
assert!(!names(&targets).contains(&name.as_str()));
assert!(state.explicit.lock().await.contains_key(&name));
*state.entitlement.write().await = Some(Entitlement::from_wire(&applications_wire()));
let lifted = entitled(&state).await;
assert!(state.pass_due().await);
let targets = state
.targets_from(&lifted, &CaddySites::default(), &[])
.await;
assert!(names(&targets).contains(&name.as_str()));
}
#[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());
}
#[tokio::test]
async fn an_undeclared_refusal_is_recorded_and_not_asked_again_between_steady_passes() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
state.note_wanted("app.example.com").await;
assert!(state.pass_due().await);
let long_ago = Timestamp::now() - std::time::Duration::from_secs(3600);
state
.record_failure(
&target("app.example.com"),
refused(RefusalKind::Undeclared, "declare it in canopy"),
long_ago,
)
.await;
let orders = state.orders.read().await;
let order = &orders["app.example.com"];
assert_eq!(order.state, "undeclared");
assert_eq!(order.reason(), Some("declare it in canopy"));
assert_eq!(order.application_type.as_deref(), Some("tamanu-central"));
drop(orders);
assert!(state.wanted.lock().await.contains("app.example.com"));
assert!(!state.name_due("app.example.com", Timestamp::now()).await);
assert!(!state.pass_due().await);
state.note_wanted("app.example.com").await;
assert!(!state.pass_due().await);
}
#[tokio::test]
async fn the_host_applications_are_reused_between_steady_passes() {
let (_dir, state) = state();
*state.applications.write().await = Some(vec![msupply()]);
assert_eq!(state.host_applications(false).await, Some(vec![msupply()]));
}
#[tokio::test]
async fn a_name_a_command_asks_for_is_due_even_while_waiting_on_an_operator() {
let (_dir, state) = state();
for (name, kind) in [
("a.example.com", RefusalKind::Undeclared),
("b.example.com", RefusalKind::Denied),
] {
state
.record_failure(
&target(name),
refused(kind, "an operator's"),
Timestamp::now(),
)
.await;
let now = Timestamp::now();
assert!(!state.target_due(name, false, None, now).await);
assert!(
!state
.target_due(name, false, Some("other.example.com"), now)
.await
);
assert!(state.target_due(name, false, Some(name), now).await);
assert!(state.target_due(name, true, None, now).await);
}
}
#[tokio::test]
async fn a_denied_refusal_is_held_the_same_way_and_keeps_the_chain_in_hand() {
let (_dir, state) = state();
with_entitlement(&state, &["example.com"], true, false).await;
hold(&state, "app.example.com", true).await;
state
.record_failure(
&target("app.example.com"),
refused(RefusalKind::Denied, "an operator denied this"),
Timestamp::now(),
)
.await;
let orders = state.orders.read().await;
assert_eq!(orders["app.example.com"].state, "denied");
assert!(awaits_operator(&orders["app.example.com"]));
drop(orders);
assert!(!state.name_due("app.example.com", Timestamp::now()).await);
assert!(state.serve("app.example.com").await.is_some());
}
#[tokio::test]
async fn an_accepted_request_replaces_the_refusal() {
let (_dir, state) = state();
state
.record_failure(
&target("app.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
assert!(awaits_operator(
&state.orders.read().await["app.example.com"]
));
state.orders.write().await.insert(
"app.example.com".into(),
Order {
state: "pending".into(),
asked_at: Some(Timestamp::now() - std::time::Duration::from_secs(300)),
application_type: Some("tamanu-central".into()),
..Order::default()
},
);
assert!(!awaits_operator(
&state.orders.read().await["app.example.com"]
));
assert!(state.name_due("app.example.com", Timestamp::now()).await);
}
#[tokio::test]
async fn any_other_refusal_is_an_ordinary_failure_with_canopys_reason() {
let (_dir, state) = state();
state
.explicit
.lock()
.await
.insert("app.example.com".into(), "msupply".into());
state
.record_failure(
&target("app.example.com"),
refused(RefusalKind::Other, "this machine hosts tamanu-central"),
Timestamp::now(),
)
.await;
let orders = state.orders.read().await;
let order = &orders["app.example.com"];
assert_eq!(order.state, "refused");
assert!(!awaits_operator(order));
assert_eq!(order.reason(), Some("this machine hosts tamanu-central"));
assert!(order.last_error.is_some());
drop(orders);
assert!(state.explicit.lock().await.is_empty());
state.note_wanted("app.example.com").await;
assert!(
!state
.name_due(
"app.example.com",
Timestamp::now() + std::time::Duration::from_secs(3600)
)
.await
);
}
#[tokio::test]
async fn a_conflict_keeps_the_request_for_the_steady_schedule() {
let (_dir, state) = state();
*state.last_pass.write().await = Some(Timestamp::now());
state
.note_explicit("pre.example.com", "tamanu-central".into())
.await
.unwrap();
let long_ago = Timestamp::now() - std::time::Duration::from_secs(3600);
state
.record_failure(
&target("pre.example.com"),
refused(RefusalKind::Conflict, "no zone covers it"),
long_ago,
)
.await;
assert!(state.explicit.lock().await.contains_key("pre.example.com"));
assert_eq!(
state.orders.read().await["pre.example.com"].reason(),
Some("no zone covers it")
);
assert!(!state.name_due("pre.example.com", Timestamp::now()).await);
assert!(!state.pass_due().await);
assert!(
state
.target_due(
"pre.example.com",
false,
Some("pre.example.com"),
Timestamp::now()
)
.await
);
}
#[tokio::test]
async fn a_failure_with_no_answer_keeps_the_order_state() {
let (_dir, state) = state();
state.orders.write().await.insert(
"app.example.com".into(),
Order {
state: "pending".into(),
..Order::default()
},
);
state
.record_failure(
&target("app.example.com"),
Failure {
refusal: None,
report: miette!("reaching canopy: timed out"),
},
Timestamp::now(),
)
.await;
let orders = state.orders.read().await;
assert!(orders["app.example.com"].pending());
assert!(orders["app.example.com"].refusal.is_none());
assert_eq!(
orders["app.example.com"].reason(),
Some("reaching canopy: timed out")
);
}
#[tokio::test]
async fn a_failure_with_no_answer_keeps_the_refusal() {
let (dir, state) = state();
state
.record_failure(
&target("app.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
state.persist_refusals().await;
state
.record_failure(
&target("app.example.com"),
Failure {
refusal: None,
report: miette!("reaching canopy: timed out"),
},
Timestamp::now(),
)
.await;
state.persist_refusals().await;
let orders = state.orders.read().await;
assert!(awaits_operator(&orders["app.example.com"]));
assert_eq!(orders["app.example.com"].reason(), Some("declare it"));
drop(orders);
assert!(
certs::load_refusals(dir.path())
.await
.contains_key("app.example.com")
);
}
#[tokio::test]
async fn refusals_survive_a_restart() {
let dir = tempfile::tempdir().unwrap();
let before = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
before
.record_failure(
&target("a.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
before
.record_failure(
&target("b.example.com"),
refused(RefusalKind::Denied, "denied"),
Timestamp::now(),
)
.await;
before
.record_failure(
&target("c.example.com"),
refused(RefusalKind::Other, "mismatch"),
Timestamp::now(),
)
.await;
before.persist_refusals().await;
let after = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
after.load_from_disk().await.unwrap();
let orders = after.orders.read().await;
assert_eq!(orders["a.example.com"].state, "undeclared");
assert_eq!(orders["a.example.com"].reason(), Some("declare it"));
assert_eq!(orders["b.example.com"].state, "denied");
assert!(orders["a.example.com"].application_type.is_some());
assert_eq!(orders["c.example.com"].state, "refused");
assert_eq!(orders["c.example.com"].reason(), Some("mismatch"));
assert!(!awaits_operator(&orders["c.example.com"]));
}
#[tokio::test]
async fn a_refusal_for_a_name_no_longer_asked_about_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let state = CertificateState::new(dir.path().to_path_buf(), peer::Permitted::default());
state
.record_failure(
&target("a.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
state.persist_refusals().await;
assert!(!certs::load_refusals(dir.path()).await.is_empty());
state.prune_orders(&[]).await;
state.persist_refusals().await;
assert!(state.orders.read().await.is_empty());
assert!(certs::load_refusals(dir.path()).await.is_empty());
}
#[tokio::test]
async fn the_status_report_carries_the_refusal_the_reason_and_the_type_sent() {
let (_dir, state) = state();
state
.record_failure(
&target("a.example.com"),
refused(RefusalKind::Undeclared, "declare it"),
Timestamp::now(),
)
.await;
let report = state.report().await;
let row = &report["orders"][0];
assert_eq!(row["name"], "a.example.com");
assert_eq!(row["state"], "undeclared");
assert_eq!(row["refusal"], "undeclared");
assert_eq!(row["reason"], "declare it");
assert_eq!(row["applicationType"], "tamanu-central");
}
#[test]
fn only_the_status_endpoint_is_open() {
let (_dir, state) = state();
let task = CanopyNames::new(Arc::new(state));
for endpoint in task.http_endpoints() {
assert_eq!(
endpoint.name == "status",
!endpoint.guarded,
"{}",
endpoint.name
);
}
}
fn msupply() -> HostApplication {
HostApplication {
type_slug: "msupply".into(),
service_names: ["api.msupply.internal".to_owned()].into(),
..central()
}
}
fn two_applications_wire(
central_covers: &str,
central_paused: bool,
) -> bestool_canopy::schema::Entitlements {
use bestool_canopy::schema::{ApplicationEntitlements, Entitlements};
let app = |type_slug: &str, domain: &str, paused: bool| {
ApplicationEntitlements::builder()
.certificates(vec![])
.domains(vec![domain.to_string()])
.may_manage_dns(false)
.may_manage_tls(true)
.paused(paused)
.registered_names(vec![])
.type_(type_slug.parse().unwrap())
.build()
};
Entitlements::builder()
.applications(vec![
app("tamanu-central", central_covers, central_paused),
app("msupply", "supply.test", false),
])
.certificates(vec![])
.domains(vec![])
.may_manage_dns(false)
.may_manage_tls(false)
.paused(false)
.registered_names(vec![])
.build()
}
fn hooked_sites(hosts: &[(&str, &str)]) -> CaddySites {
let routes: Vec<Value> = hosts
.iter()
.map(|(host, upstream)| {
json!({
"match": [{ "host": [host] }],
"handle": [{ "handler": "reverse_proxy", "dynamic_upstreams": {
"source": "a", "name": upstream, "port": "3000" } }],
})
})
.collect();
let subjects: Vec<&str> = hosts.iter().map(|(host, _)| *host).collect();
CaddySites::from_config(&json!({"apps": {
"http": { "servers": { "s": { "routes": routes } } },
"tls": { "automation": { "policies": [{
"subjects": subjects,
"get_certificate": [{ "via": "http", "url": "http://127.0.0.1:8271/certificate" }],
}] } },
}}))
}
#[tokio::test]
async fn each_dns_name_is_requested_for_the_application_it_belongs_to() {
let (_dir, state) = state();
let entitlement = Entitlement::from_wire(&two_applications_wire("tam.test", false));
let seen = hooked_sites(&[
("a.tam.test", "api.central.tamanu.internal"),
("b.supply.test", "api.msupply.internal"),
("c.tam.test", "api.msupply.internal"),
]);
let targets = state
.targets_from(&entitlement, &seen, &[central(), msupply()])
.await;
let by_name: Vec<(&str, &str)> = targets
.iter()
.map(|t| (t.name.as_str(), t.application_type.as_str()))
.collect();
assert_eq!(
by_name,
vec![
("a.tam.test", "tamanu-central"),
("b.supply.test", "msupply")
]
);
}
#[tokio::test]
async fn a_dns_name_declared_by_an_application_is_requested_for_it() {
use bestool_canopy::schema::{ApplicationEntitlements, Entitlements};
let (_dir, state) = state();
let declaring = ApplicationEntitlements::builder()
.certificates(vec![])
.domains(vec!["supply.test".to_string()])
.may_manage_dns(false)
.may_manage_tls(true)
.paused(false)
.registered_names(vec!["decl.supply.test".to_string()])
.type_("msupply".parse().unwrap())
.build();
let entitlement = Entitlement::from_wire(
&Entitlements::builder()
.applications(vec![declaring])
.certificates(vec![])
.domains(vec![])
.may_manage_dns(false)
.may_manage_tls(false)
.paused(false)
.registered_names(vec![])
.build(),
);
let seen = hooked_sites(&[("decl.supply.test", "10.0.0.9:9000")]);
let targets = state
.targets_from(&entitlement, &seen, &[central(), msupply()])
.await;
assert_eq!(
targets,
vec![Target {
name: "decl.supply.test".into(),
application_type: "msupply".into(),
}]
);
}
#[tokio::test]
async fn a_paused_application_is_skipped_and_the_server_stands_down_only_when_all_are() {
let (_dir, state) = state();
let mut entitlement = Entitlement::from_wire(&two_applications_wire("tam.test", true));
let seen = hooked_sites(&[
("a.tam.test", "api.central.tamanu.internal"),
("b.supply.test", "api.msupply.internal"),
]);
let targets = state
.targets_from(&entitlement, &seen, &[central(), msupply()])
.await;
assert_eq!(names(&targets), vec!["b.supply.test"]);
assert_eq!(stands_down(&entitlement), None);
entitlement.applications[1].paused = true;
assert_eq!(
stands_down(&entitlement),
Some("canopy reports this server paused")
);
entitlement.applications[1].paused = false;
entitlement
.applications
.iter_mut()
.for_each(|app| app.may_manage_tls = false);
assert_eq!(
stands_down(&entitlement),
Some("this server holds no TLS grant")
);
}
#[tokio::test]
async fn requesting_outside_the_named_applications_domains_is_refused() {
let (_dir, state) = state();
*state.entitlement.write().await = Some(Entitlement::from_wire(&two_applications_wire(
"tam.test", false,
)));
let task = CanopyNames::new(Arc::new(state));
let endpoints = task.http_endpoints();
let request = endpoints
.iter()
.find(|endpoint| endpoint.name == "request")
.unwrap();
let ask = |name: &str, application_type: Option<&str>| {
let mut ctx = detached_ctx();
ctx.query.insert("name".into(), name.into());
if let Some(application_type) = application_type {
ctx.query.insert("type".into(), application_type.into());
}
(request.handler)(ctx)
};
match ask("x.supply.test", Some("tamanu-central")).await {
TaskEndpointResponse::Error { message, .. } => {
assert!(message.contains("tamanu-central"), "{message}")
}
_ => panic!("expected a refusal"),
}
match ask("x.tam.test", None).await {
TaskEndpointResponse::Error { message, .. } => {
assert!(message.contains("`type`"), "{message}")
}
_ => panic!("expected a refusal"),
}
}
#[test]
fn a_command_is_permitted_by_the_application_it_names() {
let paused = Entitlement::from_wire(&two_applications_wire("tam.test", true));
let err = permit(&paused, "x.tam.test", Some("tamanu-central"), Grant::Tls).unwrap_err();
assert!(err.to_string().contains("paused"), "{err}");
let live = Entitlement::from_wire(&two_applications_wire("tam.test", false));
permit(&live, "x.tam.test", Some("tamanu-central"), Grant::Tls).unwrap();
let err = permit(&live, "x.tam.test", Some("tamanu-central"), Grant::Dns).unwrap_err();
assert!(err.to_string().contains("DNS records"), "{err}");
let err = permit(&live, "x.supply.test", Some("tamanu-central"), Grant::Tls).unwrap_err();
assert!(err.to_string().contains("tamanu-central"), "{err}");
let err = permit(&live, "x.supply.test", Some("nonesuch"), Grant::Tls).unwrap_err();
assert!(err.to_string().contains("tamanu-central, msupply"), "{err}");
permit(&live, "x.supply.test", None, Grant::Tls).unwrap();
let err = permit(&live, "x.other.test", None, Grant::Tls).unwrap_err();
assert!(err.to_string().contains("this server's group"), "{err}");
}
#[tokio::test]
async fn requesting_for_a_paused_application_is_refused() {
let (_dir, state) = state();
*state.entitlement.write().await = Some(Entitlement::from_wire(&two_applications_wire(
"tam.test", true,
)));
let state = Arc::new(state);
let task = CanopyNames::new(state.clone());
let endpoints = task.http_endpoints();
let request = endpoints
.iter()
.find(|endpoint| endpoint.name == "request")
.unwrap();
let mut ctx = detached_ctx();
ctx.query.insert("name".into(), "x.tam.test".into());
ctx.query.insert("type".into(), "tamanu-central".into());
match (request.handler)(ctx).await {
TaskEndpointResponse::Error { message, .. } => {
assert!(message.contains("paused"), "{message}")
}
_ => panic!("expected a refusal"),
}
assert!(state.explicit.lock().await.is_empty());
}
}