Skip to main content

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}