1pub mod custom;
49pub mod http;
50pub mod netbox;
51pub mod phpipam;
52
53use std::collections::BTreeSet;
54use std::net::IpAddr;
55use std::sync::Arc;
56use std::time::Duration;
57
58use async_trait::async_trait;
59use serde_json::{Map, Value};
60use tracing::{info, warn};
61
62use crate::config::IpamConfig;
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum AddressNames {
67 Unknown,
73 Known(BTreeSet<String>),
75}
76
77impl AddressNames {
78 #[must_use]
80 pub fn known() -> Self {
81 Self::Known(BTreeSet::new())
82 }
83
84 pub fn insert(&mut self, value: &str) {
90 if let Self::Known(names) = self {
91 let name = normalize(value);
92 if !name.is_empty() {
93 names.insert(name);
94 }
95 }
96 }
97
98 #[must_use]
100 pub fn is_known(&self) -> bool {
101 matches!(self, Self::Known(_))
102 }
103
104 #[must_use]
106 pub fn names(&self) -> &BTreeSet<String> {
107 static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
108 match self {
109 Self::Known(names) => names,
110 Self::Unknown => EMPTY.get_or_init(BTreeSet::new),
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
123#[error("{0}")]
124pub struct IpamError(pub String);
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub enum Source {
134 DnsName,
137 CustomField,
139 Device,
147 Vip,
151 Fhrp,
154}
155
156impl Source {
157 #[must_use]
159 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::DnsName => "dns_name",
162 Self::CustomField => "custom_field",
163 Self::Device => "device",
164 Self::Vip => "vip",
165 Self::Fhrp => "fhrp",
166 }
167 }
168
169 const ALL: &'static [Self] = &[
171 Self::DnsName,
172 Self::CustomField,
173 Self::Device,
174 Self::Vip,
175 Self::Fhrp,
176 ];
177
178 fn parse(name: &str) -> Option<Self> {
179 Self::ALL.iter().copied().find(|s| s.as_str() == name)
180 }
181}
182
183pub type Sources = BTreeSet<Source>;
189
190pub(crate) fn parse_sources(
202 backend: &str,
203 setting: &str,
204 values: &[String],
205 supported: &[Source],
206) -> anyhow::Result<Sources> {
207 anyhow::ensure!(
208 !values.is_empty(),
209 "{setting} is empty; an inventory trusted for nothing can never permit a name, so \
210 every request would be refused. List at least one of: {}",
211 names_of(supported)
212 );
213
214 let mut sources = Sources::new();
215 for value in values {
216 let name = value.trim();
217 let source = Source::parse(name).ok_or_else(|| {
218 anyhow::anyhow!(
219 "{setting}: unknown source `{name}`; known sources are {}",
220 names_of(Source::ALL)
221 )
222 })?;
223 anyhow::ensure!(
224 supported.contains(&source),
225 "{setting}: `{name}` is not a source {backend} has; it supports {}",
226 names_of(supported)
227 );
228 sources.insert(source);
229 }
230 Ok(sources)
231}
232
233fn names_of(sources: &[Source]) -> String {
235 sources
236 .iter()
237 .map(|source| format!("`{}`", source.as_str()))
238 .collect::<Vec<_>>()
239 .join(", ")
240}
241
242#[async_trait]
244pub trait Ipam: Send + Sync {
245 fn name(&self) -> &'static str;
247
248 async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError>;
250}
251
252pub struct IpamRegistry {
260 backend: Arc<dyn Ipam>,
261 timeout: Duration,
262}
263
264impl std::fmt::Debug for IpamRegistry {
265 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 formatter
268 .debug_struct("IpamRegistry")
269 .field("backend", &self.backend.name())
270 .field("timeout", &self.timeout)
271 .finish()
272 }
273}
274
275impl IpamRegistry {
276 #[must_use]
278 pub fn new(backend: Arc<dyn Ipam>, timeout: Duration) -> Self {
279 Self { backend, timeout }
280 }
281
282 #[must_use]
284 pub fn backend_name(&self) -> &'static str {
285 self.backend.name()
286 }
287
288 pub async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError> {
290 match tokio::time::timeout(self.timeout, self.backend.names_for(ip)).await {
291 Ok(result) => result,
292 Err(_) => Err(IpamError(format!(
293 "{} lookup for {ip} timed out after {}ms",
294 self.backend.name(),
295 self.timeout.as_millis()
296 ))),
297 }
298 }
299}
300
301pub fn from_config(
308 cfg: &IpamConfig,
309 outbound: crate::http_client::Outbound,
310) -> anyhow::Result<Option<Arc<IpamRegistry>>> {
311 let backend: Arc<dyn Ipam> = match cfg.backend.trim() {
312 "" => return Ok(None),
313 "netbox" => Arc::new(netbox::NetboxBackend::from_config(&cfg.netbox, outbound)?),
314 "phpipam" => Arc::new(phpipam::PhpIpamBackend::from_config(
315 &cfg.phpipam,
316 outbound,
317 )?),
318 "custom" => Arc::new(custom::CustomIpamBackend::from_config(
321 &cfg.custom,
322 cfg.timeout_ms,
323 )?),
324 other => anyhow::bail!(
325 "unknown IPAM backend: {other} (expected `netbox`, `phpipam` or `custom`)"
326 ),
327 };
328
329 info!(
330 event = "ipam_enabled",
331 outcome = "success",
332 backend = backend.name(),
333 timeout_ms = cfg.timeout_ms,
334 );
335
336 Ok(Some(Arc::new(IpamRegistry::new(
337 backend,
338 Duration::from_millis(cfg.timeout_ms),
339 ))))
340}
341
342#[must_use]
344pub fn normalize(value: &str) -> String {
345 value.trim().trim_end_matches('.').to_ascii_lowercase()
346}
347
348pub(crate) fn field_values(
355 fields: &Map<String, Value>,
356 field: &str,
357 backend: &'static str,
358 source: &str,
359) -> Vec<String> {
360 match fields.get(field) {
361 None | Some(Value::Null) => Vec::new(),
362 Some(Value::String(one)) => vec![one.clone()],
363 Some(Value::Array(items)) => items
364 .iter()
365 .filter_map(|item| match item {
366 Value::String(name) => Some(name.clone()),
367 other => {
368 warn!(
369 event = "ipam_field_entry_ignored",
370 outcome = "advisory",
371 backend,
372 field,
373 source,
374 entry = %other,
375 "custom field entry is not a string"
376 );
377 None
378 }
379 })
380 .collect(),
381 Some(other) => {
382 warn!(
383 event = "ipam_field_ignored",
384 outcome = "advisory",
385 backend,
386 field,
387 source,
388 kind = value_kind(other),
389 "custom field is neither a string nor a list of strings"
390 );
391 Vec::new()
392 }
393 }
394}
395
396pub(crate) fn value_kind(value: &Value) -> &'static str {
398 match value {
399 Value::Null => "null",
400 Value::Bool(_) => "bool",
401 Value::Number(_) => "number",
402 Value::String(_) => "string",
403 Value::Array(_) => "array",
404 Value::Object(_) => "object",
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use serde_json::json;
412
413 fn strings(values: &[&str]) -> Vec<String> {
414 values.iter().map(|v| (*v).to_string()).collect()
415 }
416
417 fn resolver() -> Arc<dyn crate::dns::Resolver> {
418 crate::challenge::build_resolver(None).unwrap()
419 }
420
421 #[test]
424 fn every_source_round_trips_through_its_name() {
425 for source in Source::ALL {
426 assert_eq!(Source::parse(source.as_str()), Some(*source));
427 }
428 assert_eq!(Source::parse("nope"), None);
429 }
430
431 #[test]
432 fn sources_parse_and_deduplicate() {
433 let parsed = parse_sources(
434 "NetBox",
435 "ipam.netbox.sources",
436 &strings(&["dns_name", "custom_field", "dns_name"]),
437 Source::ALL,
438 )
439 .unwrap();
440 assert_eq!(parsed.len(), 2);
441 assert!(parsed.contains(&Source::DnsName));
442 assert!(parsed.contains(&Source::CustomField));
443 }
444
445 #[test]
448 fn sources_are_trimmed() {
449 let parsed = parse_sources(
450 "NetBox",
451 "ipam.netbox.sources",
452 &strings(&[" dns_name "]),
453 Source::ALL,
454 )
455 .unwrap();
456 assert!(parsed.contains(&Source::DnsName));
457 }
458
459 #[test]
460 fn an_empty_sources_list_is_a_startup_error() {
461 let error = parse_sources("NetBox", "ipam.netbox.sources", &[], Source::ALL).unwrap_err();
462 let message = error.to_string();
463 assert!(
464 message.contains("ipam.netbox.sources is empty"),
465 "{message}"
466 );
467 assert!(message.contains("`dns_name`"), "{message}");
468 }
469
470 #[test]
471 fn an_unknown_source_is_a_startup_error_naming_it() {
472 let error = parse_sources(
473 "NetBox",
474 "ipam.netbox.sources",
475 &strings(&["dns_name", "typo"]),
476 Source::ALL,
477 )
478 .unwrap_err();
479 let message = error.to_string();
480 assert!(message.contains("unknown source `typo`"), "{message}");
481 assert!(message.contains("`fhrp`"), "{message}");
482 }
483
484 #[test]
488 fn a_source_another_backend_has_is_refused_by_name() {
489 let error = parse_sources(
490 "phpIPAM",
491 "ipam.phpipam.sources",
492 &strings(&["dns_name", "fhrp"]),
493 &[Source::DnsName, Source::CustomField, Source::Device],
494 )
495 .unwrap_err();
496 let message = error.to_string();
497 assert!(
498 message.contains("`fhrp` is not a source phpIPAM has"),
499 "{message}"
500 );
501 assert!(message.contains("`device`"), "{message}");
502 assert!(!message.contains("`vip`"), "{message}");
503 }
504
505 #[test]
508 fn an_unknown_address_is_not_an_empty_one() {
509 let unknown = AddressNames::Unknown;
510 let empty = AddressNames::known();
511 assert!(!unknown.is_known());
512 assert!(empty.is_known());
513 assert_eq!(unknown.names().len(), 0);
514 assert_ne!(unknown, empty);
515 }
516
517 #[test]
518 fn inserting_normalizes_and_skips_empties() {
519 let mut names = AddressNames::known();
520 names.insert("Host.Example.COM.");
521 names.insert(" ");
522 names.insert("");
523 names.insert("host.example.com");
524 assert_eq!(
525 names.names().iter().cloned().collect::<Vec<_>>(),
526 vec!["host.example.com".to_string()]
527 );
528 }
529
530 #[test]
531 fn inserting_into_an_unknown_address_does_nothing() {
532 let mut names = AddressNames::Unknown;
533 names.insert("host.example.com");
534 assert_eq!(names, AddressNames::Unknown);
535 }
536
537 #[test]
538 fn normalize_lowercases_and_strips_a_trailing_dot() {
539 assert_eq!(normalize(" Host.Example.COM. "), "host.example.com");
540 assert_eq!(normalize("*.Example.com"), "*.example.com");
541 }
542
543 #[test]
546 fn a_custom_field_may_be_a_string_or_a_list() {
547 let fields: Map<String, Value> = serde_json::from_value(json!({
548 "one": "a.example.com",
549 "many": ["a.example.com", "b.example.com"],
550 }))
551 .unwrap();
552
553 assert_eq!(field_values(&fields, "one", "NetBox", "address").len(), 1);
554 assert_eq!(field_values(&fields, "many", "NetBox", "address").len(), 2);
555 }
556
557 #[test]
560 fn an_unusable_custom_field_contributes_nothing() {
561 let fields: Map<String, Value> = serde_json::from_value(json!({
562 "absent": Value::Null,
563 "number": 7,
564 "object": {"a": 1},
565 "mixed": ["a.example.com", 7, {"b": 2}],
566 }))
567 .unwrap();
568
569 assert!(field_values(&fields, "missing", "NetBox", "address").is_empty());
570 assert!(field_values(&fields, "absent", "NetBox", "address").is_empty());
571 assert!(field_values(&fields, "number", "NetBox", "address").is_empty());
572 assert!(field_values(&fields, "object", "NetBox", "address").is_empty());
573 assert_eq!(field_values(&fields, "mixed", "NetBox", "address").len(), 1);
574 }
575
576 #[test]
577 fn value_kind_names_every_json_type() {
578 assert_eq!(value_kind(&Value::Null), "null");
579 assert_eq!(value_kind(&json!(true)), "bool");
580 assert_eq!(value_kind(&json!(1)), "number");
581 assert_eq!(value_kind(&json!("s")), "string");
582 assert_eq!(value_kind(&json!([])), "array");
583 assert_eq!(value_kind(&json!({})), "object");
584 }
585
586 #[test]
589 fn no_backend_builds_nothing() {
590 let cfg = IpamConfig::default();
591 assert!(
592 from_config(&cfg, crate::testutil::outbound_with(resolver()))
593 .unwrap()
594 .is_none()
595 );
596 }
597
598 #[test]
599 fn each_backend_builds() {
600 let netbox = from_config(
601 &IpamConfig {
602 backend: "netbox".to_string(),
603 netbox: crate::config::NetboxConfig {
604 url: "https://netbox.example.com".to_string(),
605 token: "t0ken".to_string(),
606 ..crate::config::NetboxConfig::default()
607 },
608 ..IpamConfig::default()
609 },
610 crate::testutil::outbound_with(resolver()),
611 )
612 .unwrap()
613 .unwrap();
614 assert_eq!(netbox.backend_name(), "NetBox");
615
616 let phpipam = from_config(
617 &IpamConfig {
618 backend: "phpipam".to_string(),
619 phpipam: crate::config::PhpIpamConfig {
620 url: "https://ipam.example.com".to_string(),
621 token: "t0ken".to_string(),
622 ..crate::config::PhpIpamConfig::default()
623 },
624 ..IpamConfig::default()
625 },
626 crate::testutil::outbound_with(resolver()),
627 )
628 .unwrap()
629 .unwrap();
630 assert_eq!(phpipam.backend_name(), "phpIPAM");
631
632 let dir = crate::testutil::TempDir::new("ipam-from-config");
633 let script = crate::testutil::write_script(&dir, "ipam.sh", "#!/bin/sh\nexit 3\n");
634 let custom = from_config(
635 &IpamConfig {
636 backend: "custom".to_string(),
637 custom: crate::config::CustomIpamConfig {
638 script_path: script.display().to_string(),
639 ..crate::config::CustomIpamConfig::default()
640 },
641 ..IpamConfig::default()
642 },
643 crate::testutil::outbound_with(resolver()),
644 )
645 .unwrap()
646 .unwrap();
647 assert_eq!(custom.backend_name(), "the custom IPAM script");
648 }
649
650 #[test]
654 fn a_custom_backend_with_no_script_is_a_startup_error() {
655 let cfg = IpamConfig {
656 backend: "custom".to_string(),
657 ..IpamConfig::default()
658 };
659 let error = from_config(&cfg, crate::testutil::outbound_with(resolver()))
660 .unwrap_err()
661 .to_string();
662 assert!(error.contains("ipam.custom.script_path"), "{error}");
663 }
664
665 #[test]
666 fn an_unknown_backend_is_a_startup_error_naming_the_valid_ones() {
667 let cfg = IpamConfig {
668 backend: "racktables".to_string(),
669 ..IpamConfig::default()
670 };
671 let error = from_config(&cfg, crate::testutil::outbound_with(resolver()))
672 .unwrap_err()
673 .to_string();
674 assert!(error.contains("racktables"), "{error}");
675 assert!(error.contains("netbox"), "{error}");
676 assert!(error.contains("phpipam"), "{error}");
677 assert!(error.contains("custom"), "{error}");
678 }
679
680 struct Hanging;
683
684 #[async_trait]
685 impl Ipam for Hanging {
686 fn name(&self) -> &'static str {
687 "Hanging"
688 }
689 async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
690 tokio::time::sleep(Duration::from_secs(3600)).await;
691 unreachable!("the registry's budget expires first")
692 }
693 }
694
695 struct Answering;
696
697 #[async_trait]
698 impl Ipam for Answering {
699 fn name(&self) -> &'static str {
700 "Answering"
701 }
702 async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
703 let mut names = AddressNames::known();
704 names.insert("a.example.com");
705 Ok(names)
706 }
707 }
708
709 #[tokio::test]
713 async fn the_registry_applies_the_budget() {
714 let registry = IpamRegistry::new(Arc::new(Hanging), Duration::from_millis(10));
715 let error = registry
716 .names_for("10.0.0.5".parse().unwrap())
717 .await
718 .unwrap_err();
719 assert!(error.0.contains("timed out after 10ms"), "{error}");
720 assert!(error.0.contains("Hanging"), "{error}");
721 }
722
723 #[tokio::test]
724 async fn a_prompt_backend_answers_through_the_registry() {
725 let registry = IpamRegistry::new(Arc::new(Answering), Duration::from_secs(5));
726 let names = registry
727 .names_for("10.0.0.5".parse().unwrap())
728 .await
729 .unwrap();
730 assert!(names.names().contains("a.example.com"));
731 assert_eq!(registry.backend_name(), "Answering");
732 assert!(format!("{registry:?}").contains("Answering"));
733 }
734}