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