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