Skip to main content

acme_proxy/ipam/phpipam/
mod.rs

1//! The `phpipam` IPAM backend: what phpIPAM associates with an address.
2//!
3//! The second inventory, and the one that shows the [`Ipam`] seam is not NetBox
4//! with extra steps. It answers the same question by a different route, with a
5//! different auth scheme, a different response envelope and a different idea of
6//! what "no such address" looks like on the wire.
7//!
8//! ## What phpIPAM is asked
9//!
10//! One lookup always happens:
11//! `GET /api/<app_id>/addresses/search/<client ip>/`. What it returns permits
12//! names two ways — the address's `hostname` ([`Source::DnsName`], the direct
13//! analogue of NetBox's `dns_name`) and a custom column on it
14//! ([`Source::CustomField`]).
15//!
16//! One more is conditional: [`Source::Device`] follows the address's `deviceId`
17//! to `GET /api/<app_id>/devices/<id>/` and reads the same column there. Like
18//! NetBox's, it is a **fallback and not a union** — a value on the address is
19//! the more specific statement.
20//!
21//! [`Source::Vip`] and [`Source::Fhrp`] are **refused by name at startup**:
22//! phpIPAM records no address roles and no redundancy groups, so there is
23//! nothing here to read. Ignoring them silently would leave an operator
24//! believing a check runs that never runs.
25//!
26//! ## A 404 is an answer, not a failure
27//!
28//! The one place phpIPAM's wire behaviour differs in a way this code has to
29//! know about. NetBox answers an unknown address with `200` and an empty result
30//! list; phpIPAM answers `404` with `{"code":404,"message":"No addresses
31//! found"}`. Reading that as a transport failure would turn every request from
32//! an unrecorded machine into a retryable 500 instead of the refusal it is —
33//! which is why [`JsonApiError`](crate::ipam::http::JsonApiError) carries the
34//! status at all.
35//!
36//! ## Multi-valued custom fields
37//!
38//! A phpIPAM custom field is a plain text column, so several names are written
39//! as one comma-separated string and split here. Hardcoded rather than
40//! configurable: a comma is not legal in a DNS name, so there is no estate the
41//! separator could need to differ for.
42
43pub mod client;
44
45use std::net::IpAddr;
46use std::sync::Arc;
47
48use async_trait::async_trait;
49use serde_json::{Map, Value};
50use tracing::{info, warn};
51
52use super::{AddressNames, Ipam, IpamError, Source, Sources, field_values, parse_sources};
53use crate::config::PhpIpamConfig;
54
55/// One phpIPAM address object, reduced to what this backend reads.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct PhpIpamAddress {
58    /// The address's `hostname`, empty when unset.
59    pub hostname: String,
60    /// Every column phpIPAM returned, custom ones included — they are plain
61    /// top-level members rather than a nested object as in NetBox.
62    pub fields: Map<String, Value>,
63    /// The device this address is assigned to, when there is one.
64    pub device_id: Option<u64>,
65}
66
67/// The phpIPAM queries this backend makes.
68///
69/// A trait so the policy above can be tested without a phpIPAM, the same seam
70/// [`NetboxApi`](crate::ipam::netbox::NetboxApi) is. `Ok(None)` from
71/// [`Self::search`] is the 404 case: an answer, not a failure.
72#[async_trait]
73pub trait PhpIpamApi: Send + Sync {
74    /// The address objects phpIPAM holds for this address, or `None` when it
75    /// holds no record of it at all.
76    async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String>;
77
78    /// One device's columns.
79    async fn device(&self, id: u64) -> Result<Map<String, Value>, String>;
80}
81
82/// Reports which names phpIPAM associates with an address.
83pub struct PhpIpamBackend {
84    api: Arc<dyn PhpIpamApi>,
85    custom_field: String,
86    sources: Sources,
87}
88
89impl std::fmt::Debug for PhpIpamBackend {
90    /// `dyn PhpIpamApi` is not `Debug`; the policy is the interesting part.
91    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        formatter
93            .debug_struct("PhpIpamBackend")
94            .field("custom_field", &self.custom_field)
95            .field("sources", &self.sources)
96            .finish_non_exhaustive()
97    }
98}
99
100/// phpIPAM records no roles and no redundancy groups, so `vip` and `fhrp` are
101/// absent here and refused by name.
102const SUPPORTED: &[Source] = &[Source::DnsName, Source::CustomField, Source::Device];
103
104impl PhpIpamBackend {
105    /// Builds the real phpIPAM client, then delegates. Contacts nothing.
106    pub fn from_config(
107        cfg: &PhpIpamConfig,
108        outbound: crate::http_client::Outbound,
109    ) -> anyhow::Result<Self> {
110        let api = Arc::new(client::PhpIpamClient::new(cfg, outbound)?);
111        let backend = Self::with_api(cfg, api)?;
112
113        info!(
114            event = "ipam_phpipam_loaded",
115            outcome = "success",
116            backend_url = %cfg.url,
117            app_id = %cfg.app_id,
118            custom_field = %cfg.custom_field,
119            sources = ?backend.sources,
120        );
121
122        // Unconditional and deliberately not once-only, the
123        // `ipam_netbox_tls_verification_disabled` treatment.
124        if cfg.insecure_skip_verify {
125            warn!(
126                event = "ipam_phpipam_tls_verification_disabled",
127                outcome = "advisory",
128                backend_url = %cfg.url,
129                "ipam.phpipam.insecure_skip_verify is on: phpIPAM's TLS certificate is not \
130                 verified, so the answers this server trusts could come from anyone able to \
131                 intercept the connection (ipam.phpipam.ca_cert_path is ignored while it is set)"
132            );
133        }
134
135        Ok(backend)
136    }
137
138    /// Same, against a caller-supplied API. Used by tests.
139    pub fn with_api(cfg: &PhpIpamConfig, api: Arc<dyn PhpIpamApi>) -> anyhow::Result<Self> {
140        let sources = parse_sources("phpIPAM", "ipam.phpipam.sources", &cfg.sources, SUPPORTED)?;
141
142        if sources.contains(&Source::CustomField) || sources.contains(&Source::Device) {
143            anyhow::ensure!(
144                !cfg.custom_field.trim().is_empty(),
145                "ipam.phpipam.custom_field is empty while ipam.phpipam.sources names \
146                 `custom_field` or `device`; name the phpIPAM column holding the permitted \
147                 names (default `custom_acme_allowed_names`)"
148            );
149        }
150
151        Ok(Self {
152            api,
153            custom_field: cfg.custom_field.clone(),
154            sources,
155        })
156    }
157
158    /// The custom column's entries, split on commas.
159    ///
160    /// Goes through the shared [`field_values`] first, so a phpIPAM that ever
161    /// starts returning a JSON array is read the same way NetBox's is; the
162    /// split is what a text column needs on top of that.
163    fn column_values(&self, fields: &Map<String, Value>, source: &str) -> Vec<String> {
164        field_values(fields, &self.custom_field, "phpIPAM", source)
165            .iter()
166            .flat_map(|value| value.split(','))
167            .map(str::trim)
168            .filter(|name| !name.is_empty())
169            .map(str::to_string)
170            .collect()
171    }
172
173    /// Adds one address object's own names, and reports whether its custom
174    /// column said anything — which is what decides the device fallback.
175    fn add_object_names(
176        &self,
177        names: &mut AddressNames,
178        fields: &Map<String, Value>,
179        hostname: &str,
180        source: &str,
181    ) -> bool {
182        if self.sources.contains(&Source::DnsName) {
183            names.insert(hostname);
184        }
185
186        if !self.sources.contains(&Source::CustomField) {
187            return false;
188        }
189
190        let values = self.column_values(fields, source);
191        let answered = !values.is_empty();
192        for value in values {
193            names.insert(&value);
194        }
195        answered
196    }
197}
198
199#[async_trait]
200impl Ipam for PhpIpamBackend {
201    fn name(&self) -> &'static str {
202        "phpIPAM"
203    }
204
205    async fn names_for(&self, client_ip: IpAddr) -> Result<AddressNames, IpamError> {
206        let found = self.api.search(client_ip).await.map_err(|error| {
207            IpamError(format!("phpIPAM lookup for {client_ip} failed: {error}"))
208        })?;
209
210        // `None` is phpIPAM's 404 — a fact about the address, not a failure to
211        // look it up. An empty list means the same thing and is treated alike.
212        let Some(objects) = found.filter(|objects| !objects.is_empty()) else {
213            return Ok(AddressNames::Unknown);
214        };
215
216        let mut names = AddressNames::known();
217        let mut custom_field_answered = false;
218        let mut device_id = None;
219
220        for object in &objects {
221            custom_field_answered |=
222                self.add_object_names(&mut names, &object.fields, &object.hostname, "address");
223            if device_id.is_none() {
224                device_id = object.device_id;
225            }
226        }
227
228        // A fallback: skipped entirely when the address spoke for itself. With
229        // `custom_field` not among the sources nothing was read from the
230        // address, so there is nothing to fall back *from* and the device's
231        // list always applies.
232        if let Some(id) = device_id
233            && self.sources.contains(&Source::Device)
234            && !custom_field_answered
235        {
236            let fields = self.api.device(id).await.map_err(|error| {
237                IpamError(format!(
238                    "phpIPAM lookup of device {id} for {client_ip} failed: {error}"
239                ))
240            })?;
241            for value in self.column_values(&fields, "device") {
242                names.insert(&value);
243            }
244        }
245
246        Ok(names)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254    use std::collections::HashMap;
255    use std::sync::atomic::{AtomicUsize, Ordering};
256
257    /// A phpIPAM answering from canned maps, never touching the network.
258    #[derive(Default)]
259    struct StubPhpIpam {
260        addresses: HashMap<IpAddr, Vec<PhpIpamAddress>>,
261        devices: HashMap<u64, Map<String, Value>>,
262        error: Option<String>,
263        device_calls: AtomicUsize,
264    }
265
266    impl StubPhpIpam {
267        fn with_address(mut self, ip: &str, objects: Vec<PhpIpamAddress>) -> Self {
268            self.addresses.insert(ip.parse().unwrap(), objects);
269            self
270        }
271
272        fn with_device(mut self, id: u64, fields: Value) -> Self {
273            self.devices.insert(id, fields.as_object().unwrap().clone());
274            self
275        }
276
277        fn failing(error: &str) -> Self {
278            Self {
279                error: Some(error.to_string()),
280                ..Self::default()
281            }
282        }
283    }
284
285    #[async_trait]
286    impl PhpIpamApi for StubPhpIpam {
287        async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String> {
288            if let Some(error) = &self.error {
289                return Err(error.clone());
290            }
291            Ok(self.addresses.get(&ip).cloned())
292        }
293
294        async fn device(&self, id: u64) -> Result<Map<String, Value>, String> {
295            self.device_calls.fetch_add(1, Ordering::SeqCst);
296            Ok(self.devices.get(&id).cloned().unwrap_or_default())
297        }
298    }
299
300    // ------------------------------------------------------------- fixtures
301
302    fn with_hostname(hostname: &str) -> PhpIpamAddress {
303        PhpIpamAddress {
304            hostname: hostname.to_string(),
305            ..PhpIpamAddress::default()
306        }
307    }
308
309    fn with_column(value: Value) -> PhpIpamAddress {
310        PhpIpamAddress {
311            fields: json!({ "custom_acme_allowed_names": value })
312                .as_object()
313                .unwrap()
314                .clone(),
315            ..PhpIpamAddress::default()
316        }
317    }
318
319    fn on_device(id: u64) -> PhpIpamAddress {
320        PhpIpamAddress {
321            device_id: Some(id),
322            ..PhpIpamAddress::default()
323        }
324    }
325
326    fn config() -> PhpIpamConfig {
327        PhpIpamConfig {
328            url: "https://ipam.example.com".to_string(),
329            token: "t0ken".to_string(),
330            ..PhpIpamConfig::default()
331        }
332    }
333
334    fn with_sources(sources: &[&str]) -> PhpIpamConfig {
335        PhpIpamConfig {
336            sources: sources.iter().map(|v| (*v).to_string()).collect(),
337            ..config()
338        }
339    }
340
341    fn backend(cfg: &PhpIpamConfig, api: StubPhpIpam) -> PhpIpamBackend {
342        PhpIpamBackend::with_api(cfg, Arc::new(api)).unwrap()
343    }
344
345    async fn names(backend: &PhpIpamBackend) -> AddressNames {
346        backend
347            .names_for("10.0.0.5".parse().unwrap())
348            .await
349            .unwrap()
350    }
351
352    fn assert_permits(names: &AddressNames, name: &str) {
353        assert!(
354            names.names().contains(name),
355            "{:?} lacks {name:?}",
356            names.names()
357        );
358    }
359
360    fn assert_refuses(names: &AddressNames, name: &str) {
361        assert!(
362            !names.names().contains(name),
363            "{:?} unexpectedly holds {name:?}",
364            names.names()
365        );
366    }
367
368    // ------------------------------------------------------- the happy paths
369
370    #[tokio::test]
371    async fn the_hostname_permits_that_name() {
372        let api = StubPhpIpam::default()
373            .with_address("10.0.0.5", vec![with_hostname("host.example.com")]);
374
375        assert_permits(&names(&backend(&config(), api)).await, "host.example.com");
376    }
377
378    #[tokio::test]
379    async fn the_custom_column_permits_its_names() {
380        let api = StubPhpIpam::default()
381            .with_address("10.0.0.5", vec![with_column(json!("www.example.com"))]);
382
383        assert_permits(&names(&backend(&config(), api)).await, "www.example.com");
384    }
385
386    /// A phpIPAM custom field is a text column, so several names arrive as one
387    /// comma-separated string.
388    #[tokio::test]
389    async fn a_comma_separated_column_yields_several_names() {
390        let api = StubPhpIpam::default().with_address(
391            "10.0.0.5",
392            vec![with_column(json!(
393                "www.example.com, api.example.com ,,mail.example.com"
394            ))],
395        );
396
397        let names = names(&backend(&config(), api)).await;
398        assert_permits(&names, "www.example.com");
399        assert_permits(&names, "api.example.com");
400        assert_permits(&names, "mail.example.com");
401        assert_eq!(names.names().len(), 3);
402    }
403
404    /// A phpIPAM that ever starts returning a JSON array is read the same way
405    /// NetBox's is, because both go through the shared `field_values`.
406    #[tokio::test]
407    async fn a_column_holding_a_list_is_accepted_too() {
408        let api = StubPhpIpam::default().with_address(
409            "10.0.0.5",
410            vec![with_column(json!(["a.example.com", "b.example.com"]))],
411        );
412
413        let names = names(&backend(&config(), api)).await;
414        assert_permits(&names, "a.example.com");
415        assert_permits(&names, "b.example.com");
416    }
417
418    #[tokio::test]
419    async fn names_are_normalized_on_the_way_in() {
420        let api = StubPhpIpam::default()
421            .with_address("10.0.0.5", vec![with_hostname("Host.Example.COM.")]);
422
423        assert_permits(&names(&backend(&config(), api)).await, "host.example.com");
424    }
425
426    // ------------------------------------------------------------- refusals
427
428    /// The 404 case, which is the one shape phpIPAM does differently from
429    /// NetBox: a fact about the address, never a retryable failure.
430    #[tokio::test]
431    async fn an_address_phpipam_does_not_know_is_unknown() {
432        let names = names(&backend(&config(), StubPhpIpam::default())).await;
433        assert_eq!(names, AddressNames::Unknown);
434    }
435
436    /// An empty list means the same thing as a 404 and is treated alike.
437    #[tokio::test]
438    async fn an_empty_result_is_also_unknown() {
439        let api = StubPhpIpam::default().with_address("10.0.0.5", Vec::new());
440
441        assert_eq!(names(&backend(&config(), api)).await, AddressNames::Unknown);
442    }
443
444    #[tokio::test]
445    async fn a_recorded_address_with_no_names_is_known_and_empty() {
446        let api = StubPhpIpam::default().with_address("10.0.0.5", vec![PhpIpamAddress::default()]);
447
448        let names = names(&backend(&config(), api)).await;
449        assert!(names.is_known());
450        assert!(names.names().is_empty());
451    }
452
453    #[tokio::test]
454    async fn a_failed_lookup_is_an_error_not_an_empty_answer() {
455        let backend = backend(&config(), StubPhpIpam::failing("HTTP 500"));
456
457        let error = backend
458            .names_for("10.0.0.5".parse().unwrap())
459            .await
460            .unwrap_err();
461        assert!(error.0.contains("HTTP 500"), "{error}");
462    }
463
464    // ------------------------------------------------------ the sources gate
465
466    #[tokio::test]
467    async fn dropping_dns_name_ignores_the_hostname() {
468        let api = StubPhpIpam::default()
469            .with_address("10.0.0.5", vec![with_hostname("host.example.com")]);
470        let cfg = with_sources(&["custom_field", "device"]);
471
472        assert_refuses(&names(&backend(&cfg, api)).await, "host.example.com");
473    }
474
475    #[tokio::test]
476    async fn dropping_custom_field_ignores_the_column() {
477        let api = StubPhpIpam::default().with_address(
478            "10.0.0.5",
479            vec![PhpIpamAddress {
480                hostname: "host.example.com".to_string(),
481                ..with_column(json!("www.example.com"))
482            }],
483        );
484        let cfg = with_sources(&["dns_name"]);
485
486        let names = names(&backend(&cfg, api)).await;
487        assert_permits(&names, "host.example.com");
488        assert_refuses(&names, "www.example.com");
489    }
490
491    // ------------------------------------------------------ device fallback
492
493    #[tokio::test]
494    async fn the_device_is_consulted_when_the_address_carries_no_names() {
495        let api = StubPhpIpam::default()
496            .with_address("10.0.0.5", vec![on_device(3)])
497            .with_device(
498                3,
499                json!({ "custom_acme_allowed_names": "machine.example.com" }),
500            );
501
502        assert_permits(
503            &names(&backend(&config(), api)).await,
504            "machine.example.com",
505        );
506    }
507
508    #[tokio::test]
509    async fn the_device_is_not_consulted_when_the_address_answered() {
510        let api = Arc::new(
511            StubPhpIpam::default()
512                .with_address(
513                    "10.0.0.5",
514                    vec![PhpIpamAddress {
515                        device_id: Some(3),
516                        ..with_column(json!("own.example.com"))
517                    }],
518                )
519                .with_device(
520                    3,
521                    json!({ "custom_acme_allowed_names": "machine.example.com" }),
522                ),
523        );
524        let backend = PhpIpamBackend::with_api(&config(), api.clone()).unwrap();
525
526        let names = names(&backend).await;
527        assert_permits(&names, "own.example.com");
528        assert_refuses(&names, "machine.example.com");
529        assert_eq!(api.device_calls.load(Ordering::SeqCst), 0);
530    }
531
532    #[tokio::test]
533    async fn a_hostname_alone_does_not_suppress_the_fallback() {
534        let api = StubPhpIpam::default()
535            .with_address(
536                "10.0.0.5",
537                vec![PhpIpamAddress {
538                    hostname: "host.example.com".to_string(),
539                    device_id: Some(3),
540                    ..PhpIpamAddress::default()
541                }],
542            )
543            .with_device(
544                3,
545                json!({ "custom_acme_allowed_names": "machine.example.com" }),
546            );
547
548        let names = names(&backend(&config(), api)).await;
549        assert_permits(&names, "host.example.com");
550        assert_permits(&names, "machine.example.com");
551    }
552
553    #[tokio::test]
554    async fn dropping_device_never_consults_it() {
555        let api = Arc::new(
556            StubPhpIpam::default()
557                .with_address("10.0.0.5", vec![on_device(3)])
558                .with_device(
559                    3,
560                    json!({ "custom_acme_allowed_names": "machine.example.com" }),
561                ),
562        );
563        let cfg = with_sources(&["dns_name", "custom_field"]);
564        let backend = PhpIpamBackend::with_api(&cfg, api.clone()).unwrap();
565
566        assert_refuses(&names(&backend).await, "machine.example.com");
567        assert_eq!(api.device_calls.load(Ordering::SeqCst), 0);
568    }
569
570    #[tokio::test]
571    async fn an_address_on_no_device_makes_no_further_query() {
572        let api = Arc::new(
573            StubPhpIpam::default()
574                .with_address("10.0.0.5", vec![with_hostname("host.example.com")]),
575        );
576        let backend = PhpIpamBackend::with_api(&config(), api.clone()).unwrap();
577
578        assert_permits(&names(&backend).await, "host.example.com");
579        assert_eq!(api.device_calls.load(Ordering::SeqCst), 0);
580    }
581
582    #[tokio::test]
583    async fn a_failing_device_lookup_is_an_error() {
584        struct DeviceFails;
585        #[async_trait]
586        impl PhpIpamApi for DeviceFails {
587            async fn search(&self, _ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String> {
588                Ok(Some(vec![PhpIpamAddress {
589                    device_id: Some(3),
590                    ..PhpIpamAddress::default()
591                }]))
592            }
593            async fn device(&self, _id: u64) -> Result<Map<String, Value>, String> {
594                Err("HTTP 403".to_string())
595            }
596        }
597
598        let backend = PhpIpamBackend::with_api(&config(), Arc::new(DeviceFails)).unwrap();
599        let error = backend
600            .names_for("10.0.0.5".parse().unwrap())
601            .await
602            .unwrap_err();
603        assert!(error.0.contains("HTTP 403"), "{error}");
604        assert!(error.0.contains("device 3"), "{error}");
605    }
606
607    // ------------------------------------------------------ startup + wiring
608
609    /// The refusal that proves the trait is not just NetBox with extra steps:
610    /// a source this product cannot answer for is named, not ignored.
611    #[test]
612    fn a_netbox_only_source_is_refused_by_name() {
613        for source in ["vip", "fhrp"] {
614            let cfg = with_sources(&["dns_name", source]);
615            let error = PhpIpamBackend::with_api(&cfg, Arc::new(StubPhpIpam::default()))
616                .unwrap_err()
617                .to_string();
618            assert!(
619                error.contains(&format!("`{source}` is not a source phpIPAM has")),
620                "{error}"
621            );
622        }
623    }
624
625    #[test]
626    fn an_empty_custom_field_is_a_startup_error_when_something_reads_it() {
627        let cfg = PhpIpamConfig {
628            custom_field: "   ".to_string(),
629            ..config()
630        };
631        let error = PhpIpamBackend::with_api(&cfg, Arc::new(StubPhpIpam::default()))
632            .unwrap_err()
633            .to_string();
634        assert!(error.contains("ipam.phpipam.custom_field"), "{error}");
635    }
636
637    #[test]
638    fn an_empty_custom_field_is_fine_when_nothing_reads_it() {
639        let cfg = PhpIpamConfig {
640            custom_field: String::new(),
641            ..with_sources(&["dns_name"])
642        };
643        PhpIpamBackend::with_api(&cfg, Arc::new(StubPhpIpam::default()))
644            .expect("no source reads the custom field");
645    }
646
647    #[test]
648    fn an_empty_sources_list_is_a_startup_error() {
649        let cfg = with_sources(&[]);
650        let error = PhpIpamBackend::with_api(&cfg, Arc::new(StubPhpIpam::default()))
651            .unwrap_err()
652            .to_string();
653        assert!(error.contains("ipam.phpipam.sources is empty"), "{error}");
654    }
655
656    #[test]
657    fn reports_the_product_name() {
658        assert_eq!(backend(&config(), StubPhpIpam::default()).name(), "phpIPAM");
659    }
660
661    #[test]
662    fn the_debug_impl_shows_the_policy_without_the_api() {
663        let rendered = format!("{:?}", backend(&config(), StubPhpIpam::default()));
664        assert!(rendered.contains("custom_acme_allowed_names"), "{rendered}");
665        assert!(rendered.contains("Device"), "{rendered}");
666    }
667}