1use std::net::IpAddr;
19
20use async_trait::async_trait;
21use serde::Deserialize;
22use serde_json::{Map, Value};
23use tracing::debug;
24use url::form_urlencoded::Serializer;
25
26use super::{AssignedKind, AssignedRef, NetboxApi, NetboxIp};
27use crate::config::NetboxConfig;
28use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
29
30pub(super) const DEVICE_INTERFACE: &str = "dcim.interface";
32pub(super) const VM_INTERFACE: &str = "virtualization.vminterface";
33
34#[derive(Debug)]
36pub struct NetboxClient {
37 api: JsonApi,
38}
39
40impl NetboxClient {
41 pub fn new(cfg: &NetboxConfig, outbound: crate::http_client::Outbound) -> anyhow::Result<Self> {
43 anyhow::ensure!(
44 !cfg.url.trim().is_empty(),
45 "ipam.backend is `netbox` but ipam.netbox.url is empty; give the base URL of the \
46 NetBox instance"
47 );
48 anyhow::ensure!(
49 !cfg.token.trim().is_empty(),
50 "ipam.backend is `netbox` but ipam.netbox.token is empty; supply a NetBox API \
51 token, preferably through ACME_PROXY_IPAM__NETBOX__TOKEN"
52 );
53
54 Ok(Self {
55 api: JsonApi::new(
56 &cfg.url,
57 "ipam.netbox.url",
58 vec![(hyper::header::AUTHORIZATION, format!("Token {}", cfg.token))],
59 tls_config(
60 &cfg.ca_cert_path,
61 cfg.insecure_skip_verify,
62 "ipam.netbox.ca_cert_path",
63 )?,
64 outbound,
65 )?,
66 })
67 }
68
69 async fn get(&self, path_and_query: &str) -> Result<Value, String> {
72 self.api
73 .get(path_and_query)
74 .await
75 .map_err(|error: JsonApiError| error.message)
76 }
77
78 async fn addresses(&self, query: &str, what: &str) -> Result<Vec<NetboxIp>, String> {
80 let body = self
81 .get(&format!("/api/ipam/ip-addresses/?{query}"))
82 .await?;
83 let parsed: IpListResponse = serde_json::from_value(body)
84 .map_err(|error| format!("unexpected {what} response: {error}"))?;
85 Ok(parsed.results.into_iter().map(NetboxIp::from).collect())
86 }
87}
88
89#[derive(Debug, Deserialize)]
93struct IpListResponse {
94 #[serde(default)]
95 results: Vec<IpResult>,
96}
97
98#[derive(Debug, Deserialize)]
99struct IpResult {
100 #[serde(default)]
101 dns_name: String,
102 #[serde(default)]
103 custom_fields: Map<String, Value>,
104 #[serde(default)]
105 assigned_object_type: Option<String>,
106 #[serde(default)]
107 assigned_object: Option<Value>,
108 #[serde(default)]
110 role: Option<Choice>,
111}
112
113#[derive(Debug, Deserialize)]
116struct Choice {
117 #[serde(default)]
118 value: String,
119}
120
121#[derive(Debug, Deserialize)]
123struct ObjectResponse {
124 #[serde(default)]
125 custom_fields: Map<String, Value>,
126}
127
128#[derive(Debug, Deserialize)]
133struct AssignmentListResponse {
134 #[serde(default)]
135 results: Vec<AssignmentResult>,
136}
137
138#[derive(Debug, Deserialize)]
139struct AssignmentResult {
140 #[serde(default)]
141 group: Option<Nested>,
142}
143
144#[derive(Debug, Deserialize)]
146struct Nested {
147 id: u64,
148}
149
150impl From<IpResult> for NetboxIp {
151 fn from(result: IpResult) -> Self {
152 Self {
153 dns_name: result.dns_name,
154 custom_fields: result.custom_fields,
155 assigned: assigned_ref(
156 result.assigned_object_type.as_deref(),
157 result.assigned_object.as_ref(),
158 ),
159 role: result.role.map(|choice| choice.value),
160 }
161 }
162}
163
164fn assigned_ref(typ: Option<&str>, object: Option<&Value>) -> Option<AssignedRef> {
177 let (kind, field) = match typ? {
178 DEVICE_INTERFACE => (AssignedKind::Device, "device"),
179 VM_INTERFACE => (AssignedKind::VirtualMachine, "virtual_machine"),
180 other => {
181 debug!(
182 event = "ipam_netbox_assignment_ignored",
183 outcome = "advisory",
184 assigned_object_type = other,
185 "address is assigned to an object with no machine behind it"
186 );
187 return None;
188 }
189 };
190
191 let object = object?;
192 let interface_id = object.get("id")?.as_u64()?;
193 let id = object.get(field)?.get("id")?.as_u64()?;
194 Some(AssignedRef {
195 kind,
196 id,
197 interface_id,
198 })
199}
200
201#[async_trait]
202impl NetboxApi for NetboxClient {
203 async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
204 let query = Serializer::new(String::new())
205 .append_pair("address", &ip.to_string())
206 .finish();
207 self.addresses(&query, "ip-addresses").await
208 }
209
210 async fn object_custom_fields(
211 &self,
212 reference: &AssignedRef,
213 ) -> Result<Map<String, Value>, String> {
214 let path = match reference.kind {
215 AssignedKind::Device => format!("/api/dcim/devices/{}/", reference.id),
216 AssignedKind::VirtualMachine => {
217 format!("/api/virtualization/virtual-machines/{}/", reference.id)
218 }
219 };
220
221 let body = self.get(&path).await?;
222 let parsed: ObjectResponse = serde_json::from_value(body)
223 .map_err(|error| format!("unexpected response for {path}: {error}"))?;
224
225 Ok(parsed.custom_fields)
226 }
227
228 async fn shared_addresses(
229 &self,
230 reference: &AssignedRef,
231 roles: &[String],
232 ) -> Result<Vec<NetboxIp>, String> {
233 let query = {
238 let mut query = Serializer::new(String::new());
239 query.append_pair(reference.kind.owner_filter(), &reference.id.to_string());
240 for role in roles {
241 query.append_pair("role", role);
242 }
243 query.finish()
244 };
245 self.addresses(&query, "service-address").await
246 }
247
248 async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String> {
249 let query = Serializer::new(String::new())
250 .append_pair("interface_type", reference.kind.interface_type())
251 .append_pair("interface_id", &reference.interface_id.to_string())
252 .finish();
253
254 let body = self
255 .get(&format!("/api/ipam/fhrp-group-assignments/?{query}"))
256 .await?;
257 let parsed: AssignmentListResponse = serde_json::from_value(body)
258 .map_err(|error| format!("unexpected fhrp-group-assignments response: {error}"))?;
259
260 Ok(parsed
261 .results
262 .into_iter()
263 .filter_map(|assignment| assignment.group.map(|group| group.id))
264 .collect())
265 }
266
267 async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String> {
268 let query = {
272 let mut query = Serializer::new(String::new());
273 for id in group_ids {
274 query.append_pair("fhrpgroup_id", &id.to_string());
275 }
276 query.finish()
277 };
278 self.addresses(&query, "fhrp-group-address").await
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::ipam::http::testing::{
286 closed_port, ok, serve_many, serve_once, serve_once_tls, status, test_resolver,
287 };
288 use serde_json::json;
289
290 fn config(url: &str) -> NetboxConfig {
291 NetboxConfig {
292 url: url.to_string(),
293 token: "t0ken".to_string(),
294 ..NetboxConfig::default()
295 }
296 }
297
298 #[test]
301 fn an_empty_url_is_a_startup_error() {
302 let error = NetboxClient::new(
303 &config(" "),
304 crate::testutil::outbound_with(test_resolver()),
305 )
306 .unwrap_err()
307 .to_string();
308 assert!(error.contains("ipam.netbox.url"), "{error}");
309 }
310
311 #[test]
312 fn an_empty_token_is_a_startup_error() {
313 let cfg = NetboxConfig {
314 token: String::new(),
315 ..config("https://netbox.example.com")
316 };
317 let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
318 .unwrap_err()
319 .to_string();
320 assert!(error.contains("ipam.netbox.token"), "{error}");
321 assert!(error.contains("ACME_PROXY_IPAM__NETBOX__TOKEN"), "{error}");
322 }
323
324 #[test]
325 fn an_unparsable_url_is_a_startup_error() {
326 let error = NetboxClient::new(
327 &config("not a url"),
328 crate::testutil::outbound_with(test_resolver()),
329 )
330 .unwrap_err()
331 .to_string();
332 assert!(error.contains("ipam.netbox.url"), "{error}");
333 }
334
335 #[test]
336 fn a_missing_ca_certificate_is_a_startup_error() {
337 let cfg = NetboxConfig {
338 ca_cert_path: "/nonexistent/netbox-ca.pem".to_string(),
339 ..config("https://netbox.example.com")
340 };
341 let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
342 .unwrap_err()
343 .to_string();
344 assert!(error.contains("ipam.netbox.ca_cert_path"), "{error}");
345 }
346
347 #[test]
348 fn the_debug_impl_never_renders_the_token() {
349 let client = NetboxClient::new(
350 &config("https://netbox.example.com"),
351 crate::testutil::outbound_with(test_resolver()),
352 )
353 .unwrap();
354 let rendered = format!("{client:?}");
355 assert!(!rendered.contains("t0ken"), "{rendered}");
356 }
357
358 #[test]
361 fn a_device_interface_yields_its_device_and_its_interface() {
362 let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3, "name": "srv1" } });
363 assert_eq!(
364 assigned_ref(Some(DEVICE_INTERFACE), Some(&object)),
365 Some(AssignedRef {
366 kind: AssignedKind::Device,
367 id: 3,
368 interface_id: 7,
369 })
370 );
371 }
372
373 #[test]
374 fn a_vm_interface_yields_its_virtual_machine() {
375 let object = json!({ "id": 9, "virtual_machine": { "id": 11, "name": "vm1" } });
376 assert_eq!(
377 assigned_ref(Some(VM_INTERFACE), Some(&object)),
378 Some(AssignedRef {
379 kind: AssignedKind::VirtualMachine,
380 id: 11,
381 interface_id: 9,
382 })
383 );
384 }
385
386 #[test]
387 fn an_unassigned_or_unreadable_assignment_yields_nothing() {
388 let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3 } });
389 assert_eq!(assigned_ref(None, Some(&object)), None);
393 assert_eq!(assigned_ref(Some(DEVICE_INTERFACE), None), None);
394 assert_eq!(assigned_ref(Some("ipam.fhrpgroup"), Some(&object)), None);
395 assert_eq!(
396 assigned_ref(
397 Some(DEVICE_INTERFACE),
398 Some(&json!({ "id": 7, "device": {} }))
399 ),
400 None
401 );
402 assert_eq!(
403 assigned_ref(
404 Some(DEVICE_INTERFACE),
405 Some(&json!({ "device": { "id": 3 } }))
406 ),
407 None
408 );
409 }
410
411 mod loopback {
415 use super::*;
416
417 fn client(port: u16) -> NetboxClient {
418 NetboxClient::new(
419 &config(&format!("http://127.0.0.1:{port}")),
420 crate::testutil::outbound_with(test_resolver()),
421 )
422 .unwrap()
423 }
424
425 fn on_device() -> AssignedRef {
426 AssignedRef {
427 kind: AssignedKind::Device,
428 id: 3,
429 interface_id: 7,
430 }
431 }
432
433 fn one_address() -> Value {
435 json!({
436 "count": 1,
437 "results": [{
438 "id": 12,
439 "address": "10.0.0.5/24",
440 "dns_name": "host.example.com",
441 "custom_fields": { "acme_allowed_names": ["www.example.com"] },
442 "assigned_object_type": "dcim.interface",
443 "assigned_object_id": 7,
444 "assigned_object": { "id": 7, "name": "eth0", "device": { "id": 3 } }
445 }]
446 })
447 }
448
449 #[tokio::test]
450 async fn queries_the_address_and_authenticates() {
451 let (port, server) = serve_once(ok(one_address())).await;
452
453 let objects = client(port)
454 .ip_addresses("10.0.0.5".parse().unwrap())
455 .await
456 .unwrap();
457
458 assert_eq!(objects.len(), 1);
459 assert_eq!(objects[0].dns_name, "host.example.com");
460 assert_eq!(
461 objects[0].custom_fields["acme_allowed_names"],
462 json!(["www.example.com"])
463 );
464 assert_eq!(objects[0].assigned, Some(on_device()));
465
466 let request = server.await.unwrap();
467 assert!(
468 request.starts_with("GET /api/ipam/ip-addresses/?address=10.0.0.5 HTTP/1.1"),
469 "{request}"
470 );
471 assert!(request.contains("authorization: Token t0ken"), "{request}");
472 }
473
474 #[tokio::test]
476 async fn an_ipv6_address_is_percent_encoded() {
477 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
478
479 let objects = client(port)
480 .ip_addresses("2001:db8::5".parse().unwrap())
481 .await
482 .unwrap();
483 assert!(objects.is_empty());
484
485 let request = server.await.unwrap();
486 assert!(request.contains("address=2001%3Adb8%3A%3A5"), "{request}");
487 }
488
489 #[tokio::test]
490 async fn a_role_is_read_off_the_choice_object() {
491 let (port, _server) = serve_once(ok(json!({
492 "results": [{ "dns_name": "service.example.com",
493 "role": { "value": "vrrp", "label": "VRRP" } }]
494 })))
495 .await;
496
497 let objects = client(port)
498 .shared_addresses(&on_device(), &[])
499 .await
500 .unwrap();
501 assert_eq!(objects[0].role.as_deref(), Some("vrrp"));
502 }
503
504 #[tokio::test]
505 async fn fetches_a_devices_custom_fields() {
506 let (port, server) = serve_once(ok(json!({
507 "id": 3,
508 "custom_fields": { "acme_allowed_names": ["machine.example.com"] }
509 })))
510 .await;
511
512 let fields = client(port)
513 .object_custom_fields(&on_device())
514 .await
515 .unwrap();
516
517 assert_eq!(fields["acme_allowed_names"], json!(["machine.example.com"]));
518
519 let request = server.await.unwrap();
520 assert!(
521 request.starts_with("GET /api/dcim/devices/3/ HTTP/1.1"),
522 "{request}"
523 );
524 }
525
526 #[tokio::test]
527 async fn fetches_a_virtual_machines_custom_fields() {
528 let (port, server) = serve_once(ok(json!({ "custom_fields": {} }))).await;
529
530 client(port)
531 .object_custom_fields(&AssignedRef {
532 kind: AssignedKind::VirtualMachine,
533 id: 11,
534 interface_id: 9,
535 })
536 .await
537 .unwrap();
538
539 let request = server.await.unwrap();
540 assert!(
541 request.starts_with("GET /api/virtualization/virtual-machines/11/ HTTP/1.1"),
542 "{request}"
543 );
544 }
545
546 #[tokio::test]
549 async fn service_addresses_are_scoped_to_the_device_and_the_roles() {
550 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
551
552 client(port)
553 .shared_addresses(&on_device(), &["vip".to_string(), "vrrp".to_string()])
554 .await
555 .unwrap();
556
557 let request = server.await.unwrap();
558 assert!(
559 request.starts_with(
560 "GET /api/ipam/ip-addresses/?device_id=3&role=vip&role=vrrp HTTP/1.1"
561 ),
562 "{request}"
563 );
564 }
565
566 #[tokio::test]
567 async fn service_addresses_of_a_virtual_machine_use_the_vm_filter() {
568 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
569
570 client(port)
571 .shared_addresses(
572 &AssignedRef {
573 kind: AssignedKind::VirtualMachine,
574 id: 11,
575 interface_id: 9,
576 },
577 &["vrrp".to_string()],
578 )
579 .await
580 .unwrap();
581
582 let request = server.await.unwrap();
583 assert!(
584 request.contains("virtual_machine_id=11&role=vrrp"),
585 "{request}"
586 );
587 }
588
589 #[tokio::test]
593 async fn membership_is_queried_by_the_clients_own_interface() {
594 let (port, server) = serve_once(ok(json!({
595 "results": [
596 { "id": 1, "group": { "id": 41, "name": "vrrp-41" } },
597 { "id": 2, "group": { "id": 42 } }
598 ]
599 })))
600 .await;
601
602 let groups = client(port).fhrp_groups(&on_device()).await.unwrap();
603 assert_eq!(groups, vec![41, 42]);
604
605 let request = server.await.unwrap();
606 assert!(
607 request.starts_with(
608 "GET /api/ipam/fhrp-group-assignments/\
609 ?interface_type=dcim.interface&interface_id=7 HTTP/1.1"
610 ),
611 "{request}"
612 );
613 }
614
615 #[tokio::test]
616 async fn membership_of_a_vm_interface_uses_the_vm_interface_type() {
617 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
618
619 let groups = client(port)
620 .fhrp_groups(&AssignedRef {
621 kind: AssignedKind::VirtualMachine,
622 id: 11,
623 interface_id: 9,
624 })
625 .await
626 .unwrap();
627 assert!(groups.is_empty());
628
629 let request = server.await.unwrap();
630 assert!(
631 request.contains("interface_type=virtualization.vminterface&interface_id=9"),
632 "{request}"
633 );
634 }
635
636 #[tokio::test]
639 async fn every_group_resolves_in_one_query() {
640 let (port, server) = serve_once(ok(json!({
641 "results": [{ "dns_name": "service.example.com" }]
642 })))
643 .await;
644
645 let objects = client(port).fhrp_group_addresses(&[41, 42]).await.unwrap();
646 assert_eq!(objects[0].dns_name, "service.example.com");
647
648 let request = server.await.unwrap();
649 assert!(
650 request.starts_with(
651 "GET /api/ipam/ip-addresses/?fhrpgroup_id=41&fhrpgroup_id=42 HTTP/1.1"
652 ),
653 "{request}"
654 );
655 }
656
657 #[tokio::test]
660 async fn the_two_fhrp_queries_run_in_order() {
661 let (port, server) = serve_many(vec![
662 ok(json!({ "results": [{ "group": { "id": 41 } }] })),
663 ok(json!({ "results": [{ "dns_name": "service.example.com" }] })),
664 ])
665 .await;
666
667 let client = client(port);
668 let groups = client.fhrp_groups(&on_device()).await.unwrap();
669 let objects = client.fhrp_group_addresses(&groups).await.unwrap();
670 assert_eq!(objects[0].dns_name, "service.example.com");
671
672 let requests = server.await.unwrap();
673 assert!(requests.contains("fhrp-group-assignments"), "{requests}");
674 assert!(requests.contains("fhrpgroup_id=41"), "{requests}");
675 }
676
677 #[tokio::test]
679 async fn a_subpath_base_url_prefixes_the_api_path() {
680 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
681 let cfg = config(&format!("http://127.0.0.1:{port}/netbox"));
682
683 NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
684 .unwrap()
685 .ip_addresses("10.0.0.5".parse().unwrap())
686 .await
687 .unwrap();
688
689 let request = server.await.unwrap();
690 assert!(
691 request.starts_with("GET /netbox/api/ipam/ip-addresses/?address="),
692 "{request}"
693 );
694 }
695
696 #[tokio::test]
700 async fn a_refused_token_is_reported_rather_than_parsed() {
701 let (port, _server) = serve_once(status(
702 401,
703 "Unauthorized",
704 r#"{"detail":"Invalid token header."}"#,
705 ))
706 .await;
707
708 let error = client(port)
709 .ip_addresses("10.0.0.5".parse().unwrap())
710 .await
711 .unwrap_err();
712 assert!(error.contains("401"), "{error}");
713 assert!(error.contains("Invalid token header"), "{error}");
714 }
715
716 #[tokio::test]
719 async fn a_404_is_a_failure_for_netbox() {
720 let (port, _server) = serve_once(status(404, "Not Found", "{}")).await;
721
722 let error = client(port)
723 .ip_addresses("10.0.0.5".parse().unwrap())
724 .await
725 .unwrap_err();
726 assert!(error.contains("404"), "{error}");
727 }
728
729 #[tokio::test]
732 async fn a_response_of_the_wrong_shape_is_an_error() {
733 let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
734
735 let error = client(port)
736 .ip_addresses("10.0.0.5".parse().unwrap())
737 .await
738 .unwrap_err();
739 assert!(
740 error.contains("unexpected ip-addresses response"),
741 "{error}"
742 );
743 }
744
745 #[tokio::test]
746 async fn a_malformed_assignment_list_is_an_error() {
747 let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
748
749 let error = client(port).fhrp_groups(&on_device()).await.unwrap_err();
750 assert!(
751 error.contains("unexpected fhrp-group-assignments response"),
752 "{error}"
753 );
754 }
755
756 #[tokio::test]
757 async fn a_malformed_device_response_is_an_error() {
758 let (port, _server) = serve_once(ok(json!({ "custom_fields": 7 }))).await;
759
760 let error = client(port)
761 .object_custom_fields(&on_device())
762 .await
763 .unwrap_err();
764 assert!(error.contains("unexpected response for"), "{error}");
765 }
766
767 #[tokio::test]
768 async fn a_closed_port_is_a_connect_error() {
769 let port = closed_port().await;
770
771 let error = client(port)
772 .ip_addresses("10.0.0.5".parse().unwrap())
773 .await
774 .unwrap_err();
775 assert!(error.contains("connecting to 127.0.0.1"), "{error}");
776 }
777 }
778
779 mod tls {
783 use super::*;
784
785 fn https_config(port: u16, skip: bool) -> NetboxConfig {
786 NetboxConfig {
787 insecure_skip_verify: skip,
788 ..config(&format!("https://localhost:{port}"))
789 }
790 }
791
792 #[tokio::test]
793 async fn a_self_signed_netbox_is_refused_by_default() {
794 let port = serve_once_tls(json!({ "results": [] })).await;
795
796 let error = NetboxClient::new(
797 &https_config(port, false),
798 crate::testutil::outbound_with(test_resolver()),
799 )
800 .unwrap()
801 .ip_addresses("10.0.0.5".parse().unwrap())
802 .await
803 .unwrap_err();
804 assert!(error.contains("TLS handshake"), "{error}");
805 }
806
807 #[tokio::test]
808 async fn skipping_verification_reaches_the_same_netbox() {
809 let port = serve_once_tls(json!({
810 "results": [{ "dns_name": "host.example.com", "custom_fields": {} }]
811 }))
812 .await;
813
814 let objects = NetboxClient::new(
815 &https_config(port, true),
816 crate::testutil::outbound_with(test_resolver()),
817 )
818 .unwrap()
819 .ip_addresses("10.0.0.5".parse().unwrap())
820 .await
821 .expect("skip-verify must accept a self-signed certificate");
822 assert_eq!(objects[0].dns_name, "host.example.com");
823 }
824 }
825}