1pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct PhpIpamAddress {
58 pub hostname: String,
60 pub fields: Map<String, Value>,
63 pub device_id: Option<u64>,
65}
66
67#[async_trait]
73pub trait PhpIpamApi: Send + Sync {
74 async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String>;
77
78 async fn device(&self, id: u64) -> Result<Map<String, Value>, String>;
80}
81
82pub struct PhpIpamBackend {
84 api: Arc<dyn PhpIpamApi>,
85 custom_field: String,
86 sources: Sources,
87}
88
89impl std::fmt::Debug for PhpIpamBackend {
90 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
100const SUPPORTED: &[Source] = &[Source::DnsName, Source::CustomField, Source::Device];
103
104impl PhpIpamBackend {
105 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 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}