acme_proxy/notify/
custom.rs1use 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#[derive(Debug)]
16pub struct CustomScriptNotifier {
17 hook: ScriptHook,
18}
19
20impl CustomScriptNotifier {
21 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 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 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 #[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 #[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 #[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::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}