1pub mod client;
54
55use std::net::IpAddr;
56use std::sync::Arc;
57
58use async_trait::async_trait;
59use serde_json::{Map, Value};
60use tracing::{debug, info, warn};
61
62use super::{AddressNames, Ipam, IpamError, Source, Sources, field_values, parse_sources};
63use crate::config::NetboxConfig;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum AssignedKind {
71 Device,
72 VirtualMachine,
73}
74
75impl AssignedKind {
76 fn owner_filter(self) -> &'static str {
78 match self {
79 Self::Device => "device_id",
80 Self::VirtualMachine => "virtual_machine_id",
81 }
82 }
83
84 fn interface_type(self) -> &'static str {
86 match self {
87 Self::Device => client::DEVICE_INTERFACE,
88 Self::VirtualMachine => client::VM_INTERFACE,
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct AssignedRef {
101 pub kind: AssignedKind,
102 pub id: u64,
104 pub interface_id: u64,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct NetboxIp {
111 pub dns_name: String,
113 pub custom_fields: Map<String, Value>,
115 pub assigned: Option<AssignedRef>,
117 pub role: Option<String>,
119}
120
121#[async_trait]
128pub trait NetboxApi: Send + Sync {
129 async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String>;
131
132 async fn object_custom_fields(
134 &self,
135 reference: &AssignedRef,
136 ) -> Result<Map<String, Value>, String>;
137
138 async fn shared_addresses(
140 &self,
141 reference: &AssignedRef,
142 roles: &[String],
143 ) -> Result<Vec<NetboxIp>, String>;
144
145 async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String>;
147
148 async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String>;
150}
151
152pub struct NetboxBackend {
154 api: Arc<dyn NetboxApi>,
155 custom_field: String,
156 sources: Sources,
157 vip_roles: Vec<String>,
158}
159
160impl std::fmt::Debug for NetboxBackend {
161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 formatter
164 .debug_struct("NetboxBackend")
165 .field("custom_field", &self.custom_field)
166 .field("sources", &self.sources)
167 .field("vip_roles", &self.vip_roles)
168 .finish_non_exhaustive()
169 }
170}
171
172const SUPPORTED: &[Source] = &[
174 Source::DnsName,
175 Source::CustomField,
176 Source::Device,
177 Source::Vip,
178 Source::Fhrp,
179];
180
181impl NetboxBackend {
182 pub fn from_config(
188 cfg: &NetboxConfig,
189 outbound: crate::http_client::Outbound,
190 ) -> anyhow::Result<Self> {
191 let api = Arc::new(client::NetboxClient::new(cfg, outbound)?);
192 let backend = Self::with_api(cfg, api)?;
193
194 info!(
195 event = "ipam_netbox_loaded",
196 outcome = "success",
197 backend_url = %cfg.url,
198 custom_field = %cfg.custom_field,
199 sources = ?backend.sources,
200 vip_roles = ?cfg.vip_roles,
201 );
202
203 if cfg.insecure_skip_verify {
208 warn!(
209 event = "ipam_netbox_tls_verification_disabled",
210 outcome = "advisory",
211 backend_url = %cfg.url,
212 "ipam.netbox.insecure_skip_verify is on: NetBox's TLS certificate is not \
213 verified, so the answers this server trusts could come from anyone able to \
214 intercept the connection (ipam.netbox.ca_cert_path is ignored while it is set)"
215 );
216 }
217
218 Ok(backend)
219 }
220
221 pub fn with_api(cfg: &NetboxConfig, api: Arc<dyn NetboxApi>) -> anyhow::Result<Self> {
223 let sources = parse_sources("NetBox", "ipam.netbox.sources", &cfg.sources, SUPPORTED)?;
224
225 if sources.contains(&Source::CustomField) || sources.contains(&Source::Device) {
229 anyhow::ensure!(
230 !cfg.custom_field.trim().is_empty(),
231 "ipam.netbox.custom_field is empty while ipam.netbox.sources names \
232 `custom_field` or `device`; name the NetBox custom field holding the \
233 permitted names (default `acme_allowed_names`)"
234 );
235 }
236 if sources.contains(&Source::Vip) {
237 anyhow::ensure!(
238 !cfg.vip_roles.is_empty(),
239 "ipam.netbox.vip_roles is empty while ipam.netbox.sources names `vip`; \
240 list the NetBox address roles that mark a service address (e.g. `vrrp`), \
241 or drop `vip` from ipam.netbox.sources"
242 );
243 }
244
245 Ok(Self {
246 api,
247 custom_field: cfg.custom_field.clone(),
248 sources,
249 vip_roles: cfg.vip_roles.clone(),
250 })
251 }
252
253 fn add_object_names(&self, names: &mut AddressNames, object: &NetboxIp, source: &str) -> bool {
260 if self.sources.contains(&Source::DnsName) {
261 names.insert(&object.dns_name);
262 }
263
264 if !self.sources.contains(&Source::CustomField) {
265 return false;
266 }
267
268 let values = field_values(&object.custom_fields, &self.custom_field, "NetBox", source);
269 let answered = !values.is_empty();
270 for value in values {
271 names.insert(&value);
272 }
273 answered
274 }
275
276 async fn add_device_names(
278 &self,
279 names: &mut AddressNames,
280 reference: &AssignedRef,
281 client_ip: IpAddr,
282 ) -> Result<(), IpamError> {
283 let fields = self
284 .api
285 .object_custom_fields(reference)
286 .await
287 .map_err(|error| {
288 IpamError(format!(
289 "NetBox lookup of {:?} {} for {client_ip} failed: {error}",
290 reference.kind, reference.id
291 ))
292 })?;
293 for value in field_values(&fields, &self.custom_field, "NetBox", "assigned object") {
294 names.insert(&value);
295 }
296 Ok(())
297 }
298
299 async fn add_vip_names(
301 &self,
302 names: &mut AddressNames,
303 reference: &AssignedRef,
304 client_ip: IpAddr,
305 ) -> Result<(), IpamError> {
306 let objects = self
307 .api
308 .shared_addresses(reference, &self.vip_roles)
309 .await
310 .map_err(|error| {
311 IpamError(format!(
312 "NetBox lookup of service addresses on {:?} {} for {client_ip} failed: \
313 {error}",
314 reference.kind, reference.id
315 ))
316 })?;
317
318 for object in &objects {
319 let permitted = object
325 .role
326 .as_deref()
327 .is_some_and(|role| self.vip_roles.iter().any(|wanted| wanted == role));
328 if !permitted {
329 debug!(
330 event = "ipam_netbox_vip_role_ignored",
331 outcome = "advisory",
332 role = object.role.as_deref().unwrap_or(""),
333 "service address does not carry a configured role"
334 );
335 continue;
336 }
337 self.add_object_names(names, object, "service address");
338 }
339 Ok(())
340 }
341
342 async fn add_fhrp_names(
344 &self,
345 names: &mut AddressNames,
346 reference: &AssignedRef,
347 client_ip: IpAddr,
348 ) -> Result<(), IpamError> {
349 let groups = self.api.fhrp_groups(reference).await.map_err(|error| {
350 IpamError(format!(
351 "NetBox lookup of FHRP group membership for interface {} ({client_ip}) failed: \
352 {error}",
353 reference.interface_id
354 ))
355 })?;
356
357 if groups.is_empty() {
360 debug!(
361 event = "ipam_netbox_fhrp_no_membership",
362 outcome = "advisory",
363 interface_id = reference.interface_id,
364 );
365 return Ok(());
366 }
367
368 let objects = self
369 .api
370 .fhrp_group_addresses(&groups)
371 .await
372 .map_err(|error| {
373 IpamError(format!(
374 "NetBox lookup of FHRP group addresses for {client_ip} failed: {error}"
375 ))
376 })?;
377
378 for object in &objects {
379 self.add_object_names(names, object, "FHRP group address");
380 }
381 Ok(())
382 }
383}
384
385#[async_trait]
386impl Ipam for NetboxBackend {
387 fn name(&self) -> &'static str {
388 "NetBox"
389 }
390
391 async fn names_for(&self, client_ip: IpAddr) -> Result<AddressNames, IpamError> {
392 let objects =
393 self.api.ip_addresses(client_ip).await.map_err(|error| {
394 IpamError(format!("NetBox lookup for {client_ip} failed: {error}"))
395 })?;
396
397 if objects.is_empty() {
398 return Ok(AddressNames::Unknown);
399 }
400
401 let mut names = AddressNames::known();
402 let mut custom_field_answered = false;
403 let mut assigned = None;
404
405 for object in &objects {
406 custom_field_answered |= self.add_object_names(&mut names, object, "IP address object");
407 if assigned.is_none() {
408 assigned.clone_from(&object.assigned);
409 }
410 }
411
412 let Some(reference) = assigned else {
413 return Ok(names);
415 };
416
417 if self.sources.contains(&Source::Device) && !custom_field_answered {
422 self.add_device_names(&mut names, &reference, client_ip)
423 .await?;
424 }
425
426 if self.sources.contains(&Source::Vip) {
430 self.add_vip_names(&mut names, &reference, client_ip)
431 .await?;
432 }
433 if self.sources.contains(&Source::Fhrp) {
434 self.add_fhrp_names(&mut names, &reference, client_ip)
435 .await?;
436 }
437
438 Ok(names)
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use serde_json::json;
446 use std::collections::HashMap;
447 use std::sync::atomic::{AtomicUsize, Ordering};
448
449 #[derive(Default)]
453 struct StubNetbox {
454 addresses: HashMap<IpAddr, Vec<NetboxIp>>,
455 objects: HashMap<u64, Map<String, Value>>,
456 shared: HashMap<u64, Vec<NetboxIp>>,
458 memberships: HashMap<u64, Vec<u64>>,
460 group_addresses: HashMap<u64, Vec<NetboxIp>>,
462 error: Option<String>,
463 object_calls: AtomicUsize,
466 shared_calls: AtomicUsize,
467 membership_calls: AtomicUsize,
468 group_address_calls: AtomicUsize,
469 }
470
471 impl StubNetbox {
472 fn with_address(mut self, ip: &str, objects: Vec<NetboxIp>) -> Self {
473 self.addresses.insert(ip.parse().unwrap(), objects);
474 self
475 }
476
477 fn with_object(mut self, id: u64, fields: Value) -> Self {
478 self.objects.insert(id, fields.as_object().unwrap().clone());
479 self
480 }
481
482 fn with_shared(mut self, device_id: u64, objects: Vec<NetboxIp>) -> Self {
483 self.shared.insert(device_id, objects);
484 self
485 }
486
487 fn with_membership(mut self, interface_id: u64, groups: Vec<u64>) -> Self {
488 self.memberships.insert(interface_id, groups);
489 self
490 }
491
492 fn with_group_address(mut self, group_id: u64, objects: Vec<NetboxIp>) -> Self {
493 self.group_addresses.insert(group_id, objects);
494 self
495 }
496
497 fn failing(error: &str) -> Self {
498 Self {
499 error: Some(error.to_string()),
500 ..Self::default()
501 }
502 }
503 }
504
505 #[async_trait]
506 impl NetboxApi for StubNetbox {
507 async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
508 if let Some(error) = &self.error {
509 return Err(error.clone());
510 }
511 Ok(self.addresses.get(&ip).cloned().unwrap_or_default())
512 }
513
514 async fn object_custom_fields(
515 &self,
516 reference: &AssignedRef,
517 ) -> Result<Map<String, Value>, String> {
518 self.object_calls.fetch_add(1, Ordering::SeqCst);
519 Ok(self.objects.get(&reference.id).cloned().unwrap_or_default())
520 }
521
522 async fn shared_addresses(
523 &self,
524 reference: &AssignedRef,
525 _roles: &[String],
526 ) -> Result<Vec<NetboxIp>, String> {
527 self.shared_calls.fetch_add(1, Ordering::SeqCst);
528 Ok(self.shared.get(&reference.id).cloned().unwrap_or_default())
529 }
530
531 async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String> {
532 self.membership_calls.fetch_add(1, Ordering::SeqCst);
533 Ok(self
534 .memberships
535 .get(&reference.interface_id)
536 .cloned()
537 .unwrap_or_default())
538 }
539
540 async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String> {
541 self.group_address_calls.fetch_add(1, Ordering::SeqCst);
542 Ok(group_ids
543 .iter()
544 .filter_map(|id| self.group_addresses.get(id))
545 .flatten()
546 .cloned()
547 .collect())
548 }
549 }
550
551 fn with_dns_name(dns_name: &str) -> NetboxIp {
555 NetboxIp {
556 dns_name: dns_name.to_string(),
557 ..NetboxIp::default()
558 }
559 }
560
561 fn with_field(names: Value) -> NetboxIp {
563 NetboxIp {
564 custom_fields: json!({ "acme_allowed_names": names })
565 .as_object()
566 .unwrap()
567 .clone(),
568 ..NetboxIp::default()
569 }
570 }
571
572 fn on_device() -> AssignedRef {
574 AssignedRef {
575 kind: AssignedKind::Device,
576 id: 3,
577 interface_id: 7,
578 }
579 }
580
581 fn assigned() -> NetboxIp {
583 NetboxIp {
584 assigned: Some(on_device()),
585 ..NetboxIp::default()
586 }
587 }
588
589 fn service(role: &str, dns_name: &str) -> NetboxIp {
591 NetboxIp {
592 dns_name: dns_name.to_string(),
593 role: Some(role.to_string()),
594 ..NetboxIp::default()
595 }
596 }
597
598 fn strings(values: &[&str]) -> Vec<String> {
599 values.iter().map(|v| (*v).to_string()).collect()
600 }
601
602 fn config() -> NetboxConfig {
603 NetboxConfig {
604 url: "https://netbox.example.com".to_string(),
605 token: "t0ken".to_string(),
606 ..NetboxConfig::default()
607 }
608 }
609
610 fn with_sources(sources: &[&str]) -> NetboxConfig {
611 NetboxConfig {
612 sources: strings(sources),
613 ..config()
614 }
615 }
616
617 fn backend(cfg: &NetboxConfig, api: StubNetbox) -> NetboxBackend {
618 NetboxBackend::with_api(cfg, Arc::new(api)).unwrap()
619 }
620
621 async fn names(backend: &NetboxBackend) -> AddressNames {
623 backend
624 .names_for("10.0.0.5".parse().unwrap())
625 .await
626 .unwrap()
627 }
628
629 fn assert_permits(names: &AddressNames, name: &str) {
630 assert!(
631 names.names().contains(name),
632 "{:?} lacks {name:?}",
633 names.names()
634 );
635 }
636
637 fn assert_refuses(names: &AddressNames, name: &str) {
638 assert!(
639 !names.names().contains(name),
640 "{:?} unexpectedly holds {name:?}",
641 names.names()
642 );
643 }
644
645 #[tokio::test]
648 async fn the_address_dns_name_permits_that_name() {
649 let api =
650 StubNetbox::default().with_address("10.0.0.5", vec![with_dns_name("host.example.com")]);
651
652 assert_permits(&names(&backend(&config(), api)).await, "host.example.com");
653 }
654
655 #[tokio::test]
656 async fn the_custom_field_permits_its_names() {
657 let api = StubNetbox::default().with_address(
658 "10.0.0.5",
659 vec![with_field(json!(["www.example.com", "api.example.com"]))],
660 );
661
662 let names = names(&backend(&config(), api)).await;
663 assert_permits(&names, "www.example.com");
664 assert_permits(&names, "api.example.com");
665 }
666
667 #[tokio::test]
668 async fn a_custom_field_holding_a_single_string_is_accepted() {
669 let api = StubNetbox::default()
670 .with_address("10.0.0.5", vec![with_field(json!("only.example.com"))]);
671
672 assert_permits(&names(&backend(&config(), api)).await, "only.example.com");
673 }
674
675 #[tokio::test]
676 async fn names_are_normalized_on_the_way_in() {
677 let api = StubNetbox::default()
678 .with_address("10.0.0.5", vec![with_dns_name("Host.Example.COM.")]);
679
680 assert_permits(&names(&backend(&config(), api)).await, "host.example.com");
681 }
682
683 #[tokio::test]
684 async fn several_address_objects_are_pooled() {
685 let api = StubNetbox::default().with_address(
686 "10.0.0.5",
687 vec![
688 with_dns_name("a.example.com"),
689 with_dns_name("b.example.com"),
690 ],
691 );
692
693 let names = names(&backend(&config(), api)).await;
694 assert_permits(&names, "a.example.com");
695 assert_permits(&names, "b.example.com");
696 }
697
698 #[tokio::test]
704 async fn an_address_netbox_does_not_know_is_unknown() {
705 let names = names(&backend(&config(), StubNetbox::default())).await;
706 assert_eq!(names, AddressNames::Unknown);
707 }
708
709 #[tokio::test]
710 async fn a_recorded_address_with_no_names_is_known_and_empty() {
711 let api = StubNetbox::default().with_address("10.0.0.5", vec![NetboxIp::default()]);
712
713 let names = names(&backend(&config(), api)).await;
714 assert!(names.is_known());
715 assert!(names.names().is_empty());
716 }
717
718 #[tokio::test]
719 async fn a_failed_lookup_is_an_error_not_an_empty_answer() {
720 let backend = backend(&config(), StubNetbox::failing("HTTP 500"));
721
722 let error = backend
723 .names_for("10.0.0.5".parse().unwrap())
724 .await
725 .unwrap_err();
726 assert!(error.0.contains("HTTP 500"), "{error}");
727 }
728
729 #[tokio::test]
732 async fn dropping_dns_name_ignores_the_address_dns_name() {
733 let api =
734 StubNetbox::default().with_address("10.0.0.5", vec![with_dns_name("host.example.com")]);
735 let cfg = with_sources(&["custom_field", "device"]);
736
737 assert_refuses(&names(&backend(&cfg, api)).await, "host.example.com");
738 }
739
740 #[tokio::test]
741 async fn dropping_custom_field_ignores_it() {
742 let api = StubNetbox::default().with_address(
743 "10.0.0.5",
744 vec![NetboxIp {
745 dns_name: "host.example.com".to_string(),
746 ..with_field(json!(["www.example.com"]))
747 }],
748 );
749 let cfg = with_sources(&["dns_name"]);
750
751 let names = names(&backend(&cfg, api)).await;
752 assert_permits(&names, "host.example.com");
753 assert_refuses(&names, "www.example.com");
754 }
755
756 #[tokio::test]
759 async fn the_device_is_consulted_when_the_address_carries_no_names() {
760 let api = StubNetbox::default()
761 .with_address("10.0.0.5", vec![assigned()])
762 .with_object(3, json!({ "acme_allowed_names": ["machine.example.com"] }));
763
764 assert_permits(
765 &names(&backend(&config(), api)).await,
766 "machine.example.com",
767 );
768 }
769
770 #[tokio::test]
771 async fn the_device_is_not_consulted_when_the_address_answered() {
772 let api = Arc::new(
773 StubNetbox::default()
774 .with_address(
775 "10.0.0.5",
776 vec![NetboxIp {
777 assigned: Some(on_device()),
778 ..with_field(json!(["own.example.com"]))
779 }],
780 )
781 .with_object(3, json!({ "acme_allowed_names": ["machine.example.com"] })),
782 );
783 let backend = NetboxBackend::with_api(&config(), api.clone()).unwrap();
784
785 let names = names(&backend).await;
786 assert_permits(&names, "own.example.com");
787 assert_refuses(&names, "machine.example.com");
789 assert_eq!(api.object_calls.load(Ordering::SeqCst), 0);
790 }
791
792 #[tokio::test]
793 async fn a_dns_name_alone_does_not_suppress_the_fallback() {
794 let api = StubNetbox::default()
795 .with_address(
796 "10.0.0.5",
797 vec![NetboxIp {
798 dns_name: "host.example.com".to_string(),
799 assigned: Some(on_device()),
800 ..NetboxIp::default()
801 }],
802 )
803 .with_object(3, json!({ "acme_allowed_names": ["machine.example.com"] }));
804
805 let names = names(&backend(&config(), api)).await;
806 assert_permits(&names, "host.example.com");
807 assert_permits(&names, "machine.example.com");
808 }
809
810 #[tokio::test]
811 async fn dropping_device_never_consults_it() {
812 let api = Arc::new(
813 StubNetbox::default()
814 .with_address("10.0.0.5", vec![assigned()])
815 .with_object(3, json!({ "acme_allowed_names": ["machine.example.com"] })),
816 );
817 let cfg = with_sources(&["dns_name", "custom_field"]);
818 let backend = NetboxBackend::with_api(&cfg, api.clone()).unwrap();
819
820 assert_refuses(&names(&backend).await, "machine.example.com");
821 assert_eq!(api.object_calls.load(Ordering::SeqCst), 0);
822 }
823
824 #[tokio::test]
828 async fn device_without_custom_field_always_applies() {
829 let api = StubNetbox::default()
830 .with_address(
831 "10.0.0.5",
832 vec![NetboxIp {
833 assigned: Some(on_device()),
834 ..with_field(json!(["own.example.com"]))
835 }],
836 )
837 .with_object(3, json!({ "acme_allowed_names": ["machine.example.com"] }));
838 let cfg = with_sources(&["dns_name", "device"]);
839
840 let names = names(&backend(&cfg, api)).await;
841 assert_permits(&names, "machine.example.com");
842 assert_refuses(&names, "own.example.com");
843 }
844
845 #[tokio::test]
846 async fn an_address_assigned_to_nothing_makes_no_further_query() {
847 let api = Arc::new(
848 StubNetbox::default().with_address("10.0.0.5", vec![with_dns_name("host.example.com")]),
849 );
850 let cfg = with_sources(&["dns_name", "custom_field", "device", "vip", "fhrp"]);
851 let backend = NetboxBackend::with_api(&cfg, api.clone()).unwrap();
852
853 assert_permits(&names(&backend).await, "host.example.com");
854 assert_eq!(api.object_calls.load(Ordering::SeqCst), 0);
855 assert_eq!(api.shared_calls.load(Ordering::SeqCst), 0);
856 assert_eq!(api.membership_calls.load(Ordering::SeqCst), 0);
857 }
858
859 #[tokio::test]
860 async fn a_failing_device_lookup_is_an_error() {
861 struct FallbackFails;
862 #[async_trait]
863 impl NetboxApi for FallbackFails {
864 async fn ip_addresses(&self, _ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
865 Ok(vec![NetboxIp {
866 assigned: Some(AssignedRef {
867 kind: AssignedKind::Device,
868 id: 3,
869 interface_id: 7,
870 }),
871 ..NetboxIp::default()
872 }])
873 }
874 async fn object_custom_fields(
875 &self,
876 _reference: &AssignedRef,
877 ) -> Result<Map<String, Value>, String> {
878 Err("HTTP 403".to_string())
879 }
880 async fn shared_addresses(
881 &self,
882 _reference: &AssignedRef,
883 _roles: &[String],
884 ) -> Result<Vec<NetboxIp>, String> {
885 unreachable!("the device lookup fails first")
886 }
887 async fn fhrp_groups(&self, _reference: &AssignedRef) -> Result<Vec<u64>, String> {
888 unreachable!("the device lookup fails first")
889 }
890 async fn fhrp_group_addresses(
891 &self,
892 _group_ids: &[u64],
893 ) -> Result<Vec<NetboxIp>, String> {
894 unreachable!("the device lookup fails first")
895 }
896 }
897
898 let backend = NetboxBackend::with_api(&config(), Arc::new(FallbackFails)).unwrap();
899 let error = backend
900 .names_for("10.0.0.5".parse().unwrap())
901 .await
902 .unwrap_err();
903 assert!(error.0.contains("HTTP 403"), "{error}");
904 }
905
906 #[tokio::test]
909 async fn a_service_address_on_the_same_device_lends_its_names() {
910 let api = StubNetbox::default()
911 .with_address("10.0.0.5", vec![assigned()])
912 .with_shared(3, vec![service("vrrp", "service.example.com")]);
913 let cfg = with_sources(&["dns_name", "custom_field", "vip"]);
914
915 assert_permits(&names(&backend(&cfg, api)).await, "service.example.com");
916 }
917
918 #[tokio::test]
921 async fn the_vip_source_is_a_union_not_a_fallback() {
922 let api = StubNetbox::default()
923 .with_address(
924 "10.0.0.5",
925 vec![NetboxIp {
926 assigned: Some(on_device()),
927 ..with_field(json!(["own.example.com"]))
928 }],
929 )
930 .with_shared(3, vec![service("vrrp", "service.example.com")]);
931 let cfg = with_sources(&["dns_name", "custom_field", "vip"]);
932
933 let names = names(&backend(&cfg, api)).await;
934 assert_permits(&names, "own.example.com");
935 assert_permits(&names, "service.example.com");
936 }
937
938 #[tokio::test]
941 async fn a_service_address_of_another_role_is_dropped_client_side() {
942 let api = StubNetbox::default()
943 .with_address("10.0.0.5", vec![assigned()])
944 .with_shared(
945 3,
946 vec![
947 service("vrrp", "service.example.com"),
948 service("secondary", "other.example.com"),
949 with_dns_name("plain.example.com"),
952 ],
953 );
954 let cfg = NetboxConfig {
955 vip_roles: strings(&["vrrp"]),
956 ..with_sources(&["dns_name", "custom_field", "vip"])
957 };
958
959 let names = names(&backend(&cfg, api)).await;
960 assert_permits(&names, "service.example.com");
961 assert_refuses(&names, "other.example.com");
962 assert_refuses(&names, "plain.example.com");
963 }
964
965 #[tokio::test]
966 async fn dropping_vip_never_queries_service_addresses() {
967 let api = Arc::new(
968 StubNetbox::default()
969 .with_address("10.0.0.5", vec![assigned()])
970 .with_shared(3, vec![service("vrrp", "service.example.com")]),
971 );
972 let backend = NetboxBackend::with_api(&config(), api.clone()).unwrap();
973
974 assert_refuses(&names(&backend).await, "service.example.com");
975 assert_eq!(api.shared_calls.load(Ordering::SeqCst), 0);
976 }
977
978 #[tokio::test]
979 async fn a_failing_service_address_lookup_is_an_error() {
980 struct SharedFails;
981 #[async_trait]
982 impl NetboxApi for SharedFails {
983 async fn ip_addresses(&self, _ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
984 Ok(vec![NetboxIp {
985 assigned: Some(AssignedRef {
986 kind: AssignedKind::Device,
987 id: 3,
988 interface_id: 7,
989 }),
990 ..with_field(json!(["own.example.com"]))
991 }])
992 }
993 async fn object_custom_fields(
994 &self,
995 _reference: &AssignedRef,
996 ) -> Result<Map<String, Value>, String> {
997 unreachable!("the address answered")
998 }
999 async fn shared_addresses(
1000 &self,
1001 _reference: &AssignedRef,
1002 _roles: &[String],
1003 ) -> Result<Vec<NetboxIp>, String> {
1004 Err("HTTP 502".to_string())
1005 }
1006 async fn fhrp_groups(&self, _reference: &AssignedRef) -> Result<Vec<u64>, String> {
1007 unreachable!("the service address lookup fails first")
1008 }
1009 async fn fhrp_group_addresses(
1010 &self,
1011 _group_ids: &[u64],
1012 ) -> Result<Vec<NetboxIp>, String> {
1013 unreachable!("the service address lookup fails first")
1014 }
1015 }
1016
1017 let cfg = with_sources(&["dns_name", "custom_field", "vip"]);
1018 let backend = NetboxBackend::with_api(&cfg, Arc::new(SharedFails)).unwrap();
1019 let error = backend
1020 .names_for("10.0.0.5".parse().unwrap())
1021 .await
1022 .unwrap_err();
1023 assert!(error.0.contains("HTTP 502"), "{error}");
1024 }
1025
1026 #[tokio::test]
1029 async fn an_interface_in_a_group_may_certify_the_groups_service_name() {
1030 let api = StubNetbox::default()
1031 .with_address("10.0.0.5", vec![assigned()])
1032 .with_membership(7, vec![41])
1033 .with_group_address(41, vec![with_dns_name("service.example.com")]);
1034 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1035
1036 assert_permits(&names(&backend(&cfg, api)).await, "service.example.com");
1037 }
1038
1039 #[tokio::test]
1044 async fn an_interface_in_no_group_contributes_nothing() {
1045 let api = Arc::new(
1046 StubNetbox::default()
1047 .with_address("10.0.0.5", vec![assigned()])
1048 .with_membership(99, vec![41])
1051 .with_group_address(41, vec![with_dns_name("service.example.com")]),
1052 );
1053 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1054 let backend = NetboxBackend::with_api(&cfg, api.clone()).unwrap();
1055
1056 assert_refuses(&names(&backend).await, "service.example.com");
1057 assert_eq!(api.membership_calls.load(Ordering::SeqCst), 1);
1058 assert_eq!(api.group_address_calls.load(Ordering::SeqCst), 0);
1059 }
1060
1061 #[tokio::test]
1062 async fn several_groups_are_resolved_in_one_query() {
1063 let api = Arc::new(
1064 StubNetbox::default()
1065 .with_address("10.0.0.5", vec![assigned()])
1066 .with_membership(7, vec![41, 42])
1067 .with_group_address(41, vec![with_dns_name("one.example.com")])
1068 .with_group_address(42, vec![with_dns_name("two.example.com")]),
1069 );
1070 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1071 let backend = NetboxBackend::with_api(&cfg, api.clone()).unwrap();
1072
1073 let names = names(&backend).await;
1074 assert_permits(&names, "one.example.com");
1075 assert_permits(&names, "two.example.com");
1076 assert_eq!(api.group_address_calls.load(Ordering::SeqCst), 1);
1077 }
1078
1079 #[tokio::test]
1083 async fn a_group_address_custom_field_counts_and_needs_no_role() {
1084 let api = StubNetbox::default()
1085 .with_address("10.0.0.5", vec![assigned()])
1086 .with_membership(7, vec![41])
1087 .with_group_address(41, vec![with_field(json!(["service.example.com"]))]);
1088 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1089
1090 assert_permits(&names(&backend(&cfg, api)).await, "service.example.com");
1091 }
1092
1093 #[tokio::test]
1094 async fn the_fhrp_source_is_a_union_not_a_fallback() {
1095 let api = StubNetbox::default()
1096 .with_address(
1097 "10.0.0.5",
1098 vec![NetboxIp {
1099 assigned: Some(on_device()),
1100 ..with_field(json!(["own.example.com"]))
1101 }],
1102 )
1103 .with_membership(7, vec![41])
1104 .with_group_address(41, vec![with_dns_name("service.example.com")]);
1105 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1106
1107 let names = names(&backend(&cfg, api)).await;
1108 assert_permits(&names, "own.example.com");
1109 assert_permits(&names, "service.example.com");
1110 }
1111
1112 #[tokio::test]
1113 async fn dropping_fhrp_never_queries_membership() {
1114 let api = Arc::new(
1115 StubNetbox::default()
1116 .with_address("10.0.0.5", vec![assigned()])
1117 .with_membership(7, vec![41])
1118 .with_group_address(41, vec![with_dns_name("service.example.com")]),
1119 );
1120 let backend = NetboxBackend::with_api(&config(), api.clone()).unwrap();
1121
1122 assert_refuses(&names(&backend).await, "service.example.com");
1123 assert_eq!(api.membership_calls.load(Ordering::SeqCst), 0);
1124 }
1125
1126 #[tokio::test]
1127 async fn both_service_address_sources_union_without_conflict() {
1128 let api = StubNetbox::default()
1129 .with_address("10.0.0.5", vec![assigned()])
1130 .with_shared(3, vec![service("vrrp", "role.example.com")])
1131 .with_membership(7, vec![41])
1132 .with_group_address(41, vec![with_dns_name("group.example.com")]);
1133 let cfg = with_sources(&["dns_name", "custom_field", "device", "vip", "fhrp"]);
1134
1135 let names = names(&backend(&cfg, api)).await;
1136 assert_permits(&names, "role.example.com");
1137 assert_permits(&names, "group.example.com");
1138 }
1139
1140 #[tokio::test]
1141 async fn a_failing_membership_lookup_is_an_error() {
1142 struct MembershipFails;
1143 #[async_trait]
1144 impl NetboxApi for MembershipFails {
1145 async fn ip_addresses(&self, _ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
1146 Ok(vec![NetboxIp {
1147 assigned: Some(AssignedRef {
1148 kind: AssignedKind::Device,
1149 id: 3,
1150 interface_id: 7,
1151 }),
1152 ..with_field(json!(["own.example.com"]))
1153 }])
1154 }
1155 async fn object_custom_fields(
1156 &self,
1157 _reference: &AssignedRef,
1158 ) -> Result<Map<String, Value>, String> {
1159 unreachable!("the address answered")
1160 }
1161 async fn shared_addresses(
1162 &self,
1163 _reference: &AssignedRef,
1164 _roles: &[String],
1165 ) -> Result<Vec<NetboxIp>, String> {
1166 unreachable!("vip is not among the sources")
1167 }
1168 async fn fhrp_groups(&self, _reference: &AssignedRef) -> Result<Vec<u64>, String> {
1169 Err("HTTP 500".to_string())
1170 }
1171 async fn fhrp_group_addresses(
1172 &self,
1173 _group_ids: &[u64],
1174 ) -> Result<Vec<NetboxIp>, String> {
1175 unreachable!("the membership lookup fails first")
1176 }
1177 }
1178
1179 let cfg = with_sources(&["dns_name", "custom_field", "fhrp"]);
1180 let backend = NetboxBackend::with_api(&cfg, Arc::new(MembershipFails)).unwrap();
1181 let error = backend
1182 .names_for("10.0.0.5".parse().unwrap())
1183 .await
1184 .unwrap_err();
1185 assert!(error.0.contains("HTTP 500"), "{error}");
1186 assert!(error.0.contains("interface 7"), "{error}");
1187 }
1188
1189 #[tokio::test]
1192 async fn a_custom_field_of_the_wrong_type_is_ignored_not_fatal() {
1193 let api = StubNetbox::default().with_address(
1194 "10.0.0.5",
1195 vec![NetboxIp {
1196 dns_name: "host.example.com".to_string(),
1197 custom_fields: json!({ "acme_allowed_names": 42 })
1198 .as_object()
1199 .unwrap()
1200 .clone(),
1201 ..NetboxIp::default()
1202 }],
1203 );
1204
1205 assert_permits(&names(&backend(&config(), api)).await, "host.example.com");
1207 }
1208
1209 #[tokio::test]
1210 async fn non_string_entries_of_the_custom_field_are_skipped() {
1211 let api = StubNetbox::default().with_address(
1212 "10.0.0.5",
1213 vec![with_field(json!(["ok.example.com", 7, null]))],
1214 );
1215
1216 assert_permits(&names(&backend(&config(), api)).await, "ok.example.com");
1217 }
1218
1219 #[test]
1222 fn an_empty_custom_field_is_a_startup_error_when_something_reads_it() {
1223 let cfg = NetboxConfig {
1224 custom_field: " ".to_string(),
1225 ..config()
1226 };
1227 let error = NetboxBackend::with_api(&cfg, Arc::new(StubNetbox::default()))
1228 .unwrap_err()
1229 .to_string();
1230 assert!(error.contains("ipam.netbox.custom_field"), "{error}");
1231 }
1232
1233 #[test]
1236 fn an_empty_custom_field_is_fine_when_nothing_reads_it() {
1237 let cfg = NetboxConfig {
1238 custom_field: String::new(),
1239 ..with_sources(&["dns_name"])
1240 };
1241 NetboxBackend::with_api(&cfg, Arc::new(StubNetbox::default()))
1242 .expect("no source reads the custom field");
1243 }
1244
1245 #[test]
1246 fn vip_without_roles_is_a_startup_error() {
1247 let cfg = NetboxConfig {
1248 vip_roles: Vec::new(),
1249 ..with_sources(&["dns_name", "vip"])
1250 };
1251 let error = NetboxBackend::with_api(&cfg, Arc::new(StubNetbox::default()))
1252 .unwrap_err()
1253 .to_string();
1254 assert!(error.contains("ipam.netbox.vip_roles"), "{error}");
1255 }
1256
1257 #[test]
1258 fn an_unknown_source_is_a_startup_error() {
1259 let cfg = with_sources(&["dns_name", "hostname"]);
1260 let error = NetboxBackend::with_api(&cfg, Arc::new(StubNetbox::default()))
1261 .unwrap_err()
1262 .to_string();
1263 assert!(error.contains("unknown source `hostname`"), "{error}");
1264 }
1265
1266 #[test]
1267 fn reports_the_product_name() {
1268 assert_eq!(backend(&config(), StubNetbox::default()).name(), "NetBox");
1269 }
1270
1271 #[test]
1272 fn the_debug_impl_shows_the_policy_without_the_api() {
1273 let rendered = format!("{:?}", backend(&config(), StubNetbox::default()));
1274 assert!(rendered.contains("acme_allowed_names"), "{rendered}");
1275 assert!(rendered.contains("Device"), "{rendered}");
1276 }
1277
1278 #[test]
1279 fn the_default_sources_are_what_the_filter_always_did() {
1280 let backend = backend(&config(), StubNetbox::default());
1281 assert!(backend.sources.contains(&Source::DnsName));
1282 assert!(backend.sources.contains(&Source::CustomField));
1283 assert!(backend.sources.contains(&Source::Device));
1284 assert!(!backend.sources.contains(&Source::Vip));
1285 assert!(!backend.sources.contains(&Source::Fhrp));
1286 }
1287}