Skip to main content

acme_proxy/notify/
custom.rs

1//! The `custom` notify backend: shells out to an external script or webhook
2//! wrapper, on the same contract as [`crate::filter::custom`] and
3//! [`crate::signer::custom`] — env vars plus JSON on stdin, exit code decides
4//! outcome. This is what lets an operator wire up a channel this server has
5//! no built-in support for (Slack, PagerDuty, …) without a code change.
6
7use async_trait::async_trait;
8use tracing::info;
9
10use super::{NotifyBackend, NotifyError, NotifyEvent};
11use crate::config::CustomNotifyConfig;
12use crate::script_hook::{ScriptHook, ScriptStdin};
13
14/// Executes an external script/binary to deliver one notification.
15#[derive(Debug)]
16pub struct CustomScriptNotifier {
17    hook: ScriptHook,
18}
19
20impl CustomScriptNotifier {
21    /// Validates the configuration and creates the backend.
22    pub fn from_config(cfg: &CustomNotifyConfig) -> anyhow::Result<Self> {
23        let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, cfg.timeout_ms) else {
24            anyhow::bail!(
25                "notify.custom is enabled but notify.custom.script_path is empty; \
26                 provide a path to an executable script or remove `custom` from \
27                 notify.enabled"
28            );
29        };
30
31        info!(
32            event = "notify_custom_loaded",
33            outcome = "success",
34            script_path = %hook.path().display(),
35            timeout_ms = cfg.timeout_ms,
36            args = ?cfg.args,
37        );
38
39        Ok(Self { hook })
40    }
41
42    /// Runs the script; a non-zero exit is a delivery failure.
43    ///
44    /// Every failure here is **retryable**, unlike the two built-in backends,
45    /// which can tell a bad configuration from a bad minute. A script has no way
46    /// to say "never try this again": the contract is an exit code, and giving
47    /// one of them that meaning would be a new contract for every operator
48    /// script already written against this hook — the `signer.custom` backend's
49    /// reserved exit `3`, but retrofitted. So a script that keeps failing spends
50    /// its `jobs.max_attempts` and is then abandoned with a log line, which is
51    /// what it did before this queue existed, only later and after four more
52    /// chances.
53    async fn run_script(
54        &self,
55        envs: &[(&str, &str)],
56        payload: &serde_json::Value,
57    ) -> Result<(), NotifyError> {
58        let outcome = self
59            .hook
60            .run(envs, ScriptStdin::Json(payload))
61            .await
62            .map_err(|error| NotifyError::new(format!("custom notify {error}")))?;
63
64        if outcome.output.status.success() {
65            Ok(())
66        } else {
67            Err(NotifyError::new(ScriptHook::detail(
68                &outcome,
69                "custom notify script",
70            )))
71        }
72    }
73}
74
75#[async_trait]
76impl NotifyBackend for CustomScriptNotifier {
77    fn name(&self) -> &'static str {
78        "custom"
79    }
80
81    async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError> {
82        let client_ip = event.client_ip().unwrap_or_default();
83        let account_id = event.account_id().unwrap_or_default();
84        let order_id = event.order_id().unwrap_or_default();
85        let cert_serial = event.cert_serial().unwrap_or_default();
86        let identifiers = event.identifiers_joined();
87        let profile = event.profile();
88        let kind = event.kind();
89
90        let envs = [
91            ("ACME_NOTIFY_HOOK", kind),
92            ("ACME_NOTIFY_PROFILE", profile),
93            ("ACME_NOTIFY_CLIENT_IP", client_ip),
94            ("ACME_NOTIFY_ACCOUNT_ID", account_id),
95            ("ACME_NOTIFY_ORDER_ID", order_id),
96            ("ACME_NOTIFY_CERT_SERIAL", cert_serial),
97            ("ACME_NOTIFY_IDENTIFIERS", identifiers.as_str()),
98        ];
99
100        self.run_script(&envs, &event.payload()).await
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::notify::{CertificateIssuedData, ProfileMountedData};
108    use crate::testutil::TempDir;
109    use std::time::Duration;
110
111    /// Writes an executable script and returns the configuration pointing at it.
112    /// The `ETXTBSY` reasoning lives in `crate::testutil::write_script`.
113    fn write_script(dir: &TempDir, name: &str, body: &str) -> CustomNotifyConfig {
114        let script_path = crate::testutil::write_script(dir, name, body);
115        CustomNotifyConfig {
116            script_path: script_path.to_str().unwrap().to_string(),
117            ..Default::default()
118        }
119    }
120
121    fn profile_mounted() -> NotifyEvent {
122        NotifyEvent::ProfileMounted(ProfileMountedData {
123            profile: "default".to_string(),
124        })
125    }
126
127    #[test]
128    fn missing_script_path_bails() {
129        let cfg = CustomNotifyConfig {
130            script_path: "  ".to_string(),
131            ..Default::default()
132        };
133        assert!(CustomScriptNotifier::from_config(&cfg).is_err());
134    }
135
136    /// A configured path that vanished between startup and the first event —
137    /// an operator repackaging the deployment, say. Spawning fails at delivery
138    /// time and reports which script, rather than panicking in a background
139    /// dispatch task.
140    #[tokio::test]
141    async fn a_script_that_cannot_be_spawned_is_reported() {
142        let backend = CustomScriptNotifier::from_config(&CustomNotifyConfig {
143            script_path: "/nonexistent/notify.sh".to_string(),
144            ..Default::default()
145        })
146        .unwrap();
147
148        assert_eq!(backend.name(), "custom");
149        let error = backend
150            .send(&profile_mounted())
151            .await
152            .expect_err("there is no such script");
153        assert!(
154            error.to_string().contains("failed to spawn script")
155                && error.to_string().contains("/nonexistent/notify.sh"),
156            "{error}"
157        );
158    }
159
160    /// A script that exits non-zero saying nothing still has to produce a
161    /// message — "it failed" with no detail is worse than the status code.
162    #[tokio::test]
163    async fn a_silent_failure_reports_its_exit_status() {
164        let dir = TempDir::new("notify-custom");
165        let cfg = write_script(&dir, "silent.sh", "#!/bin/sh\ncat > /dev/null\nexit 3\n");
166        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
167
168        let error = backend
169            .send(&profile_mounted())
170            .await
171            .expect_err("exit 3 is a failure");
172        assert!(error.to_string().contains("exited with status"), "{error}");
173    }
174
175    #[tokio::test]
176    async fn passing_script_delivers() {
177        let dir = TempDir::new("notify-custom");
178        let cfg = write_script(&dir, "pass.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
179        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
180        assert!(backend.send(&profile_mounted()).await.is_ok());
181    }
182
183    #[tokio::test]
184    async fn failing_script_reports_the_first_output_line() {
185        let dir = TempDir::new("notify-custom");
186        let cfg = write_script(
187            &dir,
188            "fail.sh",
189            "#!/bin/sh\ncat > /dev/null\necho \"webhook rejected\"\nexit 1\n",
190        );
191        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
192        let error = backend.send(&profile_mounted()).await.unwrap_err();
193        assert_eq!(error.to_string(), "webhook rejected");
194    }
195
196    #[tokio::test]
197    async fn script_receives_env_and_stdin() {
198        let dir = TempDir::new("notify-custom");
199        let script_content = r#"#!/bin/sh
200payload=$(cat)
201if [ "$ACME_NOTIFY_HOOK" != "certificate_issued" ]; then
202    echo "wrong hook: $ACME_NOTIFY_HOOK"
203    exit 1
204fi
205if [ "$ACME_NOTIFY_IDENTIFIERS" != "example.com" ]; then
206    echo "wrong identifiers: $ACME_NOTIFY_IDENTIFIERS"
207    exit 1
208fi
209case "$payload" in
210    *'"hook":"certificate_issued"'*) exit 0 ;;
211    *) echo "stdin missing hook tag: $payload"; exit 1 ;;
212esac
213"#;
214        let cfg = write_script(&dir, "check_env.sh", script_content);
215        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
216
217        let event = NotifyEvent::CertificateIssued(CertificateIssuedData {
218            profile: "default".to_string(),
219            order_id: "ord-1".to_string(),
220            account_id: "acc-1".to_string(),
221            cert_serial: "AA:BB".to_string(),
222            identifiers: vec!["example.com".to_string()],
223            client_ip: None,
224        });
225        assert!(backend.send(&event).await.is_ok());
226    }
227
228    /// `CARGO_MANIFEST_DIR` acts as a canary: cargo always places it in the
229    /// test binary's environment, so its presence on the child side would
230    /// mean full inheritance rather than the documented `ACME_NOTIFY_*` set.
231    #[tokio::test]
232    async fn the_script_does_not_inherit_the_server_environment() {
233        assert!(
234            std::env::var_os("CARGO_MANIFEST_DIR").is_some(),
235            "the canary must exist in the parent, otherwise the test proves nothing"
236        );
237
238        let dir = TempDir::new("notify-custom");
239        let cfg = write_script(
240            &dir,
241            "env_leak.sh",
242            r#"#!/bin/sh
243cat > /dev/null
244if [ -n "$CARGO_MANIFEST_DIR" ]; then
245    echo "inherited CARGO_MANIFEST_DIR=$CARGO_MANIFEST_DIR"
246    exit 1
247fi
248if [ -z "$PATH" ]; then
249    echo "no PATH"
250    exit 1
251fi
252exit 0
253"#,
254        );
255        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
256        assert!(backend.send(&profile_mounted()).await.is_ok());
257    }
258
259    /// `tokio::time::timeout` only abandons the future: without
260    /// `kill_on_drop`, the child survives and, since one is spawned per
261    /// event, a blocked script would accumulate one per notification.
262    #[tokio::test]
263    async fn a_timed_out_script_is_killed_rather_than_left_running() {
264        let dir = TempDir::new("notify-custom");
265        let marker = dir.path().join("survived");
266        let cfg = CustomNotifyConfig {
267            timeout_ms: 50,
268            ..write_script(
269                &dir,
270                "slow.sh",
271                &format!(
272                    "#!/bin/sh\ncat > /dev/null\nsleep 1\ntouch {}\n",
273                    marker.to_str().unwrap()
274                ),
275            )
276        };
277        let backend = CustomScriptNotifier::from_config(&cfg).unwrap();
278
279        let error = backend.send(&profile_mounted()).await.unwrap_err();
280        assert!(error.to_string().contains("timed out"));
281
282        tokio::time::sleep(Duration::from_millis(1_800)).await;
283        assert!(
284            !marker.exists(),
285            "the script survived the timeout and continued executing"
286        );
287    }
288}