use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tracing::{debug, error, info};
use super::{CertificatesExpiringData, ExpiringCertificate, Notifiers, NotifyEvent};
use crate::admin;
use crate::jobs::{JobHandler, JobOutcome, JobQueue, JobSpec};
use crate::sqlite::db::Database;
use crate::sqlite::job::Job;
use crate::sqlite::nonce::now_secs;
use crate::sqlite::order::{Order, UNPARSABLE_NOT_AFTER};
pub const EXPIRY_JOB_KIND: &str = "notify_expiry_digest";
const BACKFILL_BATCH: i64 = 500;
#[derive(Debug, Clone, Copy)]
pub struct ExpirySettings {
lead: Duration,
lead_days: u64,
interval: Duration,
max_entries: i64,
}
impl ExpirySettings {
#[must_use]
pub fn from_config(config: &crate::config::ExpiryNotifyConfig) -> Option<Self> {
if config.lead_days == 0 {
return None;
}
Some(Self {
lead: Duration::from_secs(config.lead_days * 24 * 60 * 60),
lead_days: config.lead_days,
interval: Duration::from_secs(config.interval_days.max(1) * 24 * 60 * 60),
max_entries: i64::try_from(config.max_entries).unwrap_or(i64::MAX).max(1),
})
}
}
pub struct ExpiryDigestJob {
profiles: HashMap<String, ExpirySettings>,
notifiers: Notifiers,
database: Arc<Database>,
queue: JobQueue,
}
impl ExpiryDigestJob {
#[must_use]
pub fn from_profiles(
resolved: &[crate::config::ProfileConfig],
notifiers: Notifiers,
database: Arc<Database>,
queue: JobQueue,
) -> Option<Self> {
let profiles: HashMap<String, ExpirySettings> = resolved
.iter()
.filter_map(|profile| {
ExpirySettings::from_config(&profile.sections.notify.expiry)
.map(|settings| (profile.name.clone(), settings))
})
.collect();
if profiles.is_empty() {
return None;
}
Some(Self {
profiles,
notifiers,
database,
queue,
})
}
async fn backfill(&self, profile: &str) {
let rows = match Order::find_unstamped(profile, BACKFILL_BATCH, &self.database).await {
Ok(rows) => rows,
Err(error) => {
error!(event = "notify_expiry_backfill_failed", outcome = "failure", profile = %profile, error = %error);
return;
}
};
if rows.is_empty() {
return;
}
let mut stamped = 0_u64;
let mut unparsable = 0_u64;
for (id, chain) in rows {
let not_after = leaf_not_after(&chain);
if not_after == UNPARSABLE_NOT_AFTER {
unparsable += 1;
} else {
stamped += 1;
}
if let Err(error) = Order::set_cert_not_after(id, not_after, &self.database).await {
error!(event = "notify_expiry_backfill_failed", outcome = "failure", profile = %profile, order_id = %id, error = %error);
return;
}
}
info!(
event = "notify_expiry_backfilled",
outcome = "success",
profile = %profile,
rows_stamped = stamped,
rows_unparsable = unparsable
);
}
async fn collect(
&self,
profile: &str,
settings: ExpirySettings,
) -> Result<Option<CertificatesExpiringData>, sqlx::Error> {
let now = now_secs();
let query = admin::ExpiringQuery {
profile: Some(profile.to_string()),
before: now.saturating_add(i64::try_from(settings.lead.as_secs()).unwrap_or(0)),
include_superseded: true,
limit: settings.max_entries,
offset: 0,
};
let (entries, total, _hidden) = admin::list_expiring(&query, self.database.clone()).await?;
if entries.is_empty() {
return Ok(None);
}
let certificates = entries
.into_iter()
.map(|entry| ExpiringCertificate {
order_id: entry.order.id.to_string(),
account_id: entry.order.account_id.to_string(),
cert_serial: entry.order.cert_serial.unwrap_or_default(),
identifiers: entry
.order
.identifiers
.into_iter()
.map(|identifier| identifier.value)
.collect(),
not_after: entry.order.cert_not_after.unwrap_or_default(),
days_remaining: entry.days_remaining,
superseded_by: entry.superseded_by,
})
.collect();
Ok(Some(CertificatesExpiringData {
profile: profile.to_string(),
generated_at: now,
lead_days: settings.lead_days,
total,
certificates,
}))
}
async fn reconcile(&self, queue: &JobQueue) {
for profile in self.profiles.keys() {
queue
.enqueue_or_log(JobSpec::now(EXPIRY_JOB_KIND, profile.clone()))
.await;
}
}
}
fn leaf_not_after(chain: &str) -> i64 {
crate::cert::leaf_der_from_chain(chain)
.ok()
.and_then(|der| crate::cert::cert_validity(&der).ok())
.map_or(UNPARSABLE_NOT_AFTER, |(_, not_after)| not_after)
}
#[async_trait]
impl JobHandler for ExpiryDigestJob {
fn kind(&self) -> &'static str {
EXPIRY_JOB_KIND
}
async fn run(&self, job: &Job) -> JobOutcome {
let profile = job.dedup_key.clone();
let Some(settings) = self.profiles.get(&profile).copied() else {
info!(
event = "notify_expiry_digest_retired",
outcome = "success",
profile = %profile
);
return JobOutcome::Done;
};
self.reconcile(&self.queue).await;
self.backfill(&profile).await;
match self.collect(&profile, settings).await {
Ok(None) => debug!(
event = "notify_expiry_digest_skipped",
outcome = "success",
profile = %profile
),
Ok(Some(data)) => {
let listed = data.certificates.len();
let total = data.total;
if let Some(dispatcher) = self.notifiers.get(&profile) {
dispatcher
.dispatch(NotifyEvent::CertificatesExpiring(data))
.await;
info!(
event = "notify_expiry_digest_sent",
outcome = "success",
profile = %profile,
certificates_listed = listed,
certificates_total = total
);
}
}
Err(error) => {
error!(event = "notify_expiry_digest_failed", outcome = "failure", profile = %profile, error = %error);
}
}
JobOutcome::Reschedule(settings.interval)
}
async fn recover(&self, queue: &JobQueue) {
self.reconcile(queue).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ExpiryNotifyConfig, JobsConfig};
use crate::notify::{BackendSlot, NotifyDispatcher};
use crate::testutil::account_id;
use serde_json::json;
use std::collections::HashMap;
const DAY: i64 = 24 * 60 * 60;
fn row(profile: &str) -> Job {
Job {
id: crate::sqlite::id::mint(),
kind: EXPIRY_JOB_KIND.to_string(),
dedup_key: profile.to_string(),
payload: json!({}),
status: "running".to_string(),
run_at: now_secs(),
attempts: 1,
max_attempts: 5,
deadline: None,
lease_until: None,
lease_owner: None,
last_error: None,
created_at: now_secs(),
updated_at: now_secs(),
}
}
fn settings(lead_days: u64) -> ExpiryNotifyConfig {
ExpiryNotifyConfig {
lead_days,
..ExpiryNotifyConfig::default()
}
}
async fn harness(lead_days: u64) -> (ExpiryDigestJob, Arc<Database>) {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let queue = JobQueue::new(database.clone(), &JobsConfig::default());
let dispatchers: super::super::DispatcherMap = HashMap::from([(
"default".to_string(),
Arc::new(NotifyDispatcher::new(
"default",
Vec::<BackendSlot>::new(),
queue.clone(),
)),
)]);
let (_tx, notifiers) = super::super::notifiers_channel(dispatchers);
let job = ExpiryDigestJob {
profiles: HashMap::from([(
"default".to_string(),
ExpirySettings::from_config(&settings(lead_days)).unwrap(),
)]),
notifiers,
database: database.clone(),
queue,
};
(job, database)
}
async fn issued(
db: &Database,
account: uuid::Uuid,
names: &[&str],
not_after_days: i64,
) -> Order {
crate::testutil::issued_order(db, "default", account, names, not_after_days).await
}
#[tokio::test]
async fn a_digest_lists_what_is_expiring() {
let (job, db) = harness(14).await;
let acct = account_id(&db).await;
let soon = issued(&db, acct, &["soon.example.com"], 3).await;
issued(&db, acct, &["later.example.com"], 60).await;
Order::set_cert_not_after(soon.id, now_secs() + 3 * DAY + DAY / 2, &db)
.await
.unwrap();
let data = job
.collect("default", job.profiles["default"])
.await
.unwrap()
.expect("something is expiring");
assert_eq!(data.total, 1);
assert_eq!(data.lead_days, 14);
assert_eq!(data.certificates.len(), 1);
assert_eq!(
data.certificates[0].identifiers,
vec!["soon.example.com".to_string()]
);
assert_eq!(
data.certificates[0].days_remaining, 3,
"floored, not rounded"
);
assert!(data.certificates[0].superseded_by.is_none());
}
#[tokio::test]
async fn nothing_expiring_produces_no_digest_at_all() {
let (job, db) = harness(14).await;
let acct = account_id(&db).await;
issued(&db, acct, &["fine.example.com"], 60).await;
assert!(
job.collect("default", job.profiles["default"])
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn the_backfill_stamps_once_and_records_an_unparsable_chain() {
let (job, db) = harness(14).await;
let acct = account_id(&db).await;
let good = issued(&db, acct, &["good.example.com"], 30).await;
let bad = issued(&db, acct, &["bad.example.com"], 30).await;
sqlx::query("UPDATE orders SET cert_not_after = NULL WHERE id IN (?, ?);")
.bind(good.id)
.bind(bad.id)
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE orders SET certificate = 'not a pem' WHERE id = ?;")
.bind(bad.id)
.execute(&db.pool)
.await
.unwrap();
job.backfill("default").await;
let good = Order::find_by_id(good.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(good.cert_not_after.unwrap() > now_secs());
let bad = Order::find_by_id(bad.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(bad.cert_not_after, Some(UNPARSABLE_NOT_AFTER));
assert!(
Order::find_unstamped("default", 10, &db)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn a_row_for_an_unmounted_profile_retires() {
let (job, _db) = harness(14).await;
assert!(matches!(job.run(&row("gone")).await, JobOutcome::Done));
}
#[tokio::test]
async fn a_failing_pass_reschedules_rather_than_retiring() {
let (job, db) = harness(14).await;
db.pool.close().await;
assert!(matches!(
job.run(&row("default")).await,
JobOutcome::Reschedule(_)
));
}
#[tokio::test]
async fn recovery_queues_one_row_per_profile_however_often_it_runs() {
let (job, db) = harness(14).await;
job.recover(&job.queue).await;
job.recover(&job.queue).await;
job.recover(&job.queue).await;
assert_eq!(Job::count_live(EXPIRY_JOB_KIND, &db).await.unwrap(), 1);
}
#[test]
fn a_zero_lead_configures_no_digest() {
assert!(ExpirySettings::from_config(&settings(0)).is_none());
assert!(ExpirySettings::from_config(&settings(1)).is_some());
}
#[test]
fn the_interval_is_floored_at_a_day() {
let config = ExpiryNotifyConfig {
lead_days: 7,
interval_days: 0,
..ExpiryNotifyConfig::default()
};
let settings = ExpirySettings::from_config(&config).unwrap();
assert_eq!(settings.interval, Duration::from_secs(24 * 60 * 60));
}
}