acme_proxy/ipam/custom.rs
1//! The `custom` IPAM backend: the inventory is an operator-supplied script.
2//!
3//! The third backend, and the one that exists to find out whether the
4//! [`Ipam`] seam generalises or merely spans NetBox and phpIPAM. Everything
5//! those two have in common falls away here: there is no
6//! [`sources`](super::Source) vocabulary, no
7//! [shared transport](super::http), no TLS settings and no wire status code to
8//! read an answer out of. What is left is the trait itself — one question, two
9//! shapes of answer, and an error that cannot express a denial.
10//!
11//! It is also the escape hatch. An estate whose inventory is a CMDB, a `hosts`
12//! file, an LDAP tree or a Python script against a vendor API this server will
13//! never carry a client for answers the same question through the same
14//! contract [`filter::custom`](crate::filter::custom) and
15//! [`signer::custom`](crate::signer::custom) use, over the shared
16//! [`script_hook`](crate::script_hook) hardening.
17//!
18//! ## The contract
19//!
20//! The script is told the address in `ACME_IPAM_CLIENT_IP` and, redundantly,
21//! in the JSON object on its stdin — redundantly on purpose, so a one-line
22//! shell script never has to parse JSON and a Python one never has to read the
23//! environment.
24//!
25//! What it answers with is **stdout plus an exit code**:
26//!
27//! | Exit | stdout | Means |
28//! | --- | --- | --- |
29//! | `0` | one name per line | [`AddressNames::Known`] of those names |
30//! | `0` | empty | `Known` with no names — recorded, entitled to nothing |
31//! | [`UNKNOWN_ADDRESS_EXIT_CODE`] | ignored | [`AddressNames::Unknown`] |
32//! | anything else | the reason | [`IpamError`] — a retryable 500 |
33//!
34//! One name per line rather than a separated list because a newline is the
35//! shell idiom, and neither a newline nor a comma is legal in a DNS name.
36//! Plain text rather than JSON because [`AddressNames`] holds nothing a
37//! structure would carry that a list of lines does not, and because a contract
38//! needing `jq` for what `echo` already does would be paid for by every script
39//! ever written against it — the same choice
40//! [`signer::custom`](crate::signer::custom) makes for the certificate chain.
41//!
42//! ## Why a reserved exit code
43//!
44//! `Known` with no names and `Unknown` are different answers — the filter
45//! words a different 403 for each — and an exit status is the only channel
46//! left once stdout means "the names". So "no record of this address" gets a
47//! reserved code, exactly as `signer::custom`'s `BadCsr` does, and every
48//! *other* non-zero exit stays a failure. That direction matters: a script
49//! that breaks, or is missing, or times out, must produce an
50//! [`IpamError`] — which the filter turns into a retryable 500 — and never
51//! something the client reads as a permanent refusal. The type enforces it,
52//! since `IpamError` has no denied variant to reach for.
53
54use std::net::IpAddr;
55
56use async_trait::async_trait;
57use serde_json::json;
58use tracing::info;
59
60use super::{AddressNames, Ipam, IpamError};
61use crate::config::CustomIpamConfig;
62use crate::script_hook::{ScriptError, ScriptHook, ScriptStdin};
63
64/// The exit status meaning "this inventory holds no record of that address".
65///
66/// Reserved the way [`signer::custom`](crate::signer::custom)'s `BadCsr` code
67/// is, and for the same reason: it is an *answer*, not a failure, and nothing
68/// else in the contract can carry it.
69pub const UNKNOWN_ADDRESS_EXIT_CODE: i32 = 3;
70
71/// Reports which names an operator script associates with an address.
72pub struct CustomIpamBackend {
73 hook: ScriptHook,
74}
75
76impl std::fmt::Debug for CustomIpamBackend {
77 /// The script is the whole configuration; its path is the readable part.
78 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 formatter
80 .debug_struct("CustomIpamBackend")
81 .field("script_path", &self.hook.path())
82 .finish_non_exhaustive()
83 }
84}
85
86impl CustomIpamBackend {
87 /// Validates the configuration and builds the hook. Runs nothing.
88 ///
89 /// `timeout_ms` is [`IpamConfig::timeout_ms`](crate::config::IpamConfig),
90 /// not a budget of this section's own: the registry already wraps every
91 /// lookup in it, and giving the hook the same value is what makes the
92 /// child actually killed at the deadline rather than left to
93 /// `kill_on_drop` alone.
94 pub fn from_config(cfg: &CustomIpamConfig, timeout_ms: u64) -> anyhow::Result<Self> {
95 let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, timeout_ms) else {
96 anyhow::bail!(
97 "ipam.custom.script_path is empty; provide a path to an executable \
98 script or point ipam.backend at another inventory"
99 );
100 };
101
102 info!(
103 event = "ipam_custom_loaded",
104 outcome = "success",
105 script_path = %hook.path().display(),
106 timeout_ms,
107 args = ?cfg.args,
108 );
109
110 Ok(Self { hook })
111 }
112}
113
114#[async_trait]
115impl Ipam for CustomIpamBackend {
116 /// Reads as a subject: every refusal the `ipam` filter words interpolates
117 /// this, so an operator sees "the custom IPAM script holds no record of
118 /// 10.0.0.5" rather than a bare type name.
119 fn name(&self) -> &'static str {
120 "the custom IPAM script"
121 }
122
123 async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError> {
124 let client_ip = ip.to_string();
125 let envs = [
126 ("ACME_IPAM_HOOK", "names_for"),
127 ("ACME_IPAM_CLIENT_IP", client_ip.as_str()),
128 ];
129 let payload = json!({ "hook": "names_for", "client_ip": client_ip });
130
131 // Matched variant by variant rather than with a wildcard, so a new
132 // `ScriptError` has to be considered here instead of silently joining
133 // the others — `filter::custom` makes the same choice.
134 let outcome = match self.hook.run(&envs, ScriptStdin::Json(&payload)).await {
135 Ok(outcome) => outcome,
136 Err(
137 error @ (ScriptError::Spawn { .. }
138 | ScriptError::Serialize(_)
139 | ScriptError::Wait(_)
140 | ScriptError::Timeout(_)),
141 ) => return Err(IpamError(format!("custom IPAM script {error}"))),
142 };
143
144 if outcome.output.status.success() {
145 let stdout = String::from_utf8_lossy(&outcome.output.stdout);
146 let mut names = AddressNames::known();
147 for line in stdout.lines() {
148 // `insert` normalizes and drops an empty entry, so a blank
149 // line and a stray trailing dot both cost the script nothing.
150 names.insert(line);
151 }
152 return Ok(names);
153 }
154
155 if outcome.output.status.code() == Some(UNKNOWN_ADDRESS_EXIT_CODE) {
156 return Ok(AddressNames::Unknown);
157 }
158
159 Err(IpamError(ScriptHook::detail(
160 &outcome,
161 "custom IPAM script",
162 )))
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::testutil::{TempDir, write_script};
170 use std::time::Duration;
171
172 const CLIENT: &str = "203.0.113.5";
173
174 fn client() -> IpAddr {
175 CLIENT.parse().unwrap()
176 }
177
178 /// Builds a backend over a freshly written script.
179 fn backend(dir: &TempDir, name: &str, body: &str) -> CustomIpamBackend {
180 let path = write_script(dir, name, body);
181 CustomIpamBackend::from_config(
182 &CustomIpamConfig {
183 script_path: path.display().to_string(),
184 args: Vec::new(),
185 },
186 5_000,
187 )
188 .expect("a real script should build")
189 }
190
191 // ---------------------------------------------------------- from_config
192
193 #[test]
194 fn a_blank_script_path_is_a_startup_error_naming_the_key() {
195 for path in ["", " "] {
196 let error = CustomIpamBackend::from_config(
197 &CustomIpamConfig {
198 script_path: path.to_string(),
199 ..CustomIpamConfig::default()
200 },
201 5_000,
202 )
203 .unwrap_err()
204 .to_string();
205 assert!(
206 error.contains("ipam.custom.script_path is empty"),
207 "{error}"
208 );
209 }
210 }
211
212 #[test]
213 fn the_debug_rendering_names_the_script() {
214 let dir = TempDir::new("ipam-custom");
215 let rendered = format!("{:?}", backend(&dir, "ok.sh", "#!/bin/sh\nexit 0\n"));
216 assert!(rendered.contains("ok.sh"), "{rendered}");
217 }
218
219 // ------------------------------------------------------------- answers
220
221 /// The happy path, and the normalization that comes with it: the script
222 /// may print whatever case and trailing dot its inventory holds, and a
223 /// blank line costs it nothing.
224 #[tokio::test]
225 async fn the_printed_lines_become_the_permitted_names() {
226 let dir = TempDir::new("ipam-custom");
227 let backend = backend(
228 &dir,
229 "names.sh",
230 "#!/bin/sh\necho 'WWW.Example.COM.'\necho\necho ' api.example.com '\nexit 0\n",
231 );
232
233 let names = backend.names_for(client()).await.unwrap();
234 assert!(names.is_known());
235 assert_eq!(
236 names.names().iter().cloned().collect::<Vec<_>>(),
237 vec!["api.example.com".to_string(), "www.example.com".to_string()]
238 );
239 }
240
241 /// The distinction the whole reserved exit code exists for: the filter
242 /// words a different refusal for each of these two, so they must not
243 /// collapse.
244 #[tokio::test]
245 async fn exit_three_is_an_unknown_address_and_exit_zero_with_no_names_is_not() {
246 let dir = TempDir::new("ipam-custom");
247
248 let unknown = backend(&dir, "unknown.sh", "#!/bin/sh\nexit 3\n")
249 .names_for(client())
250 .await
251 .unwrap();
252 assert_eq!(unknown, AddressNames::Unknown);
253 assert!(!unknown.is_known());
254
255 let entitled_to_nothing = backend(&dir, "empty.sh", "#!/bin/sh\nexit 0\n")
256 .names_for(client())
257 .await
258 .unwrap();
259 assert_eq!(entitled_to_nothing, AddressNames::known());
260 assert!(entitled_to_nothing.is_known());
261
262 assert_ne!(unknown, entitled_to_nothing);
263 }
264
265 /// Anything the script prints on the way out of exit 3 is ignored: the
266 /// exit code is the answer, and a stray diagnostic must not become a name.
267 #[tokio::test]
268 async fn stdout_is_ignored_on_the_unknown_exit_code() {
269 let dir = TempDir::new("ipam-custom");
270 let names = backend(
271 &dir,
272 "chatty.sh",
273 "#!/bin/sh\necho 'no such address'\nexit 3\n",
274 )
275 .names_for(client())
276 .await
277 .unwrap();
278 assert_eq!(names, AddressNames::Unknown);
279 }
280
281 // -------------------------------------------------------------- failures
282
283 /// The property the subsystem rests on: a broken script is the *server*
284 /// failing to decide, which the filter turns into a retryable 500. It is
285 /// enforced by the type — there is no denial to return from here.
286 ///
287 /// **Every script here drains its stdin** (`cat > /dev/null`), the rule
288 /// `signer::custom` and `notify::custom` already keep. This backend always
289 /// sends the address as JSON on stdin, so a script that exits without
290 /// reading it races the parent's write: when the child wins, the write is
291 /// an `EPIPE`, `ScriptOutcome::stdin_error` records it, and `detail`
292 /// appends "(the script did not read its input: …)" — by design, and
293 /// exactly what an operator wants to be told. Draining is what lets these
294 /// stay `assert_eq!` on the script's own words rather than a `starts_with`
295 /// that would no longer be checking the property named above.
296 #[tokio::test]
297 async fn any_other_non_zero_exit_is_an_error_carrying_the_scripts_own_words() {
298 let dir = TempDir::new("ipam-custom");
299
300 let error = backend(
301 &dir,
302 "broken.sh",
303 "#!/bin/sh\ncat > /dev/null\necho 'inventory unreachable'\nexit 1\n",
304 )
305 .names_for(client())
306 .await
307 .unwrap_err();
308 assert_eq!(error.0, "inventory unreachable");
309
310 let error = backend(
311 &dir,
312 "stderr.sh",
313 "#!/bin/sh\ncat > /dev/null\necho 'token refused' >&2\nexit 4\n",
314 )
315 .names_for(client())
316 .await
317 .unwrap_err();
318 assert_eq!(error.0, "token refused");
319
320 let error = backend(&dir, "silent.sh", "#!/bin/sh\ncat > /dev/null\nexit 9\n")
321 .names_for(client())
322 .await
323 .unwrap_err();
324 assert!(error.0.starts_with("custom IPAM script exited"), "{error}");
325 }
326
327 #[tokio::test]
328 async fn a_missing_script_is_an_error_rather_than_a_denial() {
329 let backend = CustomIpamBackend::from_config(
330 &CustomIpamConfig {
331 script_path: "/nonexistent/ipam.sh".to_string(),
332 args: Vec::new(),
333 },
334 5_000,
335 )
336 .unwrap();
337
338 let error = backend.names_for(client()).await.unwrap_err();
339 assert!(error.0.contains("failed to spawn"), "{error}");
340 assert!(error.0.contains("/nonexistent/ipam.sh"), "{error}");
341 }
342
343 /// A script that never returns must not outlive its deadline: the registry
344 /// budget only drops the future, so `kill_on_drop` inside the hook is what
345 /// stops one leaked process per `newOrder`.
346 #[tokio::test]
347 async fn a_timed_out_script_is_an_error_and_is_killed() {
348 let dir = TempDir::new("ipam-custom");
349 let marker = dir.path().join("still-running");
350 let path = write_script(
351 &dir,
352 "slow.sh",
353 &format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
354 );
355 let backend = CustomIpamBackend::from_config(
356 &CustomIpamConfig {
357 script_path: path.display().to_string(),
358 args: Vec::new(),
359 },
360 100,
361 )
362 .unwrap();
363
364 let error = backend.names_for(client()).await.unwrap_err();
365 assert!(error.0.contains("timed out after 100 ms"), "{error}");
366
367 tokio::time::sleep(Duration::from_millis(1_500)).await;
368 assert!(
369 !marker.exists(),
370 "the script outlived its deadline and kept running"
371 );
372 }
373
374 // -------------------------------------------------------- what it is told
375
376 /// Both channels carry the address, and neither carries the server's own
377 /// environment — which holds the NetBox token and the RFC 2136 TSIG key.
378 #[tokio::test]
379 async fn the_script_is_told_the_address_twice_and_the_server_secrets_never() {
380 let dir = TempDir::new("ipam-custom");
381 let backend = backend(
382 &dir,
383 "echo.sh",
384 "#!/bin/sh\npayload=$(cat)\n\
385 echo \"hook-$ACME_IPAM_HOOK.example.com\"\n\
386 echo \"env-$ACME_IPAM_CLIENT_IP.example.com\"\n\
387 case \"$payload\" in *'\"client_ip\":\"203.0.113.5\"'*) \
388 echo 'stdin.example.com' ;; esac\n\
389 echo \"manifest-${CARGO_MANIFEST_DIR:-unset}.example.com\"\n\
390 exit 0\n",
391 );
392
393 let names = backend.names_for(client()).await.unwrap();
394 let names: Vec<_> = names.names().iter().cloned().collect();
395 assert!(
396 names.contains(&"hook-names_for.example.com".to_string()),
397 "{names:?}"
398 );
399 assert!(
400 names.contains(&"env-203.0.113.5.example.com".to_string()),
401 "{names:?}"
402 );
403 assert!(
404 names.contains(&"stdin.example.com".to_string()),
405 "{names:?}"
406 );
407 assert!(
408 names.contains(&"manifest-unset.example.com".to_string()),
409 "{names:?}"
410 );
411 }
412
413 #[tokio::test]
414 async fn the_configured_arguments_are_passed() {
415 let dir = TempDir::new("ipam-custom");
416 let path = write_script(
417 &dir,
418 "args.sh",
419 "#!/bin/sh\necho \"$1-$2.example.com\"\nexit 0\n",
420 );
421 let backend = CustomIpamBackend::from_config(
422 &CustomIpamConfig {
423 script_path: path.display().to_string(),
424 args: vec!["first".to_string(), "second".to_string()],
425 },
426 5_000,
427 )
428 .unwrap();
429
430 let names = backend.names_for(client()).await.unwrap();
431 assert!(names.names().contains("first-second.example.com"));
432 }
433}