Skip to main content

acme_proxy/ipam/netbox/
mod.rs

1//! The `netbox` IPAM backend: what NetBox associates with an address.
2//!
3//! ## What NetBox is asked
4//!
5//! One lookup always happens: `GET /api/ipam/ip-addresses/?address=<client ip>`.
6//! What it returns permits names two ways — the address object's own `dns_name`
7//! ([`Source::DnsName`]) and a custom field on it ([`Source::CustomField`]),
8//! by default `acme_allowed_names`.
9//!
10//! Three more lookups are conditional on [`Source`]s that are off by default,
11//! because each widens what a client may certify:
12//!
13//! - [`Source::Device`] — when the address object carries no value for the
14//!   custom field, read it from the device or virtual machine the address is
15//!   assigned to, so names can be declared once per machine rather than once
16//!   per address. A **fallback and not a union** on purpose: a value set on the
17//!   address is the more specific statement, and an operator narrowing one
18//!   address of a machine would be surprised to see the machine-wide list
19//!   quietly widen it again. (This one is on by default — it predates the
20//!   others and moving an existing deployment across must change nothing.)
21//! - [`Source::Vip`] — the role-tagged service addresses of the same device,
22//!   `?device_id=N&role=vip&role=vrrp…`. A **union**: a keepalived member's own
23//!   names and the names on the VIP it answers for are both true at once.
24//! - [`Source::Fhrp`] — the addresses of an FHRP group the client's **own
25//!   interface** is recorded as a member of. Also a union.
26//!
27//! ## The FHRP path is a membership proof, and the direction is why
28//!
29//! A group is only ever reached *through an assignment naming the client's own
30//! interface*:
31//!
32//! ```text
33//! client address ─▶ its interface ─▶ fhrp-group-assignments?interface_id=…
34//!                                          └─▶ group ids ─▶ their addresses
35//! ```
36//!
37//! Nothing is ever looked up by group name, by the service address, or by the
38//! identifier the client asked for. So there is no query that could reach a
39//! group the client is not recorded in, and the check cannot be turned into a
40//! lookup of "who owns this name?" by a client choosing its request carefully.
41//! An interface in no group contributes nothing and costs one request.
42//!
43//! ## Denied versus Internal
44//!
45//! Every failure here is an [`IpamError`], which the filter reports as a 500
46//! the client can retry: NetBox answering 500, refusing the token or timing out
47//! is this server failing to reach a decision, never a statement about the
48//! client. The only thing this backend says *about* the client is
49//! [`AddressNames::Unknown`] — NetBox holds no object for the address at all.
50//! See the [subsystem docs](super) for why that split is what stops it failing
51//! open.
52
53pub 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/// Which kind of NetBox object an address is assigned to.
66///
67/// Only the two that can carry custom fields worth reading here; anything else
68/// an interface may hang off is left alone.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum AssignedKind {
71    Device,
72    VirtualMachine,
73}
74
75impl AssignedKind {
76    /// The `?…_id=` filter naming this object on the addresses endpoint.
77    fn owner_filter(self) -> &'static str {
78        match self {
79            Self::Device => "device_id",
80            Self::VirtualMachine => "virtual_machine_id",
81        }
82    }
83
84    /// NetBox's content-type label for the interface an address hangs off.
85    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/// The machine an address is assigned to, and the interface it hangs off.
94///
95/// Both halves are needed and both come from the *same* list response, so
96/// learning them costs no extra request: the device answers "what else is on
97/// this box?" ([`Source::Device`], [`Source::Vip`]) and the interface answers
98/// "which redundancy groups is this client in?" ([`Source::Fhrp`]).
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct AssignedRef {
101    pub kind: AssignedKind,
102    /// The device or virtual machine.
103    pub id: u64,
104    /// The interface the address is configured on.
105    pub interface_id: u64,
106}
107
108/// One NetBox `ipam/ip-addresses` object, reduced to what this backend reads.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct NetboxIp {
111    /// The object's `dns_name`, empty when unset.
112    pub dns_name: String,
113    /// The object's custom fields, as NetBox returned them.
114    pub custom_fields: Map<String, Value>,
115    /// The device or VM behind the assigned interface, when there is one.
116    pub assigned: Option<AssignedRef>,
117    /// The object's `role`, e.g. `vrrp`. `None` for an ordinary address.
118    pub role: Option<String>,
119}
120
121/// The NetBox queries this backend makes.
122///
123/// A trait so the policy above can be tested without a NetBox — the same seam
124/// [`Resolver`](crate::dns::Resolver) gives `reverse_dns`, and errors are plain
125/// `String`s for the same reason: what a caller does with a failed query does
126/// not concern the transport.
127#[async_trait]
128pub trait NetboxApi: Send + Sync {
129    /// The `ipam/ip-addresses` objects NetBox holds for this address.
130    async fn ip_addresses(&self, ip: IpAddr) -> Result<Vec<NetboxIp>, String>;
131
132    /// The custom fields of one device or virtual machine.
133    async fn object_custom_fields(
134        &self,
135        reference: &AssignedRef,
136    ) -> Result<Map<String, Value>, String>;
137
138    /// The addresses of the same machine carrying one of `roles`.
139    async fn shared_addresses(
140        &self,
141        reference: &AssignedRef,
142        roles: &[String],
143    ) -> Result<Vec<NetboxIp>, String>;
144
145    /// The FHRP groups this **interface** is recorded as a member of.
146    async fn fhrp_groups(&self, reference: &AssignedRef) -> Result<Vec<u64>, String>;
147
148    /// The addresses assigned to those groups, in one query.
149    async fn fhrp_group_addresses(&self, group_ids: &[u64]) -> Result<Vec<NetboxIp>, String>;
150}
151
152/// Reports which names NetBox associates with an address.
153pub 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    /// `dyn NetboxApi` is not `Debug`; the policy is the interesting part.
162    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
172/// Every source NetBox can answer for — which is all of them.
173const SUPPORTED: &[Source] = &[
174    Source::DnsName,
175    Source::CustomField,
176    Source::Device,
177    Source::Vip,
178    Source::Fhrp,
179];
180
181impl NetboxBackend {
182    /// Builds the real NetBox client, then delegates.
183    ///
184    /// Nothing is contacted here: a NetBox that is down at startup is an
185    /// outage, not a configuration error, and stopping the server for it would
186    /// turn a retryable 500 into a refusal to boot.
187    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        // Unconditional, and deliberately not once-only: this is a temporary
204        // operational state (an expired NetBox certificate being waited out),
205        // and it should stay visible in the log for as long as it lasts. The
206        // counterpart of `tls_disabled` and `challenge_validation_bypassed`.
207        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    /// Same, against a caller-supplied API. Used by tests.
222    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        // Only checked when something would read it: an operator who trusts
226        // only `dns_name` has no custom field to name, and demanding one would
227        // be a rule with no purpose behind it.
228        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    /// Adds one address object's own names, and reports whether its custom
254    /// field said anything.
255    ///
256    /// The return value is what decides the [`Source::Device`] fallback, and it
257    /// is tracked separately from `names` being non-empty because a `dns_name`
258    /// fills that too and must not suppress the fallback.
259    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    /// The device or VM's custom field, when the address itself was silent.
277    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    /// The role-tagged service addresses of the same machine.
300    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            // Re-checked here as well as in the query. NetBox refuses an
320            // unknown `role` choice outright, but a filter parameter this
321            // server got wrong must never degrade to "every address on the
322            // device" — that would widen an allowlist without saying so, which
323            // is precisely the failure an allowlist exists to prevent.
324            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    /// The addresses of every FHRP group the client's interface belongs to.
343    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        // An interface in no group contributes nothing, and the second query is
358        // not made at all — this is the ordinary case for most of an estate.
359        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            // Nothing to scope the three machine-shaped lookups to.
414            return Ok(names);
415        };
416
417        // A fallback: skipped entirely when the address spoke for itself. With
418        // `custom_field` not among the sources nothing was read from the
419        // address, so there is nothing to fall back *from* and the machine's
420        // list always applies.
421        if self.sources.contains(&Source::Device) && !custom_field_answered {
422            self.add_device_names(&mut names, &reference, client_ip)
423                .await?;
424        }
425
426        // Unions, both of them: unlike the fallback above, neither is gated on
427        // what the address itself said. A member address's own names and the
428        // names on the service address it answers for are both true at once.
429        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    // ------------------------------------------------------------- the stub
450
451    /// A NetBox answering from canned maps, never touching the network.
452    #[derive(Default)]
453    struct StubNetbox {
454        addresses: HashMap<IpAddr, Vec<NetboxIp>>,
455        objects: HashMap<u64, Map<String, Value>>,
456        /// Service addresses keyed by the device/VM id they sit on.
457        shared: HashMap<u64, Vec<NetboxIp>>,
458        /// FHRP group ids keyed by the interface recorded as a member.
459        memberships: HashMap<u64, Vec<u64>>,
460        /// Addresses keyed by the FHRP group they are assigned to.
461        group_addresses: HashMap<u64, Vec<NetboxIp>>,
462        error: Option<String>,
463        /// One counter per conditional lookup, so a test can assert a query was
464        /// *not* made — which is what "this source is off" actually means.
465        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    // ------------------------------------------------------------- fixtures
552
553    /// An address object with a `dns_name` and nothing else.
554    fn with_dns_name(dns_name: &str) -> NetboxIp {
555        NetboxIp {
556            dns_name: dns_name.to_string(),
557            ..NetboxIp::default()
558        }
559    }
560
561    /// An address object whose custom field lists `names`.
562    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    /// Device 3, interface 7 — the machine every fixture here sits on.
573    fn on_device() -> AssignedRef {
574        AssignedRef {
575            kind: AssignedKind::Device,
576            id: 3,
577            interface_id: 7,
578        }
579    }
580
581    /// An address object assigned to device 3, carrying no names itself.
582    fn assigned() -> NetboxIp {
583        NetboxIp {
584            assigned: Some(on_device()),
585            ..NetboxIp::default()
586        }
587    }
588
589    /// A service address with a role and a `dns_name`.
590    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    /// Every name the backend reports for the usual client address.
622    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    // ------------------------------------------------------- the happy paths
646
647    #[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    // ------------------------------------------------------------- refusals
699
700    /// The distinction the whole [`AddressNames`] enum exists for: an address
701    /// NetBox has never heard of is not the same as one recorded and entitled
702    /// to nothing, and the filter words the two refusals differently.
703    #[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    // ------------------------------------------------------ the sources gate
730
731    #[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    // ------------------------------------------------------ device fallback
757
758    #[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        // The machine-wide list really is out of reach while the address speaks.
788        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    /// With `custom_field` out of the sources nothing is read from the address,
825    /// so there is nothing to fall back *from* and the machine's list always
826    /// applies.
827    #[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    // ------------------------------------------------------------ vip source
907
908    #[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    /// A union, not a fallback: unlike `device`, this fires even when the
919    /// member address spoke for itself. Both statements are true at once.
920    #[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    /// NetBox refuses an unknown role choice outright, but a wrong filter
939    /// parameter must never degrade into "every address on this device".
940    #[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                    // No role at all — an ordinary address the query should
950                    // never have returned.
951                    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    // ----------------------------------------------------------- fhrp source
1027
1028    #[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    /// The security property the whole source turns on. A group is reached only
1040    /// through an assignment naming *this* interface, so an interface recorded
1041    /// in no group cannot borrow anybody's service name — and the second query
1042    /// is never made.
1043    #[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                // Group 41 exists and holds the name, but interface 7 is not a
1049                // member of it — interface 99 is.
1050                .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    /// A group's addresses carry custom fields like any other address object,
1080    /// and no role filter applies here — an address assigned to a group *is*
1081    /// the group's service address by construction.
1082    #[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    // ------------------------------------------- malformed NetBox answers
1190
1191    #[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        // The dns_name still works; the unusable field contributes nothing.
1206        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    // ------------------------------------------------------ startup + wiring
1220
1221    #[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    /// …and not otherwise: an operator trusting only `dns_name` has no custom
1234    /// field to name, and a rule with no purpose behind it is noise.
1235    #[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}