Skip to main content

acme_proxy/
script_hook.rs

1//! The contract every `custom` hook in this server runs under: one script, a
2//! cleared environment, JSON on stdin, an exit code for the verdict.
3//!
4//! Three subsystems delegate to an operator-supplied script —
5//! [`signer::custom`](crate::signer::custom) (issue/revoke/crl/renewal_info),
6//! [`filter::custom`](crate::filter::custom) (connection/identifiers) and
7//! [`notify::custom`](crate::notify::custom) (one event). They differ in what
8//! they put in the environment, what they do with stdout, and how they read the
9//! exit code. They differ in nothing else.
10//!
11//! What they shared was a *security* contract — clear the environment so a
12//! script cannot read the RFC 2136 TSIG secret or the SMTP password, restore a
13//! minimal `PATH`, kill the child when its deadline passes — written out three
14//! times, token for token. That is exactly the kind of thing that has to exist
15//! once: a hardening applied to one copy is silently absent from the other two,
16//! and nobody reviewing one of them can tell.
17
18use std::path::{Path, PathBuf};
19use std::process::{Output, Stdio};
20use std::time::Duration;
21
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24use tracing::debug;
25
26/// The `PATH` given to the script, since the server environment is cleared
27/// before each execution. Without it, a script starting with
28/// `#!/usr/bin/env …` would not find its interpreter.
29pub(crate) const DEFAULT_PATH: &str =
30    "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
31
32/// An operator-supplied script, and the budget it runs under.
33#[derive(Debug, Clone)]
34pub(crate) struct ScriptHook {
35    path: PathBuf,
36    args: Vec<String>,
37    timeout: Duration,
38}
39
40/// What to hand the script on stdin.
41pub(crate) enum ScriptStdin<'a> {
42    /// `/dev/null`. The script gets no payload and cannot block on a read.
43    Null,
44    /// A JSON object, written and then closed.
45    Json(&'a serde_json::Value),
46}
47
48/// Why a script produced no verdict at all — as opposed to producing one this
49/// caller did not like, which is [`ScriptOutcome`]'s business.
50#[derive(Debug, thiserror::Error)]
51pub(crate) enum ScriptError {
52    #[error("failed to spawn script {}: {detail}", path.display())]
53    Spawn { path: PathBuf, detail: String },
54    #[error("failed to serialize JSON stdin: {0}")]
55    Serialize(String),
56    #[error("script failed: {0}")]
57    Wait(String),
58    #[error("script timed out after {} ms", .0.as_millis())]
59    Timeout(Duration),
60}
61
62/// What a script answered, plus whether it ever read the question.
63#[derive(Debug)]
64pub(crate) struct ScriptOutcome {
65    pub output: Output,
66    /// Set when writing the JSON payload to the child's stdin failed — in
67    /// practice `EPIPE`, a script that exited without reading it.
68    ///
69    /// Deliberately not an error on its own. A script whose payload is
70    /// `{"hook":"crl"}` and which exits 0 without reading stdin is behaving
71    /// perfectly reasonably, and turning that into a failure would break
72    /// working deployments. It only matters when the script *also* failed, and
73    /// then it matters a great deal: for the signer's `issue` hook, "the script
74    /// never saw the CSR" reads nothing like "the script rejected the CSR".
75    pub stdin_error: Option<String>,
76}
77
78impl ScriptHook {
79    /// Builds a hook, or `None` when no script is configured.
80    ///
81    /// Each subsystem words its own "you enabled this but gave no path" startup
82    /// error, because only it knows which configuration key to name and what to
83    /// tell the operator to remove.
84    pub(crate) fn new(script_path: &str, args: &[String], timeout_ms: u64) -> Option<Self> {
85        if script_path.trim().is_empty() {
86            return None;
87        }
88        Some(Self {
89            path: PathBuf::from(script_path),
90            args: args.to_vec(),
91            timeout: Duration::from_millis(timeout_ms),
92        })
93    }
94
95    pub(crate) fn path(&self) -> &Path {
96        &self.path
97    }
98
99    /// Runs the script with `envs` in an otherwise empty environment.
100    pub(crate) async fn run(
101        &self,
102        envs: &[(&str, &str)],
103        stdin: ScriptStdin<'_>,
104    ) -> Result<ScriptOutcome, ScriptError> {
105        let mut cmd = Command::new(&self.path);
106        cmd.args(&self.args);
107
108        // The script would otherwise inherit the server's entire environment,
109        // which legitimately holds secrets: every `ACME_PROXY_*` configuration
110        // overlay, including the DNS update TSIG key
111        // (`…SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_SECRET`) and
112        // `notify.email.smtp_password`. An operator-supplied script has no
113        // business receiving those, so it starts from nothing and is given only
114        // the documented variables plus a `PATH` without which a
115        // `#!/usr/bin/env bash` script would not start at all.
116        cmd.env_clear();
117        cmd.env("PATH", DEFAULT_PATH);
118        for (key, value) in envs {
119            cmd.env(key, value);
120        }
121
122        // `tokio::time::timeout` below only abandons the future. Without this,
123        // a child that ignores its deadline outlives it — and since these hooks
124        // run once per request or per event, a blocked script would leak one
125        // process per call.
126        cmd.kill_on_drop(true);
127
128        let piped_stdin = matches!(stdin, ScriptStdin::Json(_));
129        cmd.stdin(if piped_stdin {
130            Stdio::piped()
131        } else {
132            Stdio::null()
133        });
134        cmd.stdout(Stdio::piped());
135        cmd.stderr(Stdio::piped());
136
137        let mut child = cmd.spawn().map_err(|error| ScriptError::Spawn {
138            path: self.path.clone(),
139            detail: error.to_string(),
140        })?;
141
142        let mut stdin_error = None;
143        if let ScriptStdin::Json(payload) = stdin {
144            let bytes =
145                serde_json::to_vec(payload).map_err(|e| ScriptError::Serialize(e.to_string()))?;
146            if let Some(mut pipe) = child.stdin.take() {
147                // Recorded rather than propagated; see `ScriptOutcome::stdin_error`.
148                if let Err(error) = pipe.write_all(&bytes).await {
149                    stdin_error = Some(error.to_string());
150                } else if let Err(error) = pipe.flush().await {
151                    stdin_error = Some(error.to_string());
152                }
153                if let Some(detail) = &stdin_error {
154                    debug!(
155                        event = "script_stdin_write_failed",
156                        outcome = "failure",
157                        script_path = %self.path.display(),
158                        error = %detail,
159                    );
160                }
161                // `pipe` drops here, closing the write end.
162            }
163        }
164
165        match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
166            Ok(Ok(output)) => Ok(ScriptOutcome {
167                output,
168                stdin_error,
169            }),
170            Ok(Err(error)) => Err(ScriptError::Wait(error.to_string())),
171            Err(_) => Err(ScriptError::Timeout(self.timeout)),
172        }
173    }
174
175    /// A one-line reason a script's non-zero exit should be reported as.
176    ///
177    /// First non-empty line of stdout, else of stderr, else the exit status —
178    /// prefixed with the stdin failure when there was one, since a script that
179    /// never received its payload failed for a completely different reason than
180    /// one that read it and objected.
181    pub(crate) fn detail(outcome: &ScriptOutcome, noun: &str) -> String {
182        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
183        let stderr = String::from_utf8_lossy(&outcome.output.stderr);
184        let first_line = stdout
185            .lines()
186            .find(|line| !line.trim().is_empty())
187            .or_else(|| stderr.lines().find(|line| !line.trim().is_empty()))
188            .unwrap_or("")
189            .trim();
190
191        let base = if first_line.is_empty() {
192            format!("{noun} exited with status {}", outcome.output.status)
193        } else {
194            first_line.to_string()
195        };
196
197        match &outcome.stdin_error {
198            Some(error) => format!("{base} (the script did not read its input: {error})"),
199            None => base,
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::testutil::{TempDir, write_script};
208
209    fn hook(path: &Path, timeout_ms: u64) -> ScriptHook {
210        ScriptHook::new(&path.display().to_string(), &[], timeout_ms).unwrap()
211    }
212
213    #[test]
214    fn a_blank_script_path_builds_no_hook() {
215        assert!(ScriptHook::new("", &[], 1000).is_none());
216        assert!(ScriptHook::new("   ", &[], 1000).is_none());
217        assert!(ScriptHook::new("/bin/true", &[], 1000).is_some());
218    }
219
220    #[tokio::test]
221    async fn a_missing_script_is_a_spawn_error() {
222        let hook = ScriptHook::new("/nonexistent/script", &[], 1000).unwrap();
223        let error = hook.run(&[], ScriptStdin::Null).await.unwrap_err();
224        assert!(matches!(error, ScriptError::Spawn { .. }), "got {error:?}");
225        assert!(error.to_string().contains("/nonexistent/script"));
226    }
227
228    /// The hardening this module exists to hold in one place: the script must
229    /// not inherit the server's environment, which carries the RFC 2136 TSIG
230    /// key and the SMTP password among other things.
231    #[tokio::test]
232    async fn the_script_does_not_inherit_the_server_environment() {
233        let dir = TempDir::new("script-hook");
234        let script = write_script(
235            &dir,
236            "env.sh",
237            "#!/bin/sh\necho \"MANIFEST=${CARGO_MANIFEST_DIR:-unset}\"\necho \"GIVEN=${ACME_TEST_VAR:-unset}\"\nexit 0\n",
238        );
239
240        let outcome = hook(&script, 5_000)
241            .run(&[("ACME_TEST_VAR", "provided")], ScriptStdin::Null)
242            .await
243            .unwrap();
244        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
245
246        assert!(
247            stdout.contains("MANIFEST=unset"),
248            "the server's own environment must not leak: {stdout}"
249        );
250        assert!(
251            stdout.contains("GIVEN=provided"),
252            "the documented variables must be passed: {stdout}"
253        );
254    }
255
256    #[tokio::test]
257    async fn the_script_receives_its_json_payload_on_stdin() {
258        let dir = TempDir::new("script-hook");
259        let script = write_script(&dir, "cat.sh", "#!/bin/sh\ncat\nexit 0\n");
260
261        let payload = serde_json::json!({ "hook": "issue", "order_id": "abc" });
262        let outcome = hook(&script, 5_000)
263            .run(&[], ScriptStdin::Json(&payload))
264            .await
265            .unwrap();
266
267        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
268        assert!(stdout.contains("\"order_id\":\"abc\""), "{stdout}");
269        assert!(outcome.stdin_error.is_none());
270    }
271
272    /// A script that exits without reading stdin is not a failure — the `crl`
273    /// hook's payload is `{"hook":"crl"}` and ignoring it is reasonable.
274    #[tokio::test]
275    async fn a_script_that_ignores_its_stdin_still_succeeds() {
276        let dir = TempDir::new("script-hook");
277        // Large enough that the write cannot all fit in the pipe buffer, so the
278        // failure is actually observable rather than silently absorbed.
279        let script = write_script(&dir, "ignore.sh", "#!/bin/sh\nexit 0\n");
280
281        let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
282        let outcome = hook(&script, 5_000)
283            .run(&[], ScriptStdin::Json(&payload))
284            .await
285            .unwrap();
286
287        assert!(outcome.output.status.success());
288    }
289
290    #[tokio::test]
291    async fn a_timed_out_script_is_killed_rather_than_left_running() {
292        let dir = TempDir::new("script-hook");
293        let marker = dir.path().join("still-running");
294        let script = write_script(
295            &dir,
296            "slow.sh",
297            &format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
298        );
299
300        let error = hook(&script, 100)
301            .run(&[], ScriptStdin::Null)
302            .await
303            .unwrap_err();
304        assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
305
306        // `kill_on_drop` must have taken the child with the abandoned future;
307        // without it the script would go on to create this file.
308        tokio::time::sleep(Duration::from_millis(1_500)).await;
309        assert!(
310            !marker.exists(),
311            "the script outlived its deadline and kept running"
312        );
313    }
314
315    #[tokio::test]
316    async fn detail_prefers_stdout_then_stderr_then_the_status() {
317        let dir = TempDir::new("script-hook");
318
319        let both = write_script(
320            &dir,
321            "both.sh",
322            "#!/bin/sh\necho 'from stdout'\necho 'from stderr' >&2\nexit 1\n",
323        );
324        let outcome = hook(&both, 5_000)
325            .run(&[], ScriptStdin::Null)
326            .await
327            .unwrap();
328        assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stdout");
329
330        let stderr_only = write_script(
331            &dir,
332            "stderr.sh",
333            "#!/bin/sh\necho 'from stderr' >&2\nexit 1\n",
334        );
335        let outcome = hook(&stderr_only, 5_000)
336            .run(&[], ScriptStdin::Null)
337            .await
338            .unwrap();
339        assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stderr");
340
341        let silent = write_script(&dir, "silent.sh", "#!/bin/sh\nexit 3\n");
342        let outcome = hook(&silent, 5_000)
343            .run(&[], ScriptStdin::Null)
344            .await
345            .unwrap();
346        let detail = ScriptHook::detail(&outcome, "test script");
347        assert!(
348            detail.starts_with("test script exited with status"),
349            "{detail}"
350        );
351    }
352
353    #[tokio::test]
354    async fn detail_says_when_the_script_never_read_its_input() {
355        let outcome = ScriptOutcome {
356            output: std::process::Output {
357                status: Default::default(),
358                stdout: b"bad CSR\n".to_vec(),
359                stderr: Vec::new(),
360            },
361            stdin_error: Some("Broken pipe (os error 32)".to_string()),
362        };
363        let detail = ScriptHook::detail(&outcome, "custom signer script");
364        assert!(detail.contains("bad CSR"), "{detail}");
365        assert!(
366            detail.contains("did not read its input"),
367            "a script that never saw the CSR must not read as one that rejected it: {detail}"
368        );
369    }
370
371    #[tokio::test]
372    async fn the_configured_arguments_are_passed() {
373        let dir = TempDir::new("script-hook");
374        let script = write_script(&dir, "args.sh", "#!/bin/sh\necho \"$1|$2\"\nexit 0\n");
375
376        let hook = ScriptHook::new(
377            &script.display().to_string(),
378            &["first".to_string(), "second".to_string()],
379            5_000,
380        )
381        .unwrap();
382        let outcome = hook.run(&[], ScriptStdin::Null).await.unwrap();
383        assert_eq!(
384            String::from_utf8_lossy(&outcome.output.stdout).trim(),
385            "first|second"
386        );
387    }
388}