1use std::net::IpAddr;
20
21use async_trait::async_trait;
22use serde::Deserialize;
23use serde_json::{Map, Value};
24use tracing::debug;
25use url::form_urlencoded::Serializer;
26
27use super::{AssignedKind, AssignedRef, NetboxApi, NetboxIp};
28use crate::config::NetboxConfig;
29use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
30
31pub(super) const DEVICE_INTERFACE: &str = "dcim.interface";
33pub(super) const VM_INTERFACE: &str = "virtualization.vminterface";
34
35const V2_TOKEN_PREFIX: &str = "nbt_";
44
45#[derive(Debug)]
47pub struct NetboxClient {
48 api: JsonApi,
49 scheme: &'static str,
54}
55
56impl NetboxClient {
57 pub fn new(cfg: &NetboxConfig, outbound: crate::http_client::Outbound) -> anyhow::Result<Self> {
59 anyhow::ensure!(
60 !cfg.url.trim().is_empty(),
61 "ipam.backend is `netbox` but ipam.netbox.url is empty; give the base URL of the \
62 NetBox instance"
63 );
64 anyhow::ensure!(
65 !cfg.token.trim().is_empty(),
66 "ipam.backend is `netbox` but ipam.netbox.token is empty; supply a NetBox API \
67 token, preferably through ACME_PROXY_IPAM__NETBOX__TOKEN"
68 );
69
70 let credential = cfg.token.trim();
74 let v2 = credential.starts_with(V2_TOKEN_PREFIX);
75 anyhow::ensure!(
76 !v2 || credential[V2_TOKEN_PREFIX.len()..].contains('.'),
77 "ipam.netbox.token starts `{V2_TOKEN_PREFIX}` but carries no secret; a NetBox v2 \
78 token is the single string `{V2_TOKEN_PREFIX}<key>.<secret>` shown once when the \
79 token is created, not the key half on its own"
80 );
81 let scheme = if v2 { "Bearer" } else { "Token" };
82
83 Ok(Self {
84 api: JsonApi::new(
85 &cfg.url,
86 "ipam.netbox.url",
87 vec![(
88 hyper::header::AUTHORIZATION,
89 format!("{scheme} {credential}"),
90 )],
91 tls_config(
92 &cfg.ca_cert_path,
93 cfg.insecure_skip_verify,
94 "ipam.netbox.ca_cert_path",
95 )?,
96 outbound,
97 )?,
98 scheme,
99 })
100 }
101
102 async fn get(&self, path_and_query: &str) -> Result<Value, String> {
105 self.api
106 .get(path_and_query)
107 .await
108 .map_err(|error: JsonApiError| match error.status {
109 Some(hyper::StatusCode::UNAUTHORIZED | hyper::StatusCode::FORBIDDEN) => format!(
110 "{}; ipam.netbox.token was sent as `Authorization: {} …`. A NetBox v2 token \
111 (the default since NetBox 4.5) starts `{V2_TOKEN_PREFIX}` and goes under \
112 `Bearer`; a legacy v1 token goes under `Token`.",
113 error.message, self.scheme
114 ),
115 _ => error.message,
116 })
117 }
118
119 async fn addresses(&self, query: &str, what: &str) -> Result<Vec<NetboxIp>, String> {
121 let body = self
122 .get(&format!("/api/ipam/ip-addresses/?{query}"))
123 .await?;
124 let parsed: IpListResponse = serde_json::from_value(body)
125 .map_err(|error| format!("unexpected {what} response: {error}"))?;
126 Ok(parsed.results.into_iter().map(NetboxIp::from).collect())
127 }
128}
129
130#[derive(Debug, Deserialize)]
134struct IpListResponse {
135 #[serde(default)]
136 results: Vec<IpResult>,
137}
138
139#[derive(Debug, Deserialize)]
140struct IpResult {
141 #[serde(default)]
142 dns_name: String,
143 #[serde(default)]
144 custom_fields: Map<String, Value>,
145 #[serde(default)]
146 assigned_object_type: Option<String>,
147 #[serde(default)]
148 assigned_object: Option<Value>,
149 #[serde(default)]
151 role: Option<Choice>,
152}
153
154#[derive(Debug, Deserialize)]
157struct Choice {
158 #[serde(default)]
159 value: String,
160}
161
162#[derive(Debug, Deserialize)]
164struct ObjectResponse {
165 #[serde(default)]
166 custom_fields: Map<String, Value>,
167}
168
169#[derive(Debug, Deserialize)]
174struct AssignmentListResponse {
175 #[serde(default)]
176 results: Vec<AssignmentResult>,
177}
178
179#[derive(Debug, Deserialize)]
180struct AssignmentResult {
181 #[serde(default)]
182 group: Option<Nested>,
183}
184
185#[derive(Debug, Deserialize)]
187struct Nested {
188 id: u64,
189}
190
191impl From<IpResult> for NetboxIp {
192 fn from(result: IpResult) -> Self {
193 Self {
194 dns_name: result.dns_name,
195 custom_fields: result.custom_fields,
196 assigned: assigned_ref(
197 result.assigned_object_type.as_deref(),
198 result.assigned_object.as_ref(),
199 ),
200 role: result.role.map(|choice| choice.value),
201 }
202 }
203}
204
205fn assigned_ref(typ: Option<&str>, object: Option<&Value>) -> Option<AssignedRef> {
218 let (kind, field) = match typ? {
219 DEVICE_INTERFACE => (AssignedKind::Device, "device"),
220 VM_INTERFACE => (AssignedKind::VirtualMachine, "virtual_machine"),
221 other => {
222 debug!(
223 event = "ipam_netbox_assignment_ignored",
224 outcome = "advisory",
225 assigned_object_type = other,
226 "address is assigned to an object with no machine behind it"
227 );
228 return None;
229 }
230 };
231
232 let object = object?;
233 let interface_id = object.get("id")?.as_u64()?;
234 let id = object.get(field)?.get("id")?.as_u64()?;
235 Some(AssignedRef {
236 kind,
237 id,
238 interface_id,
239 })
240}
241
242#[async_trait]
243impl NetboxApi for NetboxClient {
244 async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String> {
245 let query = Serializer::new(String::new())
246 .append_pair("address", &ip.to_string())
247 .finish();
248 self.addresses(&query, "ip-addresses").await
249 }
250
251 async fn object_custom_fields(
252 &self,
253 reference: &AssignedRef,
254 ) -> Result<Map<String, Value>, String> {
255 let path = match reference.kind {
256 AssignedKind::Device => format!("/api/dcim/devices/{}/", reference.id),
257 AssignedKind::VirtualMachine => {
258 format!("/api/virtualization/virtual-machines/{}/", reference.id)
259 }
260 };
261
262 let body = self.get(&path).await?;
263 let parsed: ObjectResponse = serde_json::from_value(body)
264 .map_err(|error| format!("unexpected response for {path}: {error}"))?;
265
266 Ok(parsed.custom_fields)
267 }
268
269 async fn shared_addresses(
270 &self,
271 reference: &AssignedRef,
272 roles: &[String],
273 ) -> Result<Vec<NetboxIp>, String> {
274 let query = {
279 let mut query = Serializer::new(String::new());
280 query.append_pair(reference.kind.owner_filter(), &reference.id.to_string());
281 for role in roles {
282 query.append_pair("role", role);
283 }
284 query.finish()
285 };
286 self.addresses(&query, "service-address").await
287 }
288
289 async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String> {
290 let query = Serializer::new(String::new())
291 .append_pair("interface_type", reference.kind.interface_type())
292 .append_pair("interface_id", &reference.interface_id.to_string())
293 .finish();
294
295 let body = self
296 .get(&format!("/api/ipam/fhrp-group-assignments/?{query}"))
297 .await?;
298 let parsed: AssignmentListResponse = serde_json::from_value(body)
299 .map_err(|error| format!("unexpected fhrp-group-assignments response: {error}"))?;
300
301 Ok(parsed
302 .results
303 .into_iter()
304 .filter_map(|assignment| assignment.group.map(|group| group.id))
305 .collect())
306 }
307
308 async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String> {
309 let query = {
313 let mut query = Serializer::new(String::new());
314 for id in group_ids {
315 query.append_pair("fhrpgroup_id", &id.to_string());
316 }
317 query.finish()
318 };
319 self.addresses(&query, "fhrp-group-address").await
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::ipam::http::testing::{
327 closed_port, ok, serve_many, serve_once, serve_once_tls, status, test_resolver,
328 };
329 use serde_json::json;
330
331 fn config(url: &str) -> NetboxConfig {
332 NetboxConfig {
333 url: url.to_string(),
334 token: "t0ken".to_string(),
335 ..NetboxConfig::default()
336 }
337 }
338
339 #[test]
342 fn an_empty_url_is_a_startup_error() {
343 let error = NetboxClient::new(
344 &config(" "),
345 crate::testutil::outbound_with(test_resolver()),
346 )
347 .unwrap_err()
348 .to_string();
349 assert!(error.contains("ipam.netbox.url"), "{error}");
350 }
351
352 #[test]
353 fn an_empty_token_is_a_startup_error() {
354 let cfg = NetboxConfig {
355 token: String::new(),
356 ..config("https://netbox.example.com")
357 };
358 let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
359 .unwrap_err()
360 .to_string();
361 assert!(error.contains("ipam.netbox.token"), "{error}");
362 assert!(error.contains("ACME_PROXY_IPAM__NETBOX__TOKEN"), "{error}");
363 }
364
365 #[test]
369 fn a_v2_token_carrying_no_secret_is_a_startup_error() {
370 let cfg = NetboxConfig {
371 token: "nbt_4F9DAouzURLb".to_string(),
372 ..config("https://netbox.example.com")
373 };
374 let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
375 .unwrap_err()
376 .to_string();
377 assert!(error.contains("ipam.netbox.token"), "{error}");
378 assert!(error.contains("nbt_<key>.<secret>"), "{error}");
379 }
380
381 #[test]
382 fn an_unparsable_url_is_a_startup_error() {
383 let error = NetboxClient::new(
384 &config("not a url"),
385 crate::testutil::outbound_with(test_resolver()),
386 )
387 .unwrap_err()
388 .to_string();
389 assert!(error.contains("ipam.netbox.url"), "{error}");
390 }
391
392 #[test]
393 fn a_missing_ca_certificate_is_a_startup_error() {
394 let cfg = NetboxConfig {
395 ca_cert_path: "/nonexistent/netbox-ca.pem".to_string(),
396 ..config("https://netbox.example.com")
397 };
398 let error = NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
399 .unwrap_err()
400 .to_string();
401 assert!(error.contains("ipam.netbox.ca_cert_path"), "{error}");
402 }
403
404 #[test]
405 fn the_debug_impl_never_renders_the_token() {
406 let client = NetboxClient::new(
407 &config("https://netbox.example.com"),
408 crate::testutil::outbound_with(test_resolver()),
409 )
410 .unwrap();
411 let rendered = format!("{client:?}");
412 assert!(!rendered.contains("t0ken"), "{rendered}");
413 }
414
415 #[test]
418 fn a_device_interface_yields_its_device_and_its_interface() {
419 let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3, "name": "srv1" } });
420 assert_eq!(
421 assigned_ref(Some(DEVICE_INTERFACE), Some(&object)),
422 Some(AssignedRef {
423 kind: AssignedKind::Device,
424 id: 3,
425 interface_id: 7,
426 })
427 );
428 }
429
430 #[test]
431 fn a_vm_interface_yields_its_virtual_machine() {
432 let object = json!({ "id": 9, "virtual_machine": { "id": 11, "name": "vm1" } });
433 assert_eq!(
434 assigned_ref(Some(VM_INTERFACE), Some(&object)),
435 Some(AssignedRef {
436 kind: AssignedKind::VirtualMachine,
437 id: 11,
438 interface_id: 9,
439 })
440 );
441 }
442
443 #[test]
444 fn an_unassigned_or_unreadable_assignment_yields_nothing() {
445 let object = json!({ "id": 7, "name": "eth0", "device": { "id": 3 } });
446 assert_eq!(assigned_ref(None, Some(&object)), None);
450 assert_eq!(assigned_ref(Some(DEVICE_INTERFACE), None), None);
451 assert_eq!(assigned_ref(Some("ipam.fhrpgroup"), Some(&object)), None);
452 assert_eq!(
453 assigned_ref(
454 Some(DEVICE_INTERFACE),
455 Some(&json!({ "id": 7, "device": {} }))
456 ),
457 None
458 );
459 assert_eq!(
460 assigned_ref(
461 Some(DEVICE_INTERFACE),
462 Some(&json!({ "device": { "id": 3 } }))
463 ),
464 None
465 );
466 }
467
468 mod loopback {
472 use super::*;
473
474 fn client(port: u16) -> NetboxClient {
475 NetboxClient::new(
476 &config(&format!("http://127.0.0.1:{port}")),
477 crate::testutil::outbound_with(test_resolver()),
478 )
479 .unwrap()
480 }
481
482 fn client_with_token(port: u16, token: &str) -> NetboxClient {
484 let cfg = NetboxConfig {
485 token: token.to_string(),
486 ..config(&format!("http://127.0.0.1:{port}"))
487 };
488 NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver())).unwrap()
489 }
490
491 fn on_device() -> AssignedRef {
492 AssignedRef {
493 kind: AssignedKind::Device,
494 id: 3,
495 interface_id: 7,
496 }
497 }
498
499 fn one_address() -> Value {
501 json!({
502 "count": 1,
503 "results": [{
504 "id": 12,
505 "address": "10.0.0.5/24",
506 "dns_name": "host.example.com",
507 "custom_fields": { "acme_domains": ["www.example.com"] },
508 "assigned_object_type": "dcim.interface",
509 "assigned_object_id": 7,
510 "assigned_object": { "id": 7, "name": "eth0", "device": { "id": 3 } }
511 }]
512 })
513 }
514
515 #[tokio::test]
516 async fn queries_the_address_and_authenticates() {
517 let (port, server) = serve_once(ok(one_address())).await;
518
519 let objects = client(port)
520 .ip_addresses("10.0.0.5".parse().unwrap())
521 .await
522 .unwrap();
523
524 assert_eq!(objects.len(), 1);
525 assert_eq!(objects[0].dns_name, "host.example.com");
526 assert_eq!(
527 objects[0].custom_fields["acme_domains"],
528 json!(["www.example.com"])
529 );
530 assert_eq!(objects[0].assigned, Some(on_device()));
531
532 let request = server.await.unwrap();
533 assert!(
534 request.starts_with("GET /api/ipam/ip-addresses/?address=10.0.0.5 HTTP/1.1"),
535 "{request}"
536 );
537 assert!(request.contains("authorization: Token t0ken"), "{request}");
538 }
539
540 #[tokio::test]
544 async fn a_v2_token_authenticates_as_a_bearer_credential() {
545 let (port, server) = serve_once(ok(one_address())).await;
546 let token = "nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S";
547
548 client_with_token(port, token)
549 .ip_addresses("10.0.0.5".parse().unwrap())
550 .await
551 .unwrap();
552
553 let request = server.await.unwrap();
554 assert!(
555 request.contains(&format!("authorization: Bearer {token}")),
556 "{request}"
557 );
558 }
559
560 #[tokio::test]
563 async fn surrounding_whitespace_is_trimmed_off_the_credential() {
564 let (port, server) = serve_once(ok(one_address())).await;
565
566 client_with_token(port, " t0ken\n")
567 .ip_addresses("10.0.0.5".parse().unwrap())
568 .await
569 .unwrap();
570
571 let request = server.await.unwrap();
572 assert!(
573 request.contains("authorization: Token t0ken\r\n"),
574 "{request}"
575 );
576 }
577
578 #[tokio::test]
582 async fn a_refused_credential_names_the_scheme_it_was_sent_under() {
583 let (port, _server) =
584 serve_once(status(403, "Forbidden", r#"{"detail":"Invalid token"}"#)).await;
585
586 let error = client_with_token(port, "t0ken")
587 .ip_addresses("10.0.0.5".parse().unwrap())
588 .await
589 .unwrap_err();
590
591 assert!(error.contains("Invalid token"), "{error}");
592 assert!(error.contains("ipam.netbox.token"), "{error}");
593 assert!(error.contains("Authorization: Token"), "{error}");
594 assert!(error.contains("nbt_"), "{error}");
595 }
596
597 #[tokio::test]
599 async fn an_ipv6_address_is_percent_encoded() {
600 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
601
602 let objects = client(port)
603 .ip_addresses("2001:db8::5".parse().unwrap())
604 .await
605 .unwrap();
606 assert!(objects.is_empty());
607
608 let request = server.await.unwrap();
609 assert!(request.contains("address=2001%3Adb8%3A%3A5"), "{request}");
610 }
611
612 #[tokio::test]
613 async fn a_role_is_read_off_the_choice_object() {
614 let (port, _server) = serve_once(ok(json!({
615 "results": [{ "dns_name": "service.example.com",
616 "role": { "value": "vrrp", "label": "VRRP" } }]
617 })))
618 .await;
619
620 let objects = client(port)
621 .shared_addresses(&on_device(), &[])
622 .await
623 .unwrap();
624 assert_eq!(objects[0].role.as_deref(), Some("vrrp"));
625 }
626
627 #[tokio::test]
628 async fn fetches_a_devices_custom_fields() {
629 let (port, server) = serve_once(ok(json!({
630 "id": 3,
631 "custom_fields": { "acme_domains": ["machine.example.com"] }
632 })))
633 .await;
634
635 let fields = client(port)
636 .object_custom_fields(&on_device())
637 .await
638 .unwrap();
639
640 assert_eq!(fields["acme_domains"], json!(["machine.example.com"]));
641
642 let request = server.await.unwrap();
643 assert!(
644 request.starts_with("GET /api/dcim/devices/3/ HTTP/1.1"),
645 "{request}"
646 );
647 }
648
649 #[tokio::test]
650 async fn fetches_a_virtual_machines_custom_fields() {
651 let (port, server) = serve_once(ok(json!({ "custom_fields": {} }))).await;
652
653 client(port)
654 .object_custom_fields(&AssignedRef {
655 kind: AssignedKind::VirtualMachine,
656 id: 11,
657 interface_id: 9,
658 })
659 .await
660 .unwrap();
661
662 let request = server.await.unwrap();
663 assert!(
664 request.starts_with("GET /api/virtualization/virtual-machines/11/ HTTP/1.1"),
665 "{request}"
666 );
667 }
668
669 #[tokio::test]
672 async fn service_addresses_are_scoped_to_the_device_and_the_roles() {
673 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
674
675 client(port)
676 .shared_addresses(&on_device(), &["vip".to_string(), "vrrp".to_string()])
677 .await
678 .unwrap();
679
680 let request = server.await.unwrap();
681 assert!(
682 request.starts_with(
683 "GET /api/ipam/ip-addresses/?device_id=3&role=vip&role=vrrp HTTP/1.1"
684 ),
685 "{request}"
686 );
687 }
688
689 #[tokio::test]
690 async fn service_addresses_of_a_virtual_machine_use_the_vm_filter() {
691 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
692
693 client(port)
694 .shared_addresses(
695 &AssignedRef {
696 kind: AssignedKind::VirtualMachine,
697 id: 11,
698 interface_id: 9,
699 },
700 &["vrrp".to_string()],
701 )
702 .await
703 .unwrap();
704
705 let request = server.await.unwrap();
706 assert!(
707 request.contains("virtual_machine_id=11&role=vrrp"),
708 "{request}"
709 );
710 }
711
712 #[tokio::test]
716 async fn membership_is_queried_by_the_clients_own_interface() {
717 let (port, server) = serve_once(ok(json!({
718 "results": [
719 { "id": 1, "group": { "id": 41, "name": "vrrp-41" } },
720 { "id": 2, "group": { "id": 42 } }
721 ]
722 })))
723 .await;
724
725 let groups = client(port).fhrp_groups(&on_device()).await.unwrap();
726 assert_eq!(groups, vec![41, 42]);
727
728 let request = server.await.unwrap();
729 assert!(
730 request.starts_with(
731 "GET /api/ipam/fhrp-group-assignments/\
732 ?interface_type=dcim.interface&interface_id=7 HTTP/1.1"
733 ),
734 "{request}"
735 );
736 }
737
738 #[tokio::test]
739 async fn membership_of_a_vm_interface_uses_the_vm_interface_type() {
740 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
741
742 let groups = client(port)
743 .fhrp_groups(&AssignedRef {
744 kind: AssignedKind::VirtualMachine,
745 id: 11,
746 interface_id: 9,
747 })
748 .await
749 .unwrap();
750 assert!(groups.is_empty());
751
752 let request = server.await.unwrap();
753 assert!(
754 request.contains("interface_type=virtualization.vminterface&interface_id=9"),
755 "{request}"
756 );
757 }
758
759 #[tokio::test]
762 async fn every_group_resolves_in_one_query() {
763 let (port, server) = serve_once(ok(json!({
764 "results": [{ "dns_name": "service.example.com" }]
765 })))
766 .await;
767
768 let objects = client(port).fhrp_group_addresses(&[41, 42]).await.unwrap();
769 assert_eq!(objects[0].dns_name, "service.example.com");
770
771 let request = server.await.unwrap();
772 assert!(
773 request.starts_with(
774 "GET /api/ipam/ip-addresses/?fhrpgroup_id=41&fhrpgroup_id=42 HTTP/1.1"
775 ),
776 "{request}"
777 );
778 }
779
780 #[tokio::test]
783 async fn the_two_fhrp_queries_run_in_order() {
784 let (port, server) = serve_many(vec![
785 ok(json!({ "results": [{ "group": { "id": 41 } }] })),
786 ok(json!({ "results": [{ "dns_name": "service.example.com" }] })),
787 ])
788 .await;
789
790 let client = client(port);
791 let groups = client.fhrp_groups(&on_device()).await.unwrap();
792 let objects = client.fhrp_group_addresses(&groups).await.unwrap();
793 assert_eq!(objects[0].dns_name, "service.example.com");
794
795 let requests = server.await.unwrap();
796 assert!(requests.contains("fhrp-group-assignments"), "{requests}");
797 assert!(requests.contains("fhrpgroup_id=41"), "{requests}");
798 }
799
800 #[tokio::test]
802 async fn a_subpath_base_url_prefixes_the_api_path() {
803 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
804 let cfg = config(&format!("http://127.0.0.1:{port}/netbox"));
805
806 NetboxClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
807 .unwrap()
808 .ip_addresses("10.0.0.5".parse().unwrap())
809 .await
810 .unwrap();
811
812 let request = server.await.unwrap();
813 assert!(
814 request.starts_with("GET /netbox/api/ipam/ip-addresses/?address="),
815 "{request}"
816 );
817 }
818
819 #[tokio::test]
823 async fn a_refused_token_is_reported_rather_than_parsed() {
824 let (port, _server) = serve_once(status(
825 401,
826 "Unauthorized",
827 r#"{"detail":"Invalid token header."}"#,
828 ))
829 .await;
830
831 let error = client(port)
832 .ip_addresses("10.0.0.5".parse().unwrap())
833 .await
834 .unwrap_err();
835 assert!(error.contains("401"), "{error}");
836 assert!(error.contains("Invalid token header"), "{error}");
837 }
838
839 #[tokio::test]
842 async fn a_404_is_a_failure_for_netbox() {
843 let (port, _server) = serve_once(status(404, "Not Found", "{}")).await;
844
845 let error = client(port)
846 .ip_addresses("10.0.0.5".parse().unwrap())
847 .await
848 .unwrap_err();
849 assert!(error.contains("404"), "{error}");
850 }
851
852 #[tokio::test]
855 async fn a_response_of_the_wrong_shape_is_an_error() {
856 let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
857
858 let error = client(port)
859 .ip_addresses("10.0.0.5".parse().unwrap())
860 .await
861 .unwrap_err();
862 assert!(
863 error.contains("unexpected ip-addresses response"),
864 "{error}"
865 );
866 }
867
868 #[tokio::test]
869 async fn a_malformed_assignment_list_is_an_error() {
870 let (port, _server) = serve_once(ok(json!({ "results": "nope" }))).await;
871
872 let error = client(port).fhrp_groups(&on_device()).await.unwrap_err();
873 assert!(
874 error.contains("unexpected fhrp-group-assignments response"),
875 "{error}"
876 );
877 }
878
879 #[tokio::test]
880 async fn a_malformed_device_response_is_an_error() {
881 let (port, _server) = serve_once(ok(json!({ "custom_fields": 7 }))).await;
882
883 let error = client(port)
884 .object_custom_fields(&on_device())
885 .await
886 .unwrap_err();
887 assert!(error.contains("unexpected response for"), "{error}");
888 }
889
890 #[tokio::test]
891 async fn a_closed_port_is_a_connect_error() {
892 let port = closed_port().await;
893
894 let error = client(port)
895 .ip_addresses("10.0.0.5".parse().unwrap())
896 .await
897 .unwrap_err();
898 assert!(error.contains("connecting to 127.0.0.1"), "{error}");
899 }
900 }
901
902 mod tls {
906 use super::*;
907
908 fn https_config(port: u16, skip: bool) -> NetboxConfig {
909 NetboxConfig {
910 insecure_skip_verify: skip,
911 ..config(&format!("https://localhost:{port}"))
912 }
913 }
914
915 #[tokio::test]
916 async fn a_self_signed_netbox_is_refused_by_default() {
917 let port = serve_once_tls(json!({ "results": [] })).await;
918
919 let error = NetboxClient::new(
920 &https_config(port, false),
921 crate::testutil::outbound_with(test_resolver()),
922 )
923 .unwrap()
924 .ip_addresses("10.0.0.5".parse().unwrap())
925 .await
926 .unwrap_err();
927 assert!(error.contains("TLS handshake"), "{error}");
928 }
929
930 #[tokio::test]
931 async fn skipping_verification_reaches_the_same_netbox() {
932 let port = serve_once_tls(json!({
933 "results": [{ "dns_name": "host.example.com", "custom_fields": {} }]
934 }))
935 .await;
936
937 let objects = NetboxClient::new(
938 &https_config(port, true),
939 crate::testutil::outbound_with(test_resolver()),
940 )
941 .unwrap()
942 .ip_addresses("10.0.0.5".parse().unwrap())
943 .await
944 .expect("skip-verify must accept a self-signed certificate");
945 assert_eq!(objects[0].dns_name, "host.example.com");
946 }
947 }
948}