Skip to main content

acme_proxy/ipam/
mod.rs

1//! IP address management: which names does an address own?
2//!
3//! One question, asked of whichever inventory an estate already keeps. It used
4//! to be a filter — `filter.netbox` — which welded the question to one vendor's
5//! REST API: the config lived under `[filter.netbox]`, the seam was shaped
6//! around NetBox's endpoints, and the filter was named after the product. A
7//! second inventory had nowhere to plug in.
8//!
9//! So the question lives here and the *policy* built on the answer stays in
10//! [`filter::ipam`](crate::filter::ipam), which is the only consumer. The split
11//! is the same one [`signer`](crate::signer) makes: a backend reports what is
12//! true, a caller decides what to do about it.
13//!
14//! ## Denied versus Internal
15//!
16//! The most consequential property here, and the reason [`IpamError`] is a
17//! struct rather than an enum with a "denied" variant: **an `Ipam` never denies
18//! anything.** It reports what an inventory holds, and every failure to obtain
19//! that — unreachable, 500, a refused token, a timeout — is this server failing
20//! to reach a decision, which the filter turns into a retryable 500 rather than
21//! a refusal. The only inventory-sourced denial is
22//! [`AddressNames::Unknown`], which is a fact about the address rather than a
23//! failure to look it up.
24//!
25//! That is what keeps the subsystem from ever failing open: an inventory
26//! outage stops issuance instead of permitting everything.
27//!
28//! ## The budget lives here
29//!
30//! [`IpamRegistry`] wraps every lookup in a `tokio::time::timeout`, the way
31//! [`ChallengeRegistry`](crate::challenge::ChallengeRegistry) wraps every
32//! validation attempt. A backend may make four requests to answer one question;
33//! one budget covers all of them, and a backend added later cannot forget to
34//! apply it. [`custom`] is the proof of that last clause: its lookup is a
35//! forked process rather than a request at all, and it is covered without
36//! having been written to be.
37//!
38//! ## Matching is exact
39//!
40//! Names are [`normalize`]d — lowercased and stripped of a trailing dot — and
41//! otherwise compared literally. No suffix rule, no wildcard expansion: an
42//! entry `example.com` does not permit `a.example.com`, and a request for
43//! `*.example.com` requires that exact string in the inventory. The same choice
44//! [`compile_anchored`](crate::filter::compile_anchored) makes for the
45//! regex-based filters, for the same reason — a rule that quietly covers more
46//! than it says is the bypass an allowlist exists to prevent.
47
48pub mod custom;
49pub mod http;
50pub mod netbox;
51pub mod phpipam;
52
53use std::collections::BTreeSet;
54use std::net::IpAddr;
55use std::sync::Arc;
56use std::time::Duration;
57
58use async_trait::async_trait;
59use serde_json::{Map, Value};
60use tracing::{info, warn};
61
62use crate::config::IpamConfig;
63
64/// What an inventory knows about one address.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum AddressNames {
67    /// The inventory holds no record of this address at all.
68    ///
69    /// Distinct from `Known` with an empty set, which is "recorded, and
70    /// entitled to nothing" — the two produce different refusals, and an
71    /// operator reading a 403 should be able to tell them apart.
72    Unknown,
73    /// The names it associates with the address, already [`normalize`]d.
74    Known(BTreeSet<String>),
75}
76
77impl AddressNames {
78    /// A known address with no names yet; add them with [`Self::insert`].
79    #[must_use]
80    pub fn known() -> Self {
81        Self::Known(BTreeSet::new())
82    }
83
84    /// Adds a name, normalizing it and ignoring an empty one.
85    ///
86    /// An unset NetBox `dns_name` comes back as `""` rather than as absent, so
87    /// the empty check is load-bearing rather than defensive. Does nothing on
88    /// [`Self::Unknown`].
89    pub fn insert(&mut self, value: &str) {
90        if let Self::Known(names) = self {
91            let name = normalize(value);
92            if !name.is_empty() {
93                names.insert(name);
94            }
95        }
96    }
97
98    /// Whether the inventory holds a record of the address.
99    #[must_use]
100    pub fn is_known(&self) -> bool {
101        matches!(self, Self::Known(_))
102    }
103
104    /// The names, or an empty set for an unknown address.
105    #[must_use]
106    pub fn names(&self) -> &BTreeSet<String> {
107        static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
108        match self {
109            Self::Known(names) => names,
110            Self::Unknown => EMPTY.get_or_init(BTreeSet::new),
111        }
112    }
113}
114
115/// The inventory failed to reach a decision.
116///
117/// There is deliberately **no** "denied" variant. An [`Ipam`] reports what an
118/// inventory holds; every failure to obtain that is the server's problem, never
119/// the client's, and the filter is the only place a denial is decided. Keeping
120/// the type unable to express a refusal is what stops a backend author from
121/// accidentally turning an outage into a permanent-looking rejection.
122#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
123#[error("{0}")]
124pub struct IpamError(pub String);
125
126/// One place a permitted name may come from.
127///
128/// Each backend declares which of these it supports, and its `sources` key
129/// lists which are actually consulted. The list is a **union of sets**, so its
130/// order is meaningless — unlike `filter.enabled` (evaluation order) or
131/// `challenge.enabled` (offer order).
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub enum Source {
134    /// The address object's own name: NetBox's `dns_name`, phpIPAM's
135    /// `hostname`.
136    DnsName,
137    /// The configured custom field, read from the address object itself.
138    CustomField,
139    /// The same custom field on the device or virtual machine the address is
140    /// assigned to.
141    ///
142    /// A **fallback, not a union**: read only when the address object carried
143    /// no value of its own. A value set on the address is the more specific
144    /// statement, and an operator narrowing one address of a machine would be
145    /// surprised to see the machine-wide list quietly widen it again.
146    Device,
147    /// Role-tagged service addresses on the same device — a VIP shared by a
148    /// keepalived or CARP pair. A **union**: the member's own names and the
149    /// service address's names are both true at once.
150    Vip,
151    /// The service addresses of an FHRP group the client's own interface is
152    /// recorded as a member of. A **union**, like [`Self::Vip`].
153    Fhrp,
154}
155
156impl Source {
157    /// The name this source is configured under.
158    #[must_use]
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::DnsName => "dns_name",
162            Self::CustomField => "custom_field",
163            Self::Device => "device",
164            Self::Vip => "vip",
165            Self::Fhrp => "fhrp",
166        }
167    }
168
169    /// Every source name, for an error listing what was expected.
170    const ALL: &'static [Self] = &[
171        Self::DnsName,
172        Self::CustomField,
173        Self::Device,
174        Self::Vip,
175        Self::Fhrp,
176    ];
177
178    fn parse(name: &str) -> Option<Self> {
179        Self::ALL.iter().copied().find(|s| s.as_str() == name)
180    }
181}
182
183/// The `sources` a backend was configured with, after validation.
184///
185/// Ordered so a `Debug` rendering — which is what a startup log line and
186/// `signer::build_backends`-style config keying both read — is deterministic,
187/// even though the set's own meaning has no order.
188pub type Sources = BTreeSet<Source>;
189
190/// Parses and validates a `sources` list against what one backend supports.
191///
192/// Empty, or an unknown name, is a startup error — the `challenge.enabled`
193/// rule verbatim, and for the same reason: an inventory trusted for nothing can
194/// never permit a name, so it is a filter that refuses everything, and a typo
195/// that silently narrows an allowlist is worse than a refusal to boot.
196///
197/// A name that exists but is not this backend's is refused **by name**, not
198/// ignored: `fhrp` under `[ipam.phpipam]` is an operator expecting redundancy
199/// groups from a product that records none, and answering that with silence
200/// would leave them believing a check is running that never runs.
201pub(crate) fn parse_sources(
202    backend: &str,
203    setting: &str,
204    values: &[String],
205    supported: &[Source],
206) -> anyhow::Result<Sources> {
207    anyhow::ensure!(
208        !values.is_empty(),
209        "{setting} is empty; an inventory trusted for nothing can never permit a name, so \
210         every request would be refused. List at least one of: {}",
211        names_of(supported)
212    );
213
214    let mut sources = Sources::new();
215    for value in values {
216        let name = value.trim();
217        let source = Source::parse(name).ok_or_else(|| {
218            anyhow::anyhow!(
219                "{setting}: unknown source `{name}`; known sources are {}",
220                names_of(Source::ALL)
221            )
222        })?;
223        anyhow::ensure!(
224            supported.contains(&source),
225            "{setting}: `{name}` is not a source {backend} has; it supports {}",
226            names_of(supported)
227        );
228        sources.insert(source);
229    }
230    Ok(sources)
231}
232
233/// `a`, `b`, `c` — the way an error should list what it expected.
234fn names_of(sources: &[Source]) -> String {
235    sources
236        .iter()
237        .map(|source| format!("`{}`", source.as_str()))
238        .collect::<Vec<_>>()
239        .join(", ")
240}
241
242/// The inventory this profile consults.
243#[async_trait]
244pub trait Ipam: Send + Sync {
245    /// The product's name, as it should read in a log line or a 403 detail.
246    fn name(&self) -> &'static str;
247
248    /// Every name this inventory associates with `ip`.
249    async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError>;
250}
251
252/// The configured backend plus the budget every lookup runs under.
253///
254/// The timeout is here rather than inside each backend for the reason
255/// [`ChallengeRegistry`](crate::challenge::ChallengeRegistry) keeps its there:
256/// a backend may make several requests to answer one question, one budget has
257/// to cover all of them, and a backend written later cannot forget to apply
258/// something it never touches.
259pub struct IpamRegistry {
260    backend: Arc<dyn Ipam>,
261    timeout: Duration,
262}
263
264impl std::fmt::Debug for IpamRegistry {
265    /// `dyn Ipam` is not `Debug`; the name and the budget are the readable part.
266    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        formatter
268            .debug_struct("IpamRegistry")
269            .field("backend", &self.backend.name())
270            .field("timeout", &self.timeout)
271            .finish()
272    }
273}
274
275impl IpamRegistry {
276    /// Wraps a backend in its budget.
277    #[must_use]
278    pub fn new(backend: Arc<dyn Ipam>, timeout: Duration) -> Self {
279        Self { backend, timeout }
280    }
281
282    /// The backend's name, so a refusal can say which inventory refused.
283    #[must_use]
284    pub fn backend_name(&self) -> &'static str {
285        self.backend.name()
286    }
287
288    /// One lookup, under the configured budget.
289    pub async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError> {
290        match tokio::time::timeout(self.timeout, self.backend.names_for(ip)).await {
291            Ok(result) => result,
292            Err(_) => Err(IpamError(format!(
293                "{} lookup for {ip} timed out after {}ms",
294                self.backend.name(),
295                self.timeout.as_millis()
296            ))),
297        }
298    }
299}
300
301/// Builds the configured inventory, or `None` when none is configured.
302///
303/// Called once at startup, so it may fail fast — but it contacts nothing: an
304/// inventory that is down at startup is an outage, not a configuration error,
305/// and stopping the server for it would turn a retryable 500 into a refusal to
306/// boot.
307pub fn from_config(
308    cfg: &IpamConfig,
309    outbound: crate::http_client::Outbound,
310) -> anyhow::Result<Option<Arc<IpamRegistry>>> {
311    let backend: Arc<dyn Ipam> = match cfg.backend.trim() {
312        "" => return Ok(None),
313        "netbox" => Arc::new(netbox::NetboxBackend::from_config(&cfg.netbox, outbound)?),
314        "phpipam" => Arc::new(phpipam::PhpIpamBackend::from_config(
315            &cfg.phpipam,
316            outbound,
317        )?),
318        // The one backend that reaches nothing over HTTP, so `outbound` is not
319        // threaded into it — the script decides for itself where it looks.
320        "custom" => Arc::new(custom::CustomIpamBackend::from_config(
321            &cfg.custom,
322            cfg.timeout_ms,
323        )?),
324        other => anyhow::bail!(
325            "unknown IPAM backend: {other} (expected `netbox`, `phpipam` or `custom`)"
326        ),
327    };
328
329    info!(
330        event = "ipam_enabled",
331        outcome = "success",
332        backend = backend.name(),
333        timeout_ms = cfg.timeout_ms,
334    );
335
336    Ok(Some(Arc::new(IpamRegistry::new(
337        backend,
338        Duration::from_millis(cfg.timeout_ms),
339    ))))
340}
341
342/// Lowercased and stripped of a trailing dot, the form both sides compare in.
343#[must_use]
344pub fn normalize(value: &str) -> String {
345    value.trim().trim_end_matches('.').to_ascii_lowercase()
346}
347
348/// A custom field's entries, as a list of strings.
349///
350/// An inventory lets a custom field be a multi-select (a JSON array) or plain
351/// text (a single string); both are accepted. Anything else is a field
352/// misconfigured on the inventory side, which is worth a log line but not a
353/// reason to fail the request — the names simply do not come from there.
354pub(crate) fn field_values(
355    fields: &Map<String, Value>,
356    field: &str,
357    backend: &'static str,
358    source: &str,
359) -> Vec<String> {
360    match fields.get(field) {
361        None | Some(Value::Null) => Vec::new(),
362        Some(Value::String(one)) => vec![one.clone()],
363        Some(Value::Array(items)) => items
364            .iter()
365            .filter_map(|item| match item {
366                Value::String(name) => Some(name.clone()),
367                other => {
368                    warn!(
369                        event = "ipam_field_entry_ignored",
370                        outcome = "advisory",
371                        backend,
372                        field,
373                        source,
374                        entry = %other,
375                        "custom field entry is not a string"
376                    );
377                    None
378                }
379            })
380            .collect(),
381        Some(other) => {
382            warn!(
383                event = "ipam_field_ignored",
384                outcome = "advisory",
385                backend,
386                field,
387                source,
388                kind = value_kind(other),
389                "custom field is neither a string nor a list of strings"
390            );
391            Vec::new()
392        }
393    }
394}
395
396/// A JSON value's type name, for a log line that should not carry the value.
397pub(crate) fn value_kind(value: &Value) -> &'static str {
398    match value {
399        Value::Null => "null",
400        Value::Bool(_) => "bool",
401        Value::Number(_) => "number",
402        Value::String(_) => "string",
403        Value::Array(_) => "array",
404        Value::Object(_) => "object",
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use serde_json::json;
412
413    fn strings(values: &[&str]) -> Vec<String> {
414        values.iter().map(|v| (*v).to_string()).collect()
415    }
416
417    fn resolver() -> Arc<dyn crate::dns::Resolver> {
418        crate::challenge::build_resolver(None).unwrap()
419    }
420
421    // ------------------------------------------------------------- sources
422
423    #[test]
424    fn every_source_round_trips_through_its_name() {
425        for source in Source::ALL {
426            assert_eq!(Source::parse(source.as_str()), Some(*source));
427        }
428        assert_eq!(Source::parse("nope"), None);
429    }
430
431    #[test]
432    fn sources_parse_and_deduplicate() {
433        let parsed = parse_sources(
434            "NetBox",
435            "ipam.netbox.sources",
436            &strings(&["dns_name", "custom_field", "dns_name"]),
437            Source::ALL,
438        )
439        .unwrap();
440        assert_eq!(parsed.len(), 2);
441        assert!(parsed.contains(&Source::DnsName));
442        assert!(parsed.contains(&Source::CustomField));
443    }
444
445    /// Whitespace around an entry is an operator writing a list by hand, not a
446    /// different source.
447    #[test]
448    fn sources_are_trimmed() {
449        let parsed = parse_sources(
450            "NetBox",
451            "ipam.netbox.sources",
452            &strings(&[" dns_name "]),
453            Source::ALL,
454        )
455        .unwrap();
456        assert!(parsed.contains(&Source::DnsName));
457    }
458
459    #[test]
460    fn an_empty_sources_list_is_a_startup_error() {
461        let error = parse_sources("NetBox", "ipam.netbox.sources", &[], Source::ALL).unwrap_err();
462        let message = error.to_string();
463        assert!(
464            message.contains("ipam.netbox.sources is empty"),
465            "{message}"
466        );
467        assert!(message.contains("`dns_name`"), "{message}");
468    }
469
470    #[test]
471    fn an_unknown_source_is_a_startup_error_naming_it() {
472        let error = parse_sources(
473            "NetBox",
474            "ipam.netbox.sources",
475            &strings(&["dns_name", "typo"]),
476            Source::ALL,
477        )
478        .unwrap_err();
479        let message = error.to_string();
480        assert!(message.contains("unknown source `typo`"), "{message}");
481        assert!(message.contains("`fhrp`"), "{message}");
482    }
483
484    /// The refusal a phpIPAM operator gets for asking about FHRP groups: by
485    /// name, listing what the backend does have. Silence here would leave them
486    /// believing a check runs that never runs.
487    #[test]
488    fn a_source_another_backend_has_is_refused_by_name() {
489        let error = parse_sources(
490            "phpIPAM",
491            "ipam.phpipam.sources",
492            &strings(&["dns_name", "fhrp"]),
493            &[Source::DnsName, Source::CustomField, Source::Device],
494        )
495        .unwrap_err();
496        let message = error.to_string();
497        assert!(
498            message.contains("`fhrp` is not a source phpIPAM has"),
499            "{message}"
500        );
501        assert!(message.contains("`device`"), "{message}");
502        assert!(!message.contains("`vip`"), "{message}");
503    }
504
505    // ------------------------------------------------------- AddressNames
506
507    #[test]
508    fn an_unknown_address_is_not_an_empty_one() {
509        let unknown = AddressNames::Unknown;
510        let empty = AddressNames::known();
511        assert!(!unknown.is_known());
512        assert!(empty.is_known());
513        assert_eq!(unknown.names().len(), 0);
514        assert_ne!(unknown, empty);
515    }
516
517    #[test]
518    fn inserting_normalizes_and_skips_empties() {
519        let mut names = AddressNames::known();
520        names.insert("Host.Example.COM.");
521        names.insert("  ");
522        names.insert("");
523        names.insert("host.example.com");
524        assert_eq!(
525            names.names().iter().cloned().collect::<Vec<_>>(),
526            vec!["host.example.com".to_string()]
527        );
528    }
529
530    #[test]
531    fn inserting_into_an_unknown_address_does_nothing() {
532        let mut names = AddressNames::Unknown;
533        names.insert("host.example.com");
534        assert_eq!(names, AddressNames::Unknown);
535    }
536
537    #[test]
538    fn normalize_lowercases_and_strips_a_trailing_dot() {
539        assert_eq!(normalize(" Host.Example.COM. "), "host.example.com");
540        assert_eq!(normalize("*.Example.com"), "*.example.com");
541    }
542
543    // -------------------------------------------------------- field_values
544
545    #[test]
546    fn a_custom_field_may_be_a_string_or_a_list() {
547        let fields: Map<String, Value> = serde_json::from_value(json!({
548            "one": "a.example.com",
549            "many": ["a.example.com", "b.example.com"],
550        }))
551        .unwrap();
552
553        assert_eq!(field_values(&fields, "one", "NetBox", "address").len(), 1);
554        assert_eq!(field_values(&fields, "many", "NetBox", "address").len(), 2);
555    }
556
557    /// A field of the wrong type contributes nothing and is not fatal — the
558    /// names simply do not come from there.
559    #[test]
560    fn an_unusable_custom_field_contributes_nothing() {
561        let fields: Map<String, Value> = serde_json::from_value(json!({
562            "absent": Value::Null,
563            "number": 7,
564            "object": {"a": 1},
565            "mixed": ["a.example.com", 7, {"b": 2}],
566        }))
567        .unwrap();
568
569        assert!(field_values(&fields, "missing", "NetBox", "address").is_empty());
570        assert!(field_values(&fields, "absent", "NetBox", "address").is_empty());
571        assert!(field_values(&fields, "number", "NetBox", "address").is_empty());
572        assert!(field_values(&fields, "object", "NetBox", "address").is_empty());
573        assert_eq!(field_values(&fields, "mixed", "NetBox", "address").len(), 1);
574    }
575
576    #[test]
577    fn value_kind_names_every_json_type() {
578        assert_eq!(value_kind(&Value::Null), "null");
579        assert_eq!(value_kind(&json!(true)), "bool");
580        assert_eq!(value_kind(&json!(1)), "number");
581        assert_eq!(value_kind(&json!("s")), "string");
582        assert_eq!(value_kind(&json!([])), "array");
583        assert_eq!(value_kind(&json!({})), "object");
584    }
585
586    // ---------------------------------------------------------- from_config
587
588    #[test]
589    fn no_backend_builds_nothing() {
590        let cfg = IpamConfig::default();
591        assert!(
592            from_config(&cfg, crate::testutil::outbound_with(resolver()))
593                .unwrap()
594                .is_none()
595        );
596    }
597
598    #[test]
599    fn each_backend_builds() {
600        let netbox = from_config(
601            &IpamConfig {
602                backend: "netbox".to_string(),
603                netbox: crate::config::NetboxConfig {
604                    url: "https://netbox.example.com".to_string(),
605                    token: "t0ken".to_string(),
606                    ..crate::config::NetboxConfig::default()
607                },
608                ..IpamConfig::default()
609            },
610            crate::testutil::outbound_with(resolver()),
611        )
612        .unwrap()
613        .unwrap();
614        assert_eq!(netbox.backend_name(), "NetBox");
615
616        let phpipam = from_config(
617            &IpamConfig {
618                backend: "phpipam".to_string(),
619                phpipam: crate::config::PhpIpamConfig {
620                    url: "https://ipam.example.com".to_string(),
621                    token: "t0ken".to_string(),
622                    ..crate::config::PhpIpamConfig::default()
623                },
624                ..IpamConfig::default()
625            },
626            crate::testutil::outbound_with(resolver()),
627        )
628        .unwrap()
629        .unwrap();
630        assert_eq!(phpipam.backend_name(), "phpIPAM");
631
632        let dir = crate::testutil::TempDir::new("ipam-from-config");
633        let script = crate::testutil::write_script(&dir, "ipam.sh", "#!/bin/sh\nexit 3\n");
634        let custom = from_config(
635            &IpamConfig {
636                backend: "custom".to_string(),
637                custom: crate::config::CustomIpamConfig {
638                    script_path: script.display().to_string(),
639                    ..crate::config::CustomIpamConfig::default()
640                },
641                ..IpamConfig::default()
642            },
643            crate::testutil::outbound_with(resolver()),
644        )
645        .unwrap()
646        .unwrap();
647        assert_eq!(custom.backend_name(), "the custom IPAM script");
648    }
649
650    /// The `custom` backend's own required field, refused here the way each
651    /// other backend's `url`/`token` is — the selector and the section it
652    /// selects have to agree before anything serves.
653    #[test]
654    fn a_custom_backend_with_no_script_is_a_startup_error() {
655        let cfg = IpamConfig {
656            backend: "custom".to_string(),
657            ..IpamConfig::default()
658        };
659        let error = from_config(&cfg, crate::testutil::outbound_with(resolver()))
660            .unwrap_err()
661            .to_string();
662        assert!(error.contains("ipam.custom.script_path"), "{error}");
663    }
664
665    #[test]
666    fn an_unknown_backend_is_a_startup_error_naming_the_valid_ones() {
667        let cfg = IpamConfig {
668            backend: "racktables".to_string(),
669            ..IpamConfig::default()
670        };
671        let error = from_config(&cfg, crate::testutil::outbound_with(resolver()))
672            .unwrap_err()
673            .to_string();
674        assert!(error.contains("racktables"), "{error}");
675        assert!(error.contains("netbox"), "{error}");
676        assert!(error.contains("phpipam"), "{error}");
677        assert!(error.contains("custom"), "{error}");
678    }
679
680    // ------------------------------------------------------------ registry
681
682    struct Hanging;
683
684    #[async_trait]
685    impl Ipam for Hanging {
686        fn name(&self) -> &'static str {
687            "Hanging"
688        }
689        async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
690            tokio::time::sleep(Duration::from_secs(3600)).await;
691            unreachable!("the registry's budget expires first")
692        }
693    }
694
695    struct Answering;
696
697    #[async_trait]
698    impl Ipam for Answering {
699        fn name(&self) -> &'static str {
700            "Answering"
701        }
702        async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
703            let mut names = AddressNames::known();
704            names.insert("a.example.com");
705            Ok(names)
706        }
707    }
708
709    /// The whole reason the budget lives on the registry: a backend that never
710    /// answers cannot pin a request — and the SQLite connection behind it —
711    /// open for as long as it likes.
712    #[tokio::test]
713    async fn the_registry_applies_the_budget() {
714        let registry = IpamRegistry::new(Arc::new(Hanging), Duration::from_millis(10));
715        let error = registry
716            .names_for("10.0.0.5".parse().unwrap())
717            .await
718            .unwrap_err();
719        assert!(error.0.contains("timed out after 10ms"), "{error}");
720        assert!(error.0.contains("Hanging"), "{error}");
721    }
722
723    #[tokio::test]
724    async fn a_prompt_backend_answers_through_the_registry() {
725        let registry = IpamRegistry::new(Arc::new(Answering), Duration::from_secs(5));
726        let names = registry
727            .names_for("10.0.0.5".parse().unwrap())
728            .await
729            .unwrap();
730        assert!(names.names().contains("a.example.com"));
731        assert_eq!(registry.backend_name(), "Answering");
732        assert!(format!("{registry:?}").contains("Answering"));
733    }
734}