Skip to main content

acme_proxy/ipam/netbox/
client.rs

1//! The production [`NetboxApi`]: NetBox's REST API over HTTP/1.1.
2//!
3//! Five queries, of which one always runs and four are gated by
4//! [`Source`](crate::ipam::Source):
5//!
6//! | Query | Source |
7//! | --- | --- |
8//! | `ipam/ip-addresses/?address=…` | always |
9//! | `dcim/devices/{id}/`, `virtualization/virtual-machines/{id}/` | `device` |
10//! | `ipam/ip-addresses/?device_id=…&role=…` | `vip` |
11//! | `ipam/fhrp-group-assignments/?interface_type=…&interface_id=…` | `fhrp` |
12//! | `ipam/ip-addresses/?fhrpgroup_id=…` | `fhrp` |
13//!
14//! The transport, the body cap, the TLS policy and the error shape all live in
15//! [`ipam::http`](crate::ipam::http), which both backends share. What is here
16//! is NetBox's own paths, filters, wire shapes and the two schemes its two
17//! token generations authenticate under — see [`V2_TOKEN_PREFIX`].
18
19use 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
31/// NetBox's own labels for the two interface types an address can hang off.
32pub(super) const DEVICE_INTERFACE: &str = "dcim.interface";
33pub(super) const VM_INTERFACE: &str = "virtualization.vminterface";
34
35/// The fixed prefix NetBox mints onto a v2 API token.
36///
37/// NetBox 4.5 made v2 the default and authenticates it over the standard bearer
38/// scheme (`Authorization: Bearer nbt_<key>.<secret>`), where a legacy v1 token
39/// stays `Authorization: Token <token>` and stops being accepted in NetBox 4.7.
40/// The prefix exists to make a v2 credential identifiable, and NetBox shows the
41/// whole string once at creation — so the token an operator pastes already says
42/// which scheme it wants, and no configuration key has to.
43const V2_TOKEN_PREFIX: &str = "nbt_";
44
45/// A NetBox REST client.
46#[derive(Debug)]
47pub struct NetboxClient {
48    api: JsonApi,
49    /// The scheme the configured token asked for, kept only to name it on a
50    /// refusal: NetBox rejects a credential sent under the wrong scheme exactly
51    /// as it rejects one that expired, so the answer alone cannot tell an
52    /// operator which of the two happened.
53    scheme: &'static str,
54}
55
56impl NetboxClient {
57    /// Validates the URL and builds the TLS configuration. No network yet.
58    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        // Trimmed rather than taken verbatim: the emptiness check above already
71        // trims, and a token arriving from an env file with its newline still
72        // attached is not a header value hyper will build.
73        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    /// A `GET` whose every failure is a failure — NetBox has no status this
103    /// backend reads as an answer.
104    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    /// The address list endpoint, with a pre-built query string.
120    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// ------------------------------------------------------- the wire shapes
131
132/// The `ipam/ip-addresses` list response, reduced to what is read.
133#[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    /// NetBox renders a choice field as `{"value": "vrrp", "label": "VRRP"}`.
150    #[serde(default)]
151    role: Option<Choice>,
152}
153
154/// One NetBox choice field. Only `value` is read — `label` is display text and
155/// is localized, so comparing against it would break on a translated instance.
156#[derive(Debug, Deserialize)]
157struct Choice {
158    #[serde(default)]
159    value: String,
160}
161
162/// A detail response, of which only the custom fields matter.
163#[derive(Debug, Deserialize)]
164struct ObjectResponse {
165    #[serde(default)]
166    custom_fields: Map<String, Value>,
167}
168
169/// The `ipam/fhrp-group-assignments` list response.
170///
171/// One row per (interface, group) pair. Filtering it by `interface_id` is the
172/// membership question, and the only way this backend ever learns a group id.
173#[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/// Any nested NetBox object, of which only the id is read.
186#[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
205/// Digs the device or VM — and the interface — out of an address's assignment.
206///
207/// NetBox nests it: an address points at an *interface*, and the interface
208/// carries the device (or virtual machine) it belongs to. The brief serializer
209/// the list endpoint uses includes that nested object, so both ids are known
210/// without an extra request — which is what keeps the FHRP membership query
211/// down to one round trip.
212///
213/// An address assigned to an FHRP group itself (`ipam.fhrpgroup`) yields
214/// `None`, and rightly so: a client connecting *from* the VIP already got that
215/// object's own names out of the first query, and there is no device to scope
216/// anything else to.
217fn 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        // `role` is a multiple-choice filter, so it is repeated rather than
275        // comma-joined; NetBox ORs the values. Built and finished inside its
276        // own scope: `Serializer` is not `Send`, so holding one across the
277        // await below would make this future unspawnable.
278        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        // Also a multiple-choice filter, so every group resolves in one query
310        // however many the interface belongs to. Scoped for the same `Send`
311        // reason as `shared_addresses` above.
312        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    // ------------------------------------------------------ startup checks
340
341    #[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    /// The key half of a v2 token pasted without its secret. NetBox shows the
366    /// two joined, so this is a copy that stopped at the dot — and it would
367    /// otherwise authenticate nowhere, with a 403 as the only symptom.
368    #[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    // ------------------------------------------------- the assignment digger
416
417    #[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        // No type at all, no object, a type with no machine behind it (which is
447        // what an FHRP-group VIP looks like), a device without an id, and an
448        // interface without one.
449        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    /// The real client against a loopback listener. A stub `NetboxApi` proves
469    /// the policy; only this proves the request line, the `Host` header and the
470    /// `Authorization` header are what NetBox actually needs.
471    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        /// The same client with the token spelled out, for the two schemes.
483        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        /// A realistic NetBox answer, nested assignment included.
500        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        /// A NetBox v2 token — the default since NetBox 4.5 — authenticates
541        /// under `Bearer`, not the `Token` scheme its predecessor used. The
542        /// `nbt_` prefix is the whole of how the two are told apart.
543        #[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        /// A token read out of an env file keeps its newline, which is not a
561        /// header value hyper will build — so the request never leaves at all.
562        #[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        /// NetBox refuses a credential sent under the wrong scheme exactly as
579        /// it refuses an expired one, so the answer alone leaves an operator
580        /// with nothing to check. Name the scheme that was used.
581        #[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        /// An IPv6 address's colons must survive into the query.
598        #[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        /// `role` is repeated, not comma-joined: NetBox's multiple-choice
670        /// filters OR their values, and a comma-joined one matches nothing.
671        #[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        /// The membership query, and the whole security property of the `fhrp`
713        /// source: it is scoped to the client's **own** interface, never to a
714        /// group or a name.
715        #[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        /// However many groups an interface belongs to, their addresses come
760        /// back in one query — `fhrpgroup_id` is a multiple-choice filter.
761        #[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        /// The two FHRP queries in sequence, over two connections, which is
781        /// what one lookup with `fhrp` on actually costs.
782        #[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        /// A subpath deployment keeps its prefix in the request line.
801        #[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        /// A refused token is the operator's problem, not the client's — it
820        /// must surface as an error the caller turns into a 500, never as an
821        /// empty answer that would read as "this address owns no names".
822        #[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        /// Unlike phpIPAM, NetBox has no status this backend reads as an
840        /// answer: a 404 is a failure like any other.
841        #[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        /// A body of the right shape but the wrong types is not an outage —
853        /// still an error, because a wrong answer must not read as "no names".
854        #[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    /// The proof that `insecure_skip_verify` does what it says: the same
903    /// self-signed server is unreachable with verification on and readable with
904    /// it off. Without this the switch would only be declarative.
905    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}