use std::net::IpAddr;
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use super::{AddressNames, Ipam, IpamError};
use crate::config::CustomIpamConfig;
use crate::script_hook::{ScriptError, ScriptHook, ScriptStdin};
pub const UNKNOWN_ADDRESS_EXIT_CODE: i32 = 3;
pub struct CustomIpamBackend {
hook: ScriptHook,
}
impl std::fmt::Debug for CustomIpamBackend {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CustomIpamBackend")
.field("script_path", &self.hook.path())
.finish_non_exhaustive()
}
}
impl CustomIpamBackend {
pub fn from_config(cfg: &CustomIpamConfig, timeout_ms: u64) -> anyhow::Result<Self> {
let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, timeout_ms) else {
anyhow::bail!(
"ipam.custom.script_path is empty; provide a path to an executable \
script or point ipam.backend at another inventory"
);
};
info!(
event = "ipam_custom_loaded",
outcome = "success",
script_path = %hook.path().display(),
timeout_ms,
args = ?cfg.args,
);
Ok(Self { hook })
}
}
#[async_trait]
impl Ipam for CustomIpamBackend {
fn name(&self) -> &'static str {
"the custom IPAM script"
}
async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError> {
let client_ip = ip.to_string();
let envs = [
("ACME_IPAM_HOOK", "names_for"),
("ACME_IPAM_CLIENT_IP", client_ip.as_str()),
];
let payload = json!({ "hook": "names_for", "client_ip": client_ip });
let outcome = match self.hook.run(&envs, ScriptStdin::Json(&payload)).await {
Ok(outcome) => outcome,
Err(
error @ (ScriptError::Spawn { .. }
| ScriptError::Serialize(_)
| ScriptError::Wait(_)
| ScriptError::Timeout(_)),
) => return Err(IpamError(format!("custom IPAM script {error}"))),
};
if outcome.output.status.success() {
let stdout = String::from_utf8_lossy(&outcome.output.stdout);
let mut names = AddressNames::known();
for line in stdout.lines() {
names.insert(line);
}
return Ok(names);
}
if outcome.output.status.code() == Some(UNKNOWN_ADDRESS_EXIT_CODE) {
return Ok(AddressNames::Unknown);
}
Err(IpamError(ScriptHook::detail(
&outcome,
"custom IPAM script",
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{TempDir, write_script};
use std::time::Duration;
const CLIENT: &str = "203.0.113.5";
fn client() -> IpAddr {
CLIENT.parse().unwrap()
}
fn backend(dir: &TempDir, name: &str, body: &str) -> CustomIpamBackend {
let path = write_script(dir, name, body);
CustomIpamBackend::from_config(
&CustomIpamConfig {
script_path: path.display().to_string(),
args: Vec::new(),
},
5_000,
)
.expect("a real script should build")
}
#[test]
fn a_blank_script_path_is_a_startup_error_naming_the_key() {
for path in ["", " "] {
let error = CustomIpamBackend::from_config(
&CustomIpamConfig {
script_path: path.to_string(),
..CustomIpamConfig::default()
},
5_000,
)
.unwrap_err()
.to_string();
assert!(
error.contains("ipam.custom.script_path is empty"),
"{error}"
);
}
}
#[test]
fn the_debug_rendering_names_the_script() {
let dir = TempDir::new("ipam-custom");
let rendered = format!("{:?}", backend(&dir, "ok.sh", "#!/bin/sh\nexit 0\n"));
assert!(rendered.contains("ok.sh"), "{rendered}");
}
#[tokio::test]
async fn the_printed_lines_become_the_permitted_names() {
let dir = TempDir::new("ipam-custom");
let backend = backend(
&dir,
"names.sh",
"#!/bin/sh\necho 'WWW.Example.COM.'\necho\necho ' api.example.com '\nexit 0\n",
);
let names = backend.names_for(client()).await.unwrap();
assert!(names.is_known());
assert_eq!(
names.names().iter().cloned().collect::<Vec<_>>(),
vec!["api.example.com".to_string(), "www.example.com".to_string()]
);
}
#[tokio::test]
async fn exit_three_is_an_unknown_address_and_exit_zero_with_no_names_is_not() {
let dir = TempDir::new("ipam-custom");
let unknown = backend(&dir, "unknown.sh", "#!/bin/sh\nexit 3\n")
.names_for(client())
.await
.unwrap();
assert_eq!(unknown, AddressNames::Unknown);
assert!(!unknown.is_known());
let entitled_to_nothing = backend(&dir, "empty.sh", "#!/bin/sh\nexit 0\n")
.names_for(client())
.await
.unwrap();
assert_eq!(entitled_to_nothing, AddressNames::known());
assert!(entitled_to_nothing.is_known());
assert_ne!(unknown, entitled_to_nothing);
}
#[tokio::test]
async fn stdout_is_ignored_on_the_unknown_exit_code() {
let dir = TempDir::new("ipam-custom");
let names = backend(
&dir,
"chatty.sh",
"#!/bin/sh\necho 'no such address'\nexit 3\n",
)
.names_for(client())
.await
.unwrap();
assert_eq!(names, AddressNames::Unknown);
}
#[tokio::test]
async fn any_other_non_zero_exit_is_an_error_carrying_the_scripts_own_words() {
let dir = TempDir::new("ipam-custom");
let error = backend(
&dir,
"broken.sh",
"#!/bin/sh\ncat > /dev/null\necho 'inventory unreachable'\nexit 1\n",
)
.names_for(client())
.await
.unwrap_err();
assert_eq!(error.0, "inventory unreachable");
let error = backend(
&dir,
"stderr.sh",
"#!/bin/sh\ncat > /dev/null\necho 'token refused' >&2\nexit 4\n",
)
.names_for(client())
.await
.unwrap_err();
assert_eq!(error.0, "token refused");
let error = backend(&dir, "silent.sh", "#!/bin/sh\ncat > /dev/null\nexit 9\n")
.names_for(client())
.await
.unwrap_err();
assert!(error.0.starts_with("custom IPAM script exited"), "{error}");
}
#[tokio::test]
async fn a_missing_script_is_an_error_rather_than_a_denial() {
let backend = CustomIpamBackend::from_config(
&CustomIpamConfig {
script_path: "/nonexistent/ipam.sh".to_string(),
args: Vec::new(),
},
5_000,
)
.unwrap();
let error = backend.names_for(client()).await.unwrap_err();
assert!(error.0.contains("failed to spawn"), "{error}");
assert!(error.0.contains("/nonexistent/ipam.sh"), "{error}");
}
#[tokio::test]
async fn a_timed_out_script_is_an_error_and_is_killed() {
let dir = TempDir::new("ipam-custom");
let marker = dir.path().join("still-running");
let path = write_script(
&dir,
"slow.sh",
&format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
);
let backend = CustomIpamBackend::from_config(
&CustomIpamConfig {
script_path: path.display().to_string(),
args: Vec::new(),
},
100,
)
.unwrap();
let error = backend.names_for(client()).await.unwrap_err();
assert!(error.0.contains("timed out after 100 ms"), "{error}");
tokio::time::sleep(Duration::from_millis(1_500)).await;
assert!(
!marker.exists(),
"the script outlived its deadline and kept running"
);
}
#[tokio::test]
async fn the_script_is_told_the_address_twice_and_the_server_secrets_never() {
let dir = TempDir::new("ipam-custom");
let backend = backend(
&dir,
"echo.sh",
"#!/bin/sh\npayload=$(cat)\n\
echo \"hook-$ACME_IPAM_HOOK.example.com\"\n\
echo \"env-$ACME_IPAM_CLIENT_IP.example.com\"\n\
case \"$payload\" in *'\"client_ip\":\"203.0.113.5\"'*) \
echo 'stdin.example.com' ;; esac\n\
echo \"manifest-${CARGO_MANIFEST_DIR:-unset}.example.com\"\n\
exit 0\n",
);
let names = backend.names_for(client()).await.unwrap();
let names: Vec<_> = names.names().iter().cloned().collect();
assert!(
names.contains(&"hook-names_for.example.com".to_string()),
"{names:?}"
);
assert!(
names.contains(&"env-203.0.113.5.example.com".to_string()),
"{names:?}"
);
assert!(
names.contains(&"stdin.example.com".to_string()),
"{names:?}"
);
assert!(
names.contains(&"manifest-unset.example.com".to_string()),
"{names:?}"
);
}
#[tokio::test]
async fn the_configured_arguments_are_passed() {
let dir = TempDir::new("ipam-custom");
let path = write_script(
&dir,
"args.sh",
"#!/bin/sh\necho \"$1-$2.example.com\"\nexit 0\n",
);
let backend = CustomIpamBackend::from_config(
&CustomIpamConfig {
script_path: path.display().to_string(),
args: vec!["first".to_string(), "second".to_string()],
},
5_000,
)
.unwrap();
let names = backend.names_for(client()).await.unwrap();
assert!(names.names().contains("first-second.example.com"));
}
}