Skip to main content

acme_proxy/ipam/phpipam/
client.rs

1//! The production [`PhpIpamApi`]: phpIPAM's REST API over HTTP/1.1.
2//!
3//! Two queries, of which one always runs:
4//!
5//! | Query | Source |
6//! | --- | --- |
7//! | `<app_id>/addresses/search/<ip>/` | always |
8//! | `<app_id>/devices/<id>/` | `device` |
9//!
10//! ## Two things phpIPAM does that NetBox does not
11//!
12//! **Authentication is a bare `token` header**, not `Authorization`. phpIPAM
13//! calls it the application's *app code*, configured per API application under
14//! Administration → API with security "SSL with App code". The other scheme —
15//! user credentials exchanged for a six-hour session token — is deliberately
16//! not implemented: it needs a refresh loop and a place to keep the token, for
17//! no gain over a credential that can simply be rotated in the environment.
18//!
19//! **An unknown address is a `404`**, not an empty list, and the body is a
20//! phpIPAM envelope rather than an HTTP error page. That is why [`search`]
21//! returns `Ok(None)` for it — a fact about the address, not a failure to look
22//! it up — and why the shared transport carries the status at all.
23//!
24//! ## The envelope
25//!
26//! Every phpIPAM answer is `{"code": 200, "success": true, "data": …}`, with
27//! `data` an array for a search and an object for a detail read. Custom columns
28//! are plain top-level members of each row (`custom_acme_allowed_names`),
29//! unlike NetBox's nested `custom_fields`.
30//!
31//! [`search`]: PhpIpamApi::search
32
33use std::net::IpAddr;
34
35use async_trait::async_trait;
36use hyper::{StatusCode, header::HeaderName};
37use serde::Deserialize;
38use serde_json::{Map, Value};
39
40use super::{PhpIpamAddress, PhpIpamApi};
41use crate::config::PhpIpamConfig;
42use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
43
44/// phpIPAM's own header name for the application's app code.
45const TOKEN_HEADER: &str = "token";
46
47/// A phpIPAM REST client.
48#[derive(Debug)]
49pub struct PhpIpamClient {
50    api: JsonApi,
51    app_id: String,
52}
53
54impl PhpIpamClient {
55    /// Validates the URL and builds the TLS configuration. No network yet.
56    pub fn new(
57        cfg: &PhpIpamConfig,
58        outbound: crate::http_client::Outbound,
59    ) -> anyhow::Result<Self> {
60        anyhow::ensure!(
61            !cfg.url.trim().is_empty(),
62            "ipam.backend is `phpipam` but ipam.phpipam.url is empty; give the base URL of the \
63             phpIPAM instance"
64        );
65        anyhow::ensure!(
66            !cfg.token.trim().is_empty(),
67            "ipam.backend is `phpipam` but ipam.phpipam.token is empty; supply the API \
68             application's app code, preferably through ACME_PROXY_IPAM__PHPIPAM__TOKEN"
69        );
70        // The app id is a path segment in every request, so a stray slash would
71        // silently retarget the API rather than fail.
72        let app_id = cfg.app_id.trim().to_string();
73        anyhow::ensure!(
74            !app_id.is_empty()
75                && app_id
76                    .chars()
77                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
78            "ipam.phpipam.app_id must be a single path segment of letters, digits, `-` or `_` \
79             (the API application's identifier in phpIPAM); got `{}`",
80            cfg.app_id
81        );
82
83        let header = HeaderName::from_static(TOKEN_HEADER);
84
85        Ok(Self {
86            api: JsonApi::new(
87                &cfg.url,
88                "ipam.phpipam.url",
89                vec![(header, cfg.token.clone())],
90                tls_config(
91                    &cfg.ca_cert_path,
92                    cfg.insecure_skip_verify,
93                    "ipam.phpipam.ca_cert_path",
94                )?,
95                outbound,
96            )?,
97            app_id,
98        })
99    }
100}
101
102/// The phpIPAM envelope. `data` is an array for a search and an object for a
103/// detail read, so it stays a raw [`Value`] and each caller shapes it.
104#[derive(Debug, Deserialize)]
105struct Envelope {
106    #[serde(default)]
107    data: Value,
108}
109
110/// One address row, of which `hostname` and `deviceId` are named and every
111/// other column — the custom ones included — is kept as it arrived.
112#[derive(Debug, Deserialize)]
113struct AddressRow {
114    #[serde(default)]
115    hostname: Option<String>,
116    #[serde(rename = "deviceId", default)]
117    device_id: Option<Value>,
118    #[serde(flatten)]
119    fields: Map<String, Value>,
120}
121
122impl From<AddressRow> for PhpIpamAddress {
123    fn from(row: AddressRow) -> Self {
124        Self {
125            hostname: row.hostname.unwrap_or_default(),
126            device_id: row.device_id.as_ref().and_then(numeric_id),
127            fields: row.fields,
128        }
129    }
130}
131
132/// phpIPAM renders integer columns as JSON *strings* in some versions and as
133/// numbers in others, and an unassigned device is `null`, `"0"` or `0`
134/// depending on the release. All four have to read as "no device".
135fn numeric_id(value: &Value) -> Option<u64> {
136    let id = match value {
137        Value::Number(number) => number.as_u64()?,
138        Value::String(text) => text.trim().parse().ok()?,
139        _ => return None,
140    };
141    (id != 0).then_some(id)
142}
143
144#[async_trait]
145impl PhpIpamApi for PhpIpamClient {
146    async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String> {
147        // The address is a path segment. It is an `IpAddr`, so it is already
148        // known to hold nothing needing escaping — no operator string reaches
149        // this path.
150        let path = format!("/api/{}/addresses/search/{ip}/", self.app_id);
151
152        let body = match self.api.get(&path).await {
153            Ok(body) => body,
154            // The one status phpIPAM uses as an answer rather than a failure.
155            Err(JsonApiError {
156                status: Some(StatusCode::NOT_FOUND),
157                ..
158            }) => return Ok(None),
159            Err(error) => return Err(error.message),
160        };
161
162        let envelope: Envelope = serde_json::from_value(body)
163            .map_err(|error| format!("unexpected addresses/search response: {error}"))?;
164        let rows: Vec<AddressRow> = serde_json::from_value(envelope.data)
165            .map_err(|error| format!("unexpected addresses/search data: {error}"))?;
166
167        Ok(Some(rows.into_iter().map(PhpIpamAddress::from).collect()))
168    }
169
170    async fn device(&self, id: u64) -> Result<Map<String, Value>, String> {
171        let path = format!("/api/{}/devices/{id}/", self.app_id);
172
173        let body = self
174            .api
175            .get(&path)
176            .await
177            .map_err(|error: JsonApiError| error.message)?;
178
179        let envelope: Envelope = serde_json::from_value(body)
180            .map_err(|error| format!("unexpected response for {path}: {error}"))?;
181
182        match envelope.data {
183            Value::Object(fields) => Ok(fields),
184            // A device phpIPAM has no row for answers 200 with an empty array
185            // rather than a 404. Not an error — it simply lends no names.
186            Value::Null | Value::Array(_) => Ok(Map::new()),
187            other => Err(format!(
188                "unexpected response for {path}: data is a {}",
189                crate::ipam::value_kind(&other)
190            )),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::ipam::http::testing::{
199        closed_port, ok, serve_once, serve_once_tls, status, test_resolver,
200    };
201    use serde_json::json;
202
203    fn config(url: &str) -> PhpIpamConfig {
204        PhpIpamConfig {
205            url: url.to_string(),
206            token: "t0ken".to_string(),
207            ..PhpIpamConfig::default()
208        }
209    }
210
211    // ------------------------------------------------------ startup checks
212
213    #[test]
214    fn an_empty_url_is_a_startup_error() {
215        let error = PhpIpamClient::new(
216            &config("  "),
217            crate::testutil::outbound_with(test_resolver()),
218        )
219        .unwrap_err()
220        .to_string();
221        assert!(error.contains("ipam.phpipam.url"), "{error}");
222    }
223
224    #[test]
225    fn an_empty_token_is_a_startup_error() {
226        let cfg = PhpIpamConfig {
227            token: String::new(),
228            ..config("https://ipam.example.com")
229        };
230        let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
231            .unwrap_err()
232            .to_string();
233        assert!(error.contains("ipam.phpipam.token"), "{error}");
234        assert!(error.contains("ACME_PROXY_IPAM__PHPIPAM__TOKEN"), "{error}");
235    }
236
237    /// The app id is a path segment, so a slash in it would silently retarget
238    /// every request rather than fail.
239    #[test]
240    fn an_app_id_that_is_not_one_path_segment_is_a_startup_error() {
241        for bad in ["", "  ", "a/b", "a?b", "a b", "../admin"] {
242            let cfg = PhpIpamConfig {
243                app_id: bad.to_string(),
244                ..config("https://ipam.example.com")
245            };
246            let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
247                .unwrap_err()
248                .to_string();
249            assert!(error.contains("ipam.phpipam.app_id"), "{bad}: {error}");
250        }
251    }
252
253    #[test]
254    fn a_missing_ca_certificate_is_a_startup_error() {
255        let cfg = PhpIpamConfig {
256            ca_cert_path: "/nonexistent/ipam-ca.pem".to_string(),
257            ..config("https://ipam.example.com")
258        };
259        let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
260            .unwrap_err()
261            .to_string();
262        assert!(error.contains("ipam.phpipam.ca_cert_path"), "{error}");
263    }
264
265    #[test]
266    fn the_debug_impl_never_renders_the_token() {
267        let client = PhpIpamClient::new(
268            &config("https://ipam.example.com"),
269            crate::testutil::outbound_with(test_resolver()),
270        )
271        .unwrap();
272        let rendered = format!("{client:?}");
273        assert!(!rendered.contains("t0ken"), "{rendered}");
274    }
275
276    // ---------------------------------------------------------- the id shim
277
278    /// phpIPAM renders integer columns as strings in some versions and numbers
279    /// in others, and an unassigned device is `null`, `"0"` or `0`.
280    #[test]
281    fn a_device_id_reads_from_either_json_type() {
282        assert_eq!(numeric_id(&json!(3)), Some(3));
283        assert_eq!(numeric_id(&json!("3")), Some(3));
284        assert_eq!(numeric_id(&json!(" 3 ")), Some(3));
285        assert_eq!(numeric_id(&json!(0)), None);
286        assert_eq!(numeric_id(&json!("0")), None);
287        assert_eq!(numeric_id(&Value::Null), None);
288        assert_eq!(numeric_id(&json!("")), None);
289        assert_eq!(numeric_id(&json!([3])), None);
290    }
291
292    /// The real client against a loopback listener. A stub proves the policy;
293    /// only this proves the path and the `token` header are what phpIPAM needs.
294    mod loopback {
295        use super::*;
296
297        fn client(port: u16) -> PhpIpamClient {
298            PhpIpamClient::new(
299                &config(&format!("http://127.0.0.1:{port}")),
300                crate::testutil::outbound_with(test_resolver()),
301            )
302            .unwrap()
303        }
304
305        fn one_address() -> Value {
306            json!({
307                "code": 200,
308                "success": true,
309                "data": [{
310                    "id": "12",
311                    "subnetId": "4",
312                    "ip": "10.0.0.5",
313                    "hostname": "host.example.com",
314                    "deviceId": "3",
315                    "custom_acme_allowed_names": "www.example.com"
316                }]
317            })
318        }
319
320        #[tokio::test]
321        async fn searches_the_address_and_authenticates() {
322            let (port, server) = serve_once(ok(one_address())).await;
323
324            let objects = client(port)
325                .search("10.0.0.5".parse().unwrap())
326                .await
327                .unwrap()
328                .unwrap();
329
330            assert_eq!(objects.len(), 1);
331            assert_eq!(objects[0].hostname, "host.example.com");
332            assert_eq!(objects[0].device_id, Some(3));
333            assert_eq!(
334                objects[0].fields["custom_acme_allowed_names"],
335                json!("www.example.com")
336            );
337
338            let request = server.await.unwrap();
339            assert!(
340                request.starts_with("GET /api/acme/addresses/search/10.0.0.5/ HTTP/1.1"),
341                "{request}"
342            );
343            // A bare `token` header, not `Authorization` — phpIPAM's own scheme.
344            assert!(request.contains("token: t0ken"), "{request}");
345            assert!(!request.contains("authorization:"), "{request}");
346        }
347
348        #[tokio::test]
349        async fn the_app_id_is_part_of_the_path() {
350            let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
351            let cfg = PhpIpamConfig {
352                app_id: "certs".to_string(),
353                ..config(&format!("http://127.0.0.1:{port}"))
354            };
355
356            PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
357                .unwrap()
358                .search("10.0.0.5".parse().unwrap())
359                .await
360                .unwrap();
361
362            let request = server.await.unwrap();
363            assert!(
364                request.starts_with("GET /api/certs/addresses/search/"),
365                "{request}"
366            );
367        }
368
369        #[tokio::test]
370        async fn an_ipv6_address_keeps_its_colons_in_the_path() {
371            let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
372
373            client(port)
374                .search("2001:db8::5".parse().unwrap())
375                .await
376                .unwrap();
377
378            let request = server.await.unwrap();
379            assert!(
380                request.starts_with("GET /api/acme/addresses/search/2001:db8::5/"),
381                "{request}"
382            );
383        }
384
385        /// The behaviour this whole backend is shaped around: phpIPAM answers
386        /// an unknown address with a 404, and that is an answer.
387        #[tokio::test]
388        async fn a_404_reads_as_no_such_address_rather_than_a_failure() {
389            let (port, _server) = serve_once(status(
390                404,
391                "Not Found",
392                r#"{"code":404,"success":false,"message":"No addresses found"}"#,
393            ))
394            .await;
395
396            let found = client(port)
397                .search("10.0.0.5".parse().unwrap())
398                .await
399                .expect("a 404 must not be an error");
400            assert!(found.is_none());
401        }
402
403        /// …and every other status still is a failure, so a broken phpIPAM
404        /// stops issuance rather than reading as "this address owns no names".
405        #[tokio::test]
406        async fn a_refused_token_is_still_a_failure() {
407            let (port, _server) = serve_once(status(
408                401,
409                "Unauthorized",
410                r#"{"code":401,"message":"Invalid app code"}"#,
411            ))
412            .await;
413
414            let error = client(port)
415                .search("10.0.0.5".parse().unwrap())
416                .await
417                .unwrap_err();
418            assert!(error.contains("401"), "{error}");
419            assert!(error.contains("Invalid app code"), "{error}");
420        }
421
422        #[tokio::test]
423        async fn a_server_error_is_a_failure() {
424            let (port, _server) = serve_once(status(500, "Internal Server Error", "boom!")).await;
425
426            let error = client(port)
427                .search("10.0.0.5".parse().unwrap())
428                .await
429                .unwrap_err();
430            assert!(error.contains("500"), "{error}");
431        }
432
433        #[tokio::test]
434        async fn fetches_a_devices_columns() {
435            let (port, server) = serve_once(ok(json!({
436                "code": 200,
437                "data": { "id": "3", "hostname": "srv1",
438                          "custom_acme_allowed_names": "machine.example.com" }
439            })))
440            .await;
441
442            let fields = client(port).device(3).await.unwrap();
443            assert_eq!(
444                fields["custom_acme_allowed_names"],
445                json!("machine.example.com")
446            );
447
448            let request = server.await.unwrap();
449            assert!(
450                request.starts_with("GET /api/acme/devices/3/ HTTP/1.1"),
451                "{request}"
452            );
453        }
454
455        /// phpIPAM answers a device it has no row for with `200` and an empty
456        /// array rather than a 404. Not an error — it simply lends no names.
457        #[tokio::test]
458        async fn an_absent_device_lends_no_names_without_erroring() {
459            let (port, _server) = serve_once(ok(json!({ "code": 200, "data": [] }))).await;
460
461            assert!(client(port).device(3).await.unwrap().is_empty());
462        }
463
464        #[tokio::test]
465        async fn a_device_response_of_the_wrong_shape_is_an_error() {
466            let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
467
468            let error = client(port).device(3).await.unwrap_err();
469            assert!(error.contains("data is a string"), "{error}");
470        }
471
472        #[tokio::test]
473        async fn a_search_response_of_the_wrong_shape_is_an_error() {
474            let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
475
476            let error = client(port)
477                .search("10.0.0.5".parse().unwrap())
478                .await
479                .unwrap_err();
480            assert!(
481                error.contains("unexpected addresses/search data"),
482                "{error}"
483            );
484        }
485
486        #[tokio::test]
487        async fn an_unreadable_envelope_is_an_error() {
488            let (port, _server) = serve_once(ok(json!([1, 2, 3]))).await;
489
490            let error = client(port)
491                .search("10.0.0.5".parse().unwrap())
492                .await
493                .unwrap_err();
494            assert!(
495                error.contains("unexpected addresses/search response"),
496                "{error}"
497            );
498        }
499
500        #[tokio::test]
501        async fn a_closed_port_is_a_connect_error() {
502            let port = closed_port().await;
503
504            let error = client(port)
505                .search("10.0.0.5".parse().unwrap())
506                .await
507                .unwrap_err();
508            assert!(error.contains("connecting to 127.0.0.1"), "{error}");
509        }
510    }
511
512    /// The same proof the NetBox client carries: `insecure_skip_verify` is not
513    /// merely declarative.
514    mod tls {
515        use super::*;
516
517        fn https_config(port: u16, skip: bool) -> PhpIpamConfig {
518            PhpIpamConfig {
519                insecure_skip_verify: skip,
520                ..config(&format!("https://localhost:{port}"))
521            }
522        }
523
524        #[tokio::test]
525        async fn a_self_signed_phpipam_is_refused_by_default() {
526            let port = serve_once_tls(json!({ "data": [] })).await;
527
528            let error = PhpIpamClient::new(
529                &https_config(port, false),
530                crate::testutil::outbound_with(test_resolver()),
531            )
532            .unwrap()
533            .search("10.0.0.5".parse().unwrap())
534            .await
535            .unwrap_err();
536            assert!(error.contains("TLS handshake"), "{error}");
537        }
538
539        #[tokio::test]
540        async fn skipping_verification_reaches_the_same_phpipam() {
541            let port = serve_once_tls(json!({
542                "data": [{ "hostname": "host.example.com" }]
543            }))
544            .await;
545
546            let objects = PhpIpamClient::new(
547                &https_config(port, true),
548                crate::testutil::outbound_with(test_resolver()),
549            )
550            .unwrap()
551            .search("10.0.0.5".parse().unwrap())
552            .await
553            .expect("skip-verify must accept a self-signed certificate")
554            .unwrap();
555            assert_eq!(objects[0].hostname, "host.example.com");
556        }
557    }
558}