use std::process::Stdio;
use futures::future::BoxFuture;
use super::{DnsProvider, TxtRecord, sanitize_upstream};
const HOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
const MAX_STDERR_BYTES: usize = 8 * 1024;
const SECRET_ENV_MARKERS: [&str; 7] = [
"TOKEN",
"SECRET",
"KEY",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"AUTH",
];
const MIN_REDACTABLE_ENV_VALUE: usize = 8;
pub struct ExecProvider {
command: Vec<String>,
secrets: Vec<super::SecretString>,
env_override: Option<Vec<(String, String)>>,
}
impl ExecProvider {
#[must_use]
pub const fn new(command: Vec<String>) -> Self {
Self {
command,
secrets: Vec::new(),
env_override: None,
}
}
#[must_use]
pub fn with_secrets(mut self, secrets: Vec<super::SecretString>) -> Self {
self.secrets = secrets;
self
}
#[cfg(all(test, unix))]
fn with_env(mut self, vars: Vec<(String, String)>) -> Self {
self.env_override = Some(vars);
self
}
fn secrets(&self) -> Vec<String> {
let inherited = self.env_override.as_ref().map_or_else(
|| {
let vars: Vec<(String, String)> = std::env::vars_os()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
.collect();
secret_env_values(vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))
},
|vars| secret_env_values(vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))),
);
self.secrets
.iter()
.map(|s| super::SecretString::expose(s).to_owned())
.chain(inherited)
.collect()
}
async fn run(&self, action: &str, record: &TxtRecord) -> Result<(), String> {
let (program, leading) = self
.command
.split_first()
.ok_or_else(|| "[server.tls.acme.dns] exec command is empty".to_owned())?;
let mut cmd = tokio::process::Command::new(program);
cmd.args(leading)
.arg(action)
.arg(&record.fqdn)
.arg(&record.value)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| {
format!(
"could not run the DNS-01 exec hook `{program}`: {e}. Check that \
[server.tls.acme.dns] command names an executable file this process can run"
)
})?;
let mut pipe = child
.stderr
.take()
.ok_or_else(|| format!("the DNS-01 exec hook `{program}` had no stderr pipe"))?;
let finished = tokio::time::timeout(HOOK_TIMEOUT, async {
let mut stderr = Vec::new();
let drain = read_bounded(&mut pipe, &mut stderr, MAX_STDERR_BYTES);
let ((), status) = tokio::join!(drain, child.wait());
(stderr, status)
})
.await;
let (stderr, status) = finished.map_err(|_| {
format!(
"the DNS-01 exec hook `{program}` did not finish within {}s while running \
`{action}` for TXT {}; it was killed and the order failed",
HOOK_TIMEOUT.as_secs(),
record.fqdn
)
})?;
let status = status
.map_err(|e| format!("could not wait for the DNS-01 exec hook `{program}`: {e}"))?;
if status.success() {
return Ok(());
}
let secrets = self.secrets();
let borrowed: Vec<&str> = secrets.iter().map(String::as_str).collect();
Err(format!(
"the DNS-01 exec hook `{program}` failed `{action}` for TXT {} ({}){}",
record.fqdn,
describe_status(status),
stderr_excerpt(&stderr, &borrowed)
))
}
}
async fn read_bounded<R>(pipe: &mut R, sink: &mut Vec<u8>, cap: usize)
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt as _;
let mut chunk = [0_u8; 4096];
loop {
match pipe.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(n) => {
let room = cap.saturating_sub(sink.len());
if room > 0 {
sink.extend_from_slice(&chunk[..n.min(room)]);
}
}
}
}
}
fn secret_env_values<'a>(vars: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<String> {
vars.into_iter()
.filter(|(name, value)| {
value.len() >= MIN_REDACTABLE_ENV_VALUE && {
let name = name.to_ascii_uppercase();
SECRET_ENV_MARKERS.iter().any(|m| name.contains(m))
}
})
.map(|(_, value)| value.to_owned())
.collect()
}
fn describe_status(status: std::process::ExitStatus) -> String {
status.code().map_or_else(
|| "terminated by a signal".to_owned(),
|c| format!("exit status {c}"),
)
}
fn stderr_excerpt(stderr: &[u8], secrets: &[&str]) -> String {
let safe = sanitize_upstream(&String::from_utf8_lossy(stderr), secrets);
if safe.is_empty() {
return String::new();
}
format!(": {safe}")
}
impl DnsProvider for ExecProvider {
fn name(&self) -> &'static str {
"exec"
}
fn upsert_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>> {
Box::pin(async move { self.run("present", record).await })
}
fn delete_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>> {
Box::pin(async move { self.run("cleanup", record).await })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record() -> TxtRecord {
TxtRecord::new("myapp.com", "challenge-value")
}
#[cfg(unix)]
#[tokio::test]
async fn a_zero_exit_publishes_the_record() {
let provider = ExecProvider::new(vec!["/usr/bin/env".to_owned(), "true".to_owned()]);
assert!(provider.upsert_txt(&record()).await.is_ok());
assert!(provider.delete_txt(&record()).await.is_ok());
}
#[cfg(unix)]
#[tokio::test]
async fn the_hook_receives_action_fqdn_and_value_as_separate_arguments() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
r#"test "$1" = present && test "$2" = _acme-challenge.myapp.com && test "$3" = challenge-value && test "$#" = 3"#
.to_owned(),
"hook".to_owned(),
]);
provider
.upsert_txt(&record())
.await
.expect("the hook must see exactly present, the fqdn, and the value");
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
r#"test "$1" = cleanup && test "$#" = 3"#.to_owned(),
"hook".to_owned(),
]);
provider
.delete_txt(&record())
.await
.expect("cleanup action");
}
#[cfg(unix)]
#[tokio::test]
async fn the_documented_hook_script_branches_correctly() {
fn documented_hook(expect: &str) -> ExecProvider {
let script = format!(
r#"
[ "$1" = present ] && ACTION="add $2. 60 TXT \"$3\"" || ACTION="delete $2. TXT \"$3\""
case "$ACTION" in
'{expect}'*) exit 0 ;;
*) echo "took the wrong branch: $ACTION" >&2; exit 1 ;;
esac
"#
);
ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
script,
"hook".to_owned(),
])
}
documented_hook("add _acme-challenge.myapp.com. 60 TXT")
.upsert_txt(&record())
.await
.expect("the documented script must take the `add` branch on present");
documented_hook("delete _acme-challenge.myapp.com. TXT")
.delete_txt(&record())
.await
.expect("the documented script must take the `delete` branch on cleanup");
}
#[cfg(unix)]
#[tokio::test]
async fn a_non_zero_exit_fails_the_order_and_quotes_stderr() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
"echo 'zone is not delegated' >&2; exit 3".to_owned(),
"hook".to_owned(),
]);
let err = provider
.upsert_txt(&record())
.await
.expect_err("a failing hook must fail the order");
assert!(err.contains("exit status 3"), "got: {err}");
assert!(err.contains("zone is not delegated"), "got: {err}");
assert!(err.contains("_acme-challenge.myapp.com"), "got: {err}");
}
#[tokio::test]
async fn a_missing_hook_program_is_an_actionable_error() {
let provider = ExecProvider::new(vec!["/nonexistent/acme-dns-hook".to_owned()]);
let err = provider
.upsert_txt(&record())
.await
.expect_err("a missing program must fail");
assert!(err.contains("/nonexistent/acme-dns-hook"), "got: {err}");
assert!(err.contains("command"), "got: {err}");
}
#[cfg(unix)]
#[tokio::test]
async fn the_value_is_never_interpreted_as_shell_syntax() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
r#"test "$3" = '; touch /tmp/pwned`whoami`'"#.to_owned(),
"hook".to_owned(),
]);
let hostile = TxtRecord::new("myapp.com", "; touch /tmp/pwned`whoami`");
provider
.upsert_txt(&hostile)
.await
.expect("the value reaches the hook verbatim, unexpanded");
}
#[test]
fn stderr_excerpt_is_bounded_and_omitted_when_empty() {
assert_eq!(stderr_excerpt(b" ", &[]), "");
assert_eq!(stderr_excerpt(b" boom ", &[]), ": boom");
let long = vec![b'x'; super::super::UPSTREAM_EXCERPT_CHARS * 2];
let excerpt = stderr_excerpt(&long, &[]);
assert!(excerpt.ends_with('…'));
assert!(excerpt.chars().count() <= super::super::UPSTREAM_EXCERPT_CHARS + 3);
}
#[cfg(unix)]
#[tokio::test]
async fn a_hook_cannot_leak_a_traced_credential_through_its_stderr() {
const TOKEN: &str = "cf-live-token-DO-NOT-LEAK-9f3a";
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
format!("echo '+ curl -H \"Authorization: Bearer {TOKEN}\"' >&2; exit 1"),
"hook".to_owned(),
])
.with_secrets(vec![super::super::SecretString::new(TOKEN)]);
let err = provider
.upsert_txt(&record())
.await
.expect_err("a failing hook fails the order");
assert!(!err.contains(TOKEN), "the hook's credential leaked: {err}");
assert!(err.contains("<redacted>"), "got: {err}");
assert!(err.contains("exit status 1"), "got: {err}");
}
#[cfg(unix)]
#[tokio::test]
async fn a_value_starting_with_a_dash_still_reaches_the_hook_as_a_value() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
r#"test "$1" = present && test "$3" = -A-leading-dash-value"#.to_owned(),
"hook".to_owned(),
]);
let dashed = TxtRecord::new("myapp.com", "-A-leading-dash-value");
provider
.upsert_txt(&dashed)
.await
.expect("a leading-dash value reaches the hook as the third argument");
}
#[cfg(unix)]
#[tokio::test]
async fn an_inherited_provider_credential_is_scrubbed_from_stderr() {
const TOKEN: &str = "hetzner-live-token-DO-NOT-LEAK-4c1d";
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
format!(r#"echo '+ curl -H "Authorization: Bearer {TOKEN}"' >&2; exit 7"#),
"hook".to_owned(),
])
.with_env(vec![
("PATH".to_owned(), "/usr/bin:/bin".to_owned()),
("HETZNER_API_TOKEN".to_owned(), TOKEN.to_owned()),
]);
let err = provider
.upsert_txt(&record())
.await
.expect_err("a failing hook fails the order");
assert!(
!err.contains(TOKEN),
"a credential autumn never handled leaked through the hook's stderr: {err}"
);
assert!(err.contains("<redacted>"), "got: {err}");
assert!(err.contains("exit status 7"), "got: {err}");
}
#[test]
fn only_credential_shaped_environment_names_are_scrubbed() {
let secrets = secret_env_values([
("PLAIN_VALUE", "an-ordinary-configuration-value"),
("HETZNER_API_TOKEN", "a-credential-shaped-value"),
("AWS_SECRET_ACCESS_KEY", "another-credential-value"),
("tsig_password", "lowercase-names-match-too"),
("SHORT_KEY", "short"),
("PATH", "/usr/local/bin:/usr/bin:/bin"),
]);
for expected in [
"a-credential-shaped-value",
"another-credential-value",
"lowercase-names-match-too",
] {
assert!(
secrets.iter().any(|s| s == expected),
"a credential-shaped name must be scrubbed: {expected}"
);
}
assert!(
!secrets
.iter()
.any(|s| s == "an-ordinary-configuration-value"),
"an ordinary variable must not be scrubbed"
);
assert!(
!secrets.iter().any(|s| s == "short"),
"a value below the length floor must not be scrubbed"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_runaway_hook_cannot_grow_this_process() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
format!(
"i=0; while [ $i -lt {} ]; do echo 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' >&2; \
i=$((i+1)); done; exit 4",
MAX_STDERR_BYTES / 8
),
"hook".to_owned(),
]);
let err = provider
.upsert_txt(&record())
.await
.expect_err("a failing hook fails the order");
assert!(err.contains("exit status 4"), "got: {err}");
assert!(
err.chars().count() <= super::super::UPSTREAM_EXCERPT_CHARS + 200,
"the published error grew with the hook's output: {} chars",
err.chars().count()
);
}
#[cfg(unix)]
#[tokio::test]
async fn hook_stdout_is_discarded_rather_than_buffered() {
let provider = ExecProvider::new(vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
"i=0; while [ $i -lt 20000 ]; do echo 'noise on stdout'; i=$((i+1)); done".to_owned(),
"hook".to_owned(),
]);
provider
.upsert_txt(&record())
.await
.expect("a chatty-but-successful hook still publishes the record");
}
}