Skip to main content

acme_proxy/config/types/
notify.rs

1//! `[notify]` — the operator-notification backends and their event filters.
2//!
3//! Re-exported flat from [`super`], so nothing outside this directory names
4//! the submodule.
5
6use std::collections::BTreeMap;
7
8use serde::Deserialize;
9
10use super::empty_string_is_no_values;
11
12/// Notification-subsystem configuration: which backends are active, and each
13/// backend's own settings. See [`crate::notify`].
14#[derive(Debug, Clone, Default, Deserialize)]
15#[serde(default)]
16pub struct NotifyConfig {
17    /// Which backends are active: `"email"`, `"webhook"`, `"custom"`.
18    /// Empty (default) means no notifications are sent at all.
19    #[serde(deserialize_with = "empty_string_is_no_values")]
20    pub enabled: Vec<String>,
21    pub email: EmailNotifyConfig,
22    /// The periodic expiry digest — off until `lead_days` is non-zero.
23    pub expiry: ExpiryNotifyConfig,
24    /// Which of `webhook`'s entries to POST to, and in what order, when
25    /// `webhook` is listed in `enabled` — the same shape as `custom_enabled`
26    /// below, and resolved by the same [`resolve_named_entries`].
27    ///
28    /// [`resolve_named_entries`]: crate::config::resolve_named_entries
29    #[serde(deserialize_with = "empty_string_is_no_values")]
30    pub webhook_enabled: Vec<String>,
31    /// Named HTTP webhook targets, selected and ordered by `webhook_enabled`.
32    /// Each name must match `^[a-z0-9-]+$`, same as `custom`'s entries and for
33    /// the same reason.
34    pub webhook: BTreeMap<String, WebhookNotifyConfig>,
35    /// Which of `custom`'s entries to run, and in what order, when `custom`
36    /// is listed in `enabled` — the same shape as `webhook_enabled`.
37    #[serde(deserialize_with = "empty_string_is_no_values")]
38    pub custom_enabled: Vec<String>,
39    /// Named external script/webhook configs, selected and ordered by
40    /// `custom_enabled`. Each name must match `^[a-z0-9-]+$`, same as
41    /// `filter.custom`'s entries and for the same reason (see
42    /// [`valid_config_key_name`](crate::config::valid_config_key_name)).
43    pub custom: BTreeMap<String, CustomNotifyConfig>,
44    /// Filesystem directory to look for template overrides in, checked
45    /// per-template-file before falling back to the compiled-in defaults.
46    /// Empty (default) means compiled-in defaults only.
47    pub template_dir: String,
48}
49/// Configuration for the `email` notify backend (SMTP via `lettre`).
50#[derive(Debug, Clone, Deserialize)]
51#[serde(default)]
52pub struct EmailNotifyConfig {
53    pub smtp_host: String,
54    pub smtp_port: u16,
55    pub smtp_username: String,
56    pub smtp_password: String,
57    /// `"starttls"` (default) | `"tls"` | `"none"`.
58    pub smtp_security: String,
59    pub from: String,
60    #[serde(deserialize_with = "empty_string_is_no_values")]
61    pub to: Vec<String>,
62    /// Which lifecycle events this backend reacts to. Defaults to all of
63    /// them, listed explicitly rather than relying on "empty means all": every
64    /// other list field in this codebase treats empty as *off*, so reusing
65    /// that convention here would silently mean "no events" the moment an
66    /// operator writes `events = []` expecting "all".
67    #[serde(deserialize_with = "empty_string_is_no_values")]
68    pub events: Vec<String>,
69    pub timeout_ms: u64,
70}
71
72impl Default for EmailNotifyConfig {
73    fn default() -> Self {
74        Self {
75            smtp_host: String::new(),
76            smtp_port: 587,
77            smtp_username: String::new(),
78            smtp_password: String::new(),
79            smtp_security: "starttls".to_string(),
80            from: String::new(),
81            to: Vec::new(),
82            events: all_notify_events(),
83            timeout_ms: 5000,
84        }
85    }
86}
87/// `[notify.expiry]` — the periodic digest of certificates approaching expiry
88/// (see [`crate::notify::expiry`]).
89///
90/// One message per profile per `interval_days`, listing what lapses inside
91/// `lead_days`, and **not** one message per certificate: a renewal is a new
92/// order, so the certificate it replaced still expires on schedule and a
93/// per-certificate reminder would tell an operator about every certificate the
94/// CA has ever issued. The digest carries the superseded ones annotated
95/// instead, so the un-renewed rows are what stands out.
96#[derive(Debug, Clone, Deserialize)]
97#[serde(default)]
98pub struct ExpiryNotifyConfig {
99    /// How far ahead to look, in days. **`0` (the default) is off** — the job
100    /// is never registered at all, the shape `audit.retention_days` and
101    /// `jobs.retention_days` already use for "do not schedule this sweep".
102    pub lead_days: u64,
103    /// How often the digest is sent, in days. There is deliberately no
104    /// per-certificate rate limit beside it: the digest *is* the rate limit,
105    /// which is what the per-certificate shape needed a stored "last reminded"
106    /// timestamp for.
107    pub interval_days: u64,
108    /// The most certificates one message lists. The count of matches is
109    /// carried whole regardless, so a truncated digest still says how many it
110    /// did not name — a per-certificate message was self-limiting and this is
111    /// not, and ten thousand expiring at once is an unreadable mail and a
112    /// webhook body a provider refuses.
113    pub max_entries: usize,
114}
115
116impl Default for ExpiryNotifyConfig {
117    fn default() -> Self {
118        Self {
119            lead_days: 0,
120            interval_days: 7,
121            max_entries: 50,
122        }
123    }
124}
125/// Configuration for one named `webhook` notify target: an HTTP request whose
126/// URL, method, headers and body are all the operator's to state.
127///
128/// This is what makes a chat provider configuration rather than a backend —
129/// Slack, Mattermost, Teams, Telegram and Matrix differ only in these four
130/// values. See [`crate::notify::webhook`].
131#[derive(Debug, Clone, Deserialize)]
132#[serde(default)]
133pub struct WebhookNotifyConfig {
134    /// The endpoint to call. Required once the entry is selected; it routinely
135    /// carries the credential in its path (a Slack hook id, a Telegram bot
136    /// token), which is why nothing ever logs more of it than the host.
137    pub url: String,
138    /// `"POST"` (default), `"PUT"` or `"PATCH"`. Matrix's send-message API is
139    /// the reason this is configurable at all.
140    pub method: String,
141    /// Extra request headers, e.g. `Authorization`. Applied after the
142    /// defaults, so an entry may override `content-type`.
143    pub headers: BTreeMap<String, String>,
144    /// The request body, as a MiniJinja template. `message` (the rendered
145    /// `webhook/<event>.j2`), `hook` and every field of the event's own
146    /// payload are in scope.
147    ///
148    /// The default is the shape Slack, Mattermost and Teams all accept. Note
149    /// the `tojson` filter: `.j2` templates have auto-escaping off, so a
150    /// message holding a quote or a newline needs it to stay valid JSON.
151    pub body: String,
152    #[serde(deserialize_with = "empty_string_is_no_values")]
153    pub events: Vec<String>,
154    pub timeout_ms: u64,
155}
156
157/// The default `body`: the `{"text": …}` payload Slack, Mattermost and
158/// Microsoft Teams incoming webhooks all accept, so those three need a `url`
159/// and nothing else.
160pub const DEFAULT_WEBHOOK_BODY: &str = r#"{"text": {{ message | tojson }}}"#;
161
162impl Default for WebhookNotifyConfig {
163    fn default() -> Self {
164        Self {
165            url: String::new(),
166            method: "POST".to_string(),
167            headers: BTreeMap::new(),
168            body: DEFAULT_WEBHOOK_BODY.to_string(),
169            events: all_notify_events(),
170            timeout_ms: 5000,
171        }
172    }
173}
174/// Configuration for one named `custom` notify script/webhook — the
175/// notify-side counterpart of `CustomFilterConfig`/`CustomSignerConfig`.
176#[derive(Debug, Clone, Deserialize)]
177#[serde(default)]
178pub struct CustomNotifyConfig {
179    pub script_path: String,
180    pub timeout_ms: u64,
181    #[serde(deserialize_with = "empty_string_is_no_values")]
182    pub args: Vec<String>,
183    #[serde(deserialize_with = "empty_string_is_no_values")]
184    pub events: Vec<String>,
185}
186
187impl Default for CustomNotifyConfig {
188    fn default() -> Self {
189        Self {
190            script_path: String::new(),
191            timeout_ms: 5000,
192            args: Vec::new(),
193            events: all_notify_events(),
194        }
195    }
196}
197
198/// The lifecycle events the `notify` subsystem can react to. Kept here rather
199/// than in `src/notify` so each backend's `events` default (below) can list
200/// them without creating a dependency from `config` on `notify`.
201///
202/// `certificates_expiring` is the odd one and worth recognising as such: the
203/// other six are things that just happened to one account, order or
204/// certificate, where it is a periodic digest about however many certificates
205/// are approaching expiry. It reaches a backend only once
206/// `notify.expiry.lead_days` is non-zero, so listing it here does not start
207/// sending anything on its own.
208pub const ALL_NOTIFY_EVENTS: [&str; 7] = [
209    "profile_mounted",
210    "account_created",
211    "account_deactivated",
212    "certificate_issued",
213    "certificate_revoked",
214    "challenge_failed",
215    "certificates_expiring",
216];
217
218fn all_notify_events() -> Vec<String> {
219    ALL_NOTIFY_EVENTS.iter().map(|s| s.to_string()).collect()
220}