Skip to main content

acme_proxy/config/
mod.rs

1//! ACME Proxy Configuration Management
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::Deserialize;
6
7pub mod types;
8pub use types::*;
9
10/// Runtime configuration for the ACME proxy server.
11///
12/// The eight sections a profile can carry (`signer`, `filter`, `ipam`,
13/// `challenge`, `eab`, `order`, `notify`, `meta`) are kept here as the **base
14/// every profile inherits**; nothing serves them directly. The rest (`database`, `server`,
15/// `admin`, `nonce`, `audit`, `jobs`, `logging`, `dns`, `proxy`) is process-wide and has no
16/// per-profile form — an operator of the web admin manages every endpoint this process
17/// serves, so `admin` in particular has no per-profile meaning, `audit`
18/// records one trail for the whole CA, and `jobs` drains one queue.
19#[derive(Debug, Clone, Default, Deserialize)]
20#[serde(default)]
21pub struct Config {
22    pub database: DatabaseConfig,
23    pub server: ServerConfig,
24    /// The web admin listener. Process-wide, so deliberately absent from
25    /// [`PROFILE_SECTIONS`].
26    pub admin: AdminConfig,
27    pub nonce: NonceConfig,
28    /// Traceability and the CA's audit trail. Process-wide for the reason
29    /// [`AuditConfig`] gives, so also absent from [`PROFILE_SECTIONS`].
30    pub audit: AuditConfig,
31    /// The durable background-work queue. Process-wide for the reason
32    /// [`JobsConfig`] gives, so also absent from [`PROFILE_SECTIONS`].
33    pub jobs: JobsConfig,
34    /// The Prometheus exposition endpoint. Process-wide for the reason
35    /// [`MetricsConfig`] gives, so also absent from [`PROFILE_SECTIONS`].
36    pub metrics: MetricsConfig,
37    pub logging: LoggingConfig,
38    pub order: OrderConfig,
39    pub signer: SignerConfig,
40    pub challenge: ChallengeConfig,
41    pub filter: FilterConfig,
42    /// The inventory the `ipam` filter consults. Per-profile, so two endpoints
43    /// may consult different ones — and read by nothing unless that filter is
44    /// enabled.
45    pub ipam: IpamConfig,
46    pub eab: EabConfig,
47    pub notify: NotifyConfig,
48    pub meta: MetaConfig,
49    pub dns: DnsConfig,
50    /// The forward proxy every outbound client dials through. Process-wide for
51    /// the reason [`ProxyConfig`] gives, so also absent from
52    /// [`PROFILE_SECTIONS`].
53    pub proxy: ProxyConfig,
54    /// The configuration sources as they were read, *before* serde filled in
55    /// any default — the only form in which "unset" and "set to the default
56    /// value" can still be told apart, which is what per-key inheritance
57    /// needs. Populated by [`Config::load`]; `None` for a `Config` built in
58    /// code (tests, `Config::default`), which simply has no profiles to
59    /// resolve.
60    #[serde(skip)]
61    raw: Option<::config::Config>,
62}
63
64/// The sections a profile may override, in the order they are documented.
65const PROFILE_SECTIONS: &[&str] = &[
66    "signer",
67    "filter",
68    "ipam",
69    "challenge",
70    "eab",
71    "order",
72    "notify",
73    "meta",
74];
75
76/// Whether `name` is safe to use as both a TOML table key *and* an
77/// environment-variable segment (`ACME_PROXY_..._<NAME>_...`) naming the same
78/// entry: `_` would collide with the `__` nesting separator, and the `config`
79/// crate lowercases environment keys, so anything outside this set could name
80/// one entry in a file and a silently different one through the environment.
81///
82/// Used for profile names (`[profiles.<name>]` / `ACME_PROXY_PROFILES__<NAME>__…`),
83/// `filter.custom` entry names (`[filter.custom.<name>]` /
84/// `ACME_PROXY_FILTER__CUSTOM__<NAME>__…`), and `notify.custom` entry names
85/// (`[notify.custom.<name>]` / `ACME_PROXY_NOTIFY__CUSTOM__<NAME>__…`) —
86/// anywhere a config table is keyed by an operator-chosen name rather than a
87/// fixed field.
88pub(crate) fn valid_config_key_name(name: &str) -> bool {
89    !name.is_empty()
90        && name
91            .chars()
92            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
93}
94
95/// Resolves a selection list against the table of named entries it selects
96/// from, validating both halves.
97///
98/// Three tables have this shape now — `notify.custom`, `notify.webhook`, and
99/// `filter` grew it independently before its redesign — and they must not
100/// drift, because the name rule in particular carries reasoning that is not
101/// obvious from the code: an entry's name is also an environment-variable
102/// segment, which the `config` crate lowercases, so anything outside the
103/// permitted set could name one entry in a file and a silently different one
104/// through the environment.
105///
106/// `table` is the entries' own path (`"notify.custom"`, `"notify.webhook"`) and
107/// `enabled_key` the key selecting from it; both are used only to word the
108/// errors, so an operator is told which key to go and look at. `backend` is the
109/// value in `<subsystem>.enabled` that turned the table on.
110pub(crate) fn resolve_named_entries<'a, T>(
111    table: &str,
112    enabled_key: &str,
113    backend: &str,
114    entries: &'a BTreeMap<String, T>,
115    enabled: &'a [String],
116) -> anyhow::Result<Vec<(&'a str, &'a T)>> {
117    validate_key_names(table, entries.keys())?;
118    let subsystem = table.split('.').next().unwrap_or(table);
119    anyhow::ensure!(
120        !enabled.is_empty(),
121        "{table} is enabled but {enabled_key} is empty; \
122         list the [{table}.<name>] entries to use, or remove `{backend}` from \
123         {subsystem}.enabled"
124    );
125
126    enabled
127        .iter()
128        .map(|name| {
129            let entry = entries.get(name).ok_or_else(|| {
130                anyhow::anyhow!(
131                    "{enabled_key} names `{name}`, but no [{table}.{name}] is configured"
132                )
133            })?;
134            Ok((name.as_str(), entry))
135        })
136        .collect()
137}
138
139/// Checks every key of an operator-named config table, naming the offender.
140///
141/// `prefix` is the table's path (`filter.check`, `notify.custom`), used only to
142/// word the error so an operator is told which key to go and look at. The
143/// reasoning in that error is the part worth stating once rather than three
144/// times: a table key is also an environment-variable segment, and the `config`
145/// crate lowercases those, so anything outside the permitted set could name one
146/// entry in a file and a silently different one through the environment.
147pub(crate) fn validate_key_names<'a>(
148    prefix: &str,
149    keys: impl Iterator<Item = &'a String>,
150) -> anyhow::Result<()> {
151    let env_prefix = prefix.to_ascii_uppercase().replace('.', "__");
152    for key in keys {
153        anyhow::ensure!(
154            valid_config_key_name(key),
155            "{prefix}.{key}: invalid name (use lowercase letters, digits and `-` — the \
156             name is also an environment variable segment, and the config crate \
157             lowercases those, so anything else could silently name a different entry \
158             through ACME_PROXY_{env_prefix}__… than in the file)"
159        );
160    }
161    Ok(())
162}
163
164/// Profile names are URL segments (`/profile/<name>`) as well as config-key
165/// names; see [`valid_config_key_name`].
166fn valid_profile_name(name: &str) -> bool {
167    valid_config_key_name(name)
168}
169
170/// The list-valued fields of `[filter.check.<name>]`.
171///
172/// Separate from [`LIST_KEYS`] because the entry name is only known at runtime,
173/// so these are registered by scanning the environment rather than by literal.
174/// A new list field on `CheckConfig` needs an entry here or its environment
175/// variable is silently dropped.
176const CHECK_LIST_KEYS: &[&str] = &[
177    "stages",
178    "allow",
179    "deny",
180    "allow_regex",
181    "deny_regex",
182    "allowed_types",
183    "kids",
184    "args",
185];
186
187const LIST_KEYS: &[&str] = &[
188    "challenge.enabled",
189    "filter.rules",
190    "filter.trusted_proxies",
191    // Removed keys. Registered so they still parse from the environment,
192    // which is what lets `filter::build` refuse them by name there as well as
193    // in a file — unregistered, they would fail as an opaque serde type error
194    // instead.
195    "filter.enabled",
196    "filter.exempt_paths",
197    "filter.custom_enabled",
198    "ipam.netbox.sources",
199    "ipam.netbox.vip_roles",
200    "ipam.phpipam.sources",
201    "signer.relay.contact",
202    "signer.custom.args",
203    "signer.local_ca.crl_distribution_points",
204    "signer.local_ca.ca_issuer_urls",
205    "notify.enabled",
206    "notify.email.to",
207    "notify.email.events",
208    "notify.webhook_enabled",
209    "notify.custom_enabled",
210    "meta.caa_identities",
211    "proxy.no_proxy",
212];
213
214impl Config {
215    #[cfg(test)]
216    fn list_key(&self, key: &str) -> Option<Vec<String>> {
217        let value = match key {
218            "challenge.enabled" => &self.challenge.enabled,
219            "filter.rules" => &self.filter.rules,
220            "filter.trusted_proxies" => &self.filter.trusted_proxies,
221            "filter.enabled" => &self.filter.enabled,
222            "filter.exempt_paths" => &self.filter.exempt_paths,
223            "filter.custom_enabled" => &self.filter.custom_enabled,
224            "ipam.netbox.sources" => &self.ipam.netbox.sources,
225            "ipam.netbox.vip_roles" => &self.ipam.netbox.vip_roles,
226            "ipam.phpipam.sources" => &self.ipam.phpipam.sources,
227            "signer.relay.contact" => &self.signer.relay.contact,
228            "signer.custom.args" => &self.signer.custom.args,
229            "signer.local_ca.crl_distribution_points" => {
230                &self.signer.local_ca.crl_distribution_points
231            }
232            "signer.local_ca.ca_issuer_urls" => &self.signer.local_ca.ca_issuer_urls,
233            "notify.enabled" => &self.notify.enabled,
234            "notify.email.to" => &self.notify.email.to,
235            "notify.email.events" => &self.notify.email.events,
236            "notify.webhook_enabled" => &self.notify.webhook_enabled,
237            "notify.custom_enabled" => &self.notify.custom_enabled,
238            "meta.caa_identities" => &self.meta.caa_identities,
239            "proxy.no_proxy" => &self.proxy.no_proxy,
240            _ => return None,
241        };
242        Some(value.clone())
243    }
244
245    /// Loads configuration from defaults, TOML file, and environment variables.
246    pub fn load() -> Result<Self, ::config::ConfigError> {
247        let path = std::env::var("ACME_PROXY_CONFIG").unwrap_or_else(|_| "config".into());
248
249        let mut environment = ::config::Environment::with_prefix("ACME_PROXY")
250            .prefix_separator("_")
251            .separator("__")
252            .try_parsing(true)
253            .list_separator(",");
254        // Every list-valued key, both globally and inside each profile the
255        // environment mentions. `with_list_parse_key` takes a *literal* key,
256        // and a profile name is only known at runtime — so the names are
257        // scanned for first. Without this, `ACME_PROXY_PROFILES__LE__
258        // CHALLENGE__ENABLED` would be silently dropped, the same trap the
259        // global `LIST_KEYS` registry exists for.
260        let profiles_in_env = profile_names_in_env();
261        for key in LIST_KEYS {
262            environment = environment.with_list_parse_key(key);
263            for name in &profiles_in_env {
264                environment = environment.with_list_parse_key(&format!("profiles.{name}.{key}"));
265            }
266        }
267        // One level deeper: three sections are tables keyed by a name only
268        // known at runtime, each with list-valued fields of its own. Same
269        // reasoning as the profile-scoped loop above (and as `profiles_in_env`
270        // itself) — without registration every one of these is silently
271        // *dropped* from the environment rather than refused, which is the one
272        // failure mode a configuration bug should never have.
273        //
274        // Both scopes of each are registered by walking `None` (global) and
275        // then each profile: the two used to be written out separately, four
276        // times over, each copy repeating the same comment.
277        //
278        // (`notify.webhook.<name>.headers` is a map rather than a list, so it
279        // needs no entry — `config` nests it from `…__HEADERS__<NAME>` without
280        // help.)
281        const NAMED_TABLES: &[(&str, &[&str])] = &[
282            ("filter.check", CHECK_LIST_KEYS),
283            ("notify.custom", &["args", "events"]),
284            ("notify.webhook", &["events"]),
285        ];
286        let scopes = std::iter::once(None).chain(profiles_in_env.iter().map(Some));
287        for profile in scopes {
288            for (section, keys) in NAMED_TABLES {
289                let (env_prefix, key_prefix) = match profile {
290                    None => (
291                        format!("ACME_PROXY_{}__", env_segment(section)),
292                        (*section).to_string(),
293                    ),
294                    Some(name) => (
295                        format!(
296                            "ACME_PROXY_PROFILES__{}__{}__",
297                            name.to_ascii_uppercase(),
298                            env_segment(section)
299                        ),
300                        format!("profiles.{name}.{section}"),
301                    ),
302                };
303                for entry in names_in_env(&env_prefix) {
304                    for key in *keys {
305                        environment =
306                            environment.with_list_parse_key(&format!("{key_prefix}.{entry}.{key}"));
307                    }
308                }
309            }
310        }
311
312        let built = ::config::Config::builder()
313            .add_source(::config::File::with_name(&path).required(false))
314            .add_source(environment)
315            .build()?;
316
317        let mut config: Config = built.clone().try_deserialize()?;
318        config.raw = Some(built);
319        Ok(config)
320    }
321
322    /// The profiles this configuration mounts, each fully populated: what the
323    /// profile states, over what the matching global section states, over the
324    /// compiled defaults — resolved key by key, not section by section, so a
325    /// profile changing one knob keeps the rest of the global section.
326    ///
327    /// Fails when nothing is left to serve: profiles are the only way to serve
328    /// ACME at all, and a server that silently answers nothing would be worse
329    /// than one that refuses to start.
330    pub fn resolve_profiles(&self) -> anyhow::Result<Vec<ProfileConfig>> {
331        let raw_profiles = self
332            .raw
333            .as_ref()
334            .and_then(|raw| raw.get::<::config::Value>("profiles").ok())
335            .map(as_table)
336            .unwrap_or_default();
337
338        let mut profiles = Vec::new();
339        // A `BTreeMap` rather than the source's own ordering: mount order, log
340        // order and error messages must not depend on how a file was written.
341        for (name, raw_profile) in raw_profiles.into_iter().collect::<BTreeMap<_, _>>() {
342            anyhow::ensure!(
343                valid_profile_name(&name),
344                "invalid profile name `{name}`: use lowercase letters, digits and `-` \
345                 (the name is both a URL segment and an environment variable segment)"
346            );
347
348            let sections = self.merged_sections(&raw_profile).map_err(|error| {
349                anyhow::anyhow!("profile `{name}`: invalid configuration: {error}")
350            })?;
351
352            if sections.enabled {
353                profiles.push(ProfileConfig { name, sections });
354            }
355        }
356
357        anyhow::ensure!(!profiles.is_empty(), self.no_profiles_message());
358        Ok(profiles)
359    }
360
361    /// Deserializes one profile, each of its sections overlaid on the global
362    /// one first. Both sides are the *raw* values, so a key nobody wrote falls
363    /// through to serde's own default rather than to a default masquerading as
364    /// a global setting.
365    fn merged_sections(
366        &self,
367        raw_profile: &::config::Value,
368    ) -> Result<ProfileSections, ::config::ConfigError> {
369        let profile_table = as_table(raw_profile.clone());
370        let mut merged = profile_table.clone();
371
372        for section in PROFILE_SECTIONS {
373            let global = self
374                .raw
375                .as_ref()
376                .and_then(|raw| raw.get::<::config::Value>(section).ok());
377            let overlay = profile_table.get(*section).cloned();
378
379            match (global, overlay) {
380                (Some(global), Some(overlay)) => {
381                    merged.insert((*section).to_string(), merge_values(&global, &overlay));
382                }
383                (Some(global), None) => {
384                    merged.insert((*section).to_string(), global);
385                }
386                // Nothing written anywhere: leave the key out and let the
387                // section's own `#[serde(default)]` fill it in.
388                (None, _) => {}
389            }
390        }
391
392        ProfileSections::deserialize(::config::Value::new(
393            None,
394            ::config::ValueKind::Table(merged),
395        ))
396    }
397
398    /// The startup error for a configuration that mounts nothing — written to
399    /// be copy-pasteable, since "no profiles" is what every first run hits.
400    fn no_profiles_message(&self) -> String {
401        format!(
402            "no enabled [profiles] — acme-proxy serves nothing without one. Minimal config:\n\
403             \n    [profiles.default]\n\n\
404             Its ACME directory is then at {}/profile/default/directory.",
405            self.server.base_url
406        )
407    }
408}
409
410/// A dotted configuration section as its `ACME_PROXY_*` spelling:
411/// `filter.check` becomes `FILTER__CHECK`.
412///
413/// The two spellings of every section used to be written out by hand at each
414/// registration site, which is exactly where one of them goes stale.
415fn env_segment(section: &str) -> String {
416    section.to_ascii_uppercase().replace('.', "__")
417}
418
419// The first `__`-delimited segment after `prefix`, for every environment
420/// variable that starts with it — lowercased, matching what the `config`
421/// crate does to environment keys, so `…__LE__…` and a `[profiles.le]` table
422/// (or `…__CUSTOM__MAIN__…` and a `[filter.custom.main]` table) name the same
423/// entry.
424fn names_in_env(prefix: &str) -> BTreeSet<String> {
425    std::env::vars()
426        .filter_map(|(key, _)| {
427            let rest = key.strip_prefix(prefix)?;
428            let name = rest.split("__").next()?;
429            (!name.is_empty()).then(|| name.to_ascii_lowercase())
430        })
431        .collect()
432}
433
434/// Profile names mentioned by `ACME_PROXY_PROFILES__<NAME>__…` variables.
435fn profile_names_in_env() -> BTreeSet<String> {
436    names_in_env("ACME_PROXY_PROFILES__")
437}
438
439/// A value's table, or an empty one for anything else (including absent).
440fn as_table(value: ::config::Value) -> ::config::Map<String, ::config::Value> {
441    match value.kind {
442        ::config::ValueKind::Table(table) => table,
443        _ => ::config::Map::new(),
444    }
445}
446
447/// Overlays `overlay` on `base`, recursing into tables.
448///
449/// Scalars **and arrays** are replaced wholesale: an inherited list a profile
450/// could only ever extend (never shorten) would be a trap in a `deny` list.
451fn merge_values(base: &::config::Value, overlay: &::config::Value) -> ::config::Value {
452    match (&base.kind, &overlay.kind) {
453        (::config::ValueKind::Table(base), ::config::ValueKind::Table(overlay)) => {
454            let mut merged = base.clone();
455            for (key, value) in overlay {
456                let merged_value = match merged.get(key) {
457                    Some(existing) => merge_values(existing, value),
458                    None => value.clone(),
459                };
460                merged.insert(key.clone(), merged_value);
461            }
462            ::config::Value::new(None, ::config::ValueKind::Table(merged))
463        }
464        _ => overlay.clone(),
465    }
466}
467
468/// Serialises every test that reads or writes the process environment.
469///
470/// `Config::load` consults `ACME_PROXY_*` and `ACME_PROXY_CONFIG`, which are
471/// process-wide: a test setting one while another is loading a configuration
472/// makes the second read the first's variables. One lock for the whole crate,
473/// not one per module — three independent locks serialise a module against
474/// itself and against nothing else, which is the same as no lock at all.
475#[cfg(test)]
476pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::testutil::EnvGuard;
482
483    /// A throwaway directory holding one `config.toml`, removed on drop.
484    ///
485    /// Profile resolution reads the *raw* configuration sources, so it can only
486    /// be exercised through `Config::load()` — a `Config` built in code has no
487    /// sources to merge and therefore no profiles at all.
488    struct TempConfig {
489        dir: crate::testutil::TempDir,
490    }
491
492    impl TempConfig {
493        fn new(body: &str) -> Self {
494            let dir = crate::testutil::TempDir::new("cfg");
495            dir.write("config.toml", body);
496            Self { dir }
497        }
498
499        fn path(&self) -> String {
500            self.dir.join("config").to_string_lossy().into_owned()
501        }
502    }
503
504    /// Loads `body` as the whole configuration file.
505    fn load_toml(body: &str) -> Config {
506        let file = TempConfig::new(body);
507        let path = file.path();
508        let _guard = EnvGuard::new(&[("ACME_PROXY_CONFIG", &path)]);
509        Config::load().expect("the configuration must load")
510    }
511
512    #[test]
513    fn a_bare_profile_table_inherits_every_global_section() {
514        let config = load_toml(
515            r#"
516            [challenge]
517            enabled = ["dns-01"]
518            bypass = false
519
520            [profiles.le]
521            "#,
522        );
523
524        let profiles = config.resolve_profiles().unwrap();
525        assert_eq!(profiles.len(), 1);
526        assert_eq!(profiles[0].name, "le");
527        assert_eq!(profiles[0].sections.challenge.enabled, vec!["dns-01"]);
528        assert!(!profiles[0].sections.challenge.bypass);
529        // Untouched globally *and* by the profile: the compiled default.
530        assert_eq!(profiles[0].sections.signer.backend, "local_ca");
531    }
532
533    /// The trap section-level inheritance would fall into: a profile that
534    /// overrides one knob of a section must keep the rest of the **global**
535    /// section, not silently fall back to the compiled defaults.
536    #[test]
537    fn overriding_one_key_keeps_the_rest_of_the_global_section() {
538        let config = load_toml(
539            r#"
540            [challenge]
541            enabled = ["dns-01"]
542            bypass = true
543            timeout_ms = 1234
544
545            [profiles.strict]
546            challenge.bypass = false
547            "#,
548        );
549
550        let profiles = config.resolve_profiles().unwrap();
551        let challenge = &profiles[0].sections.challenge;
552        assert!(!challenge.bypass, "the profile's own value wins");
553        assert_eq!(
554            challenge.enabled,
555            vec!["dns-01"],
556            "the rest of the section is inherited, not reset to the default"
557        );
558        assert_eq!(challenge.timeout_ms, 1234);
559    }
560
561    /// `ipam` is per-profile, which is the whole reason it is a section rather
562    /// than a process-wide one: two endpoints of the same server may consult
563    /// different inventories, and each keeps the rest of the global section.
564    #[test]
565    fn two_profiles_may_name_different_inventories() {
566        let config = load_toml(
567            r#"
568            [ipam]
569            backend = "netbox"
570            timeout_ms = 1234
571
572            [ipam.netbox]
573            url = "https://netbox.example.com"
574            token = "t0ken"
575
576            [ipam.phpipam]
577            url = "https://ipam.example.com"
578            token = "appcode"
579
580            [profiles.dmz]
581
582            [profiles.internal]
583            ipam.backend = "phpipam"
584            "#,
585        );
586
587        let profiles = config.resolve_profiles().unwrap();
588        let by_name = |name: &str| {
589            profiles
590                .iter()
591                .find(|p| p.name == name)
592                .map(|p| &p.sections.ipam)
593                .unwrap()
594        };
595
596        assert_eq!(by_name("dmz").backend, "netbox");
597        assert_eq!(by_name("internal").backend, "phpipam");
598        // …and overriding the one key keeps the rest of the global section,
599        // both the sibling tables and the budget.
600        assert_eq!(by_name("internal").timeout_ms, 1234);
601        assert_eq!(by_name("internal").phpipam.url, "https://ipam.example.com");
602        assert_eq!(by_name("internal").netbox.url, "https://netbox.example.com");
603    }
604
605    /// A profile may narrow what its inventory is trusted for without
606    /// restating the connection details — the per-key inheritance rule applied
607    /// to the one list that decides how much an address may claim.
608    #[test]
609    fn a_profile_may_narrow_the_ipam_sources_alone() {
610        let config = load_toml(
611            r#"
612            [ipam]
613            backend = "netbox"
614
615            [ipam.netbox]
616            url = "https://netbox.example.com"
617            token = "t0ken"
618            sources = ["dns_name", "custom_field", "device", "fhrp"]
619
620            [profiles.strict]
621            ipam.netbox.sources = ["dns_name"]
622            "#,
623        );
624
625        let netbox = &config.resolve_profiles().unwrap()[0].sections.ipam.netbox;
626        assert_eq!(netbox.sources, vec!["dns_name"]);
627        assert_eq!(netbox.url, "https://netbox.example.com");
628        assert_eq!(netbox.token, "t0ken");
629    }
630
631    /// `notify` joins `PROFILE_SECTIONS` like every other subsystem: a profile
632    /// overriding one knob keeps the rest of the *global* `[notify]` section
633    /// rather than resetting to compiled defaults.
634    #[test]
635    fn a_profile_can_override_one_notify_key_and_keep_the_rest() {
636        let config = load_toml(
637            r#"
638            [notify]
639            enabled = ["email"]
640            email.smtp_host = "mail.example.com"
641            email.smtp_port = 2525
642
643            [profiles.staging]
644            notify.email.smtp_host = "mail.staging.example.com"
645            "#,
646        );
647
648        let profiles = config.resolve_profiles().unwrap();
649        let notify = &profiles[0].sections.notify;
650        assert_eq!(notify.enabled, vec!["email"], "inherited from global");
651        assert_eq!(notify.email.smtp_host, "mail.staging.example.com");
652        assert_eq!(
653            notify.email.smtp_port, 2525,
654            "the rest of the section is inherited, not reset to the default"
655        );
656    }
657
658    #[test]
659    fn a_profile_section_replaces_an_inherited_list_wholesale() {
660        let config = load_toml(
661            r#"
662            [filter]
663            check.names.deny = ["a.example", "b.example"]
664
665            [profiles.narrow]
666            filter.check.names.deny = ["c.example"]
667            "#,
668        );
669
670        let profiles = config.resolve_profiles().unwrap();
671        assert_eq!(
672            profiles[0].sections.filter.check["names"].deny,
673            vec!["c.example"],
674            "arrays are replaced, never merged"
675        );
676    }
677
678    /// The other half of the inheritance promise, and the reason
679    /// `[filter.rule.<name>]` is a map rather than an array of tables: a
680    /// profile overrides one field of one rule and inherits the rest, which an
681    /// array could not express at all.
682    #[test]
683    fn a_profile_overrides_one_field_of_an_inherited_rule() {
684        let config = load_toml(
685            r#"
686            [filter]
687            rules = ["inventory"]
688            rule.inventory.when = "inv"
689            rule.inventory.then = "allow"
690            rule.inventory.message = "not yours"
691
692            [profiles.staging]
693            filter.rule.inventory.mode = "warn"
694            "#,
695        );
696
697        let rule = &config.resolve_profiles().unwrap()[0].sections.filter.rule["inventory"];
698        assert_eq!(rule.mode, "warn");
699        assert_eq!(rule.when, "inv", "the condition is inherited");
700        assert_eq!(rule.message, "not yours", "so is the message");
701    }
702
703    #[test]
704    fn profiles_are_resolved_in_name_order() {
705        let config = load_toml(
706            r#"
707            [profiles.zulu]
708            [profiles.alpha]
709            [profiles.mike]
710            "#,
711        );
712
713        let names: Vec<_> = config
714            .resolve_profiles()
715            .unwrap()
716            .into_iter()
717            .map(|p| p.name)
718            .collect();
719        assert_eq!(names, vec!["alpha", "mike", "zulu"]);
720    }
721
722    #[test]
723    fn a_disabled_profile_is_not_mounted() {
724        let config = load_toml(
725            r#"
726            [profiles.live]
727
728            [profiles.parked]
729            enabled = false
730            "#,
731        );
732
733        let names: Vec<_> = config
734            .resolve_profiles()
735            .unwrap()
736            .into_iter()
737            .map(|p| p.name)
738            .collect();
739        assert_eq!(names, vec!["live"]);
740    }
741
742    #[test]
743    fn a_configuration_with_no_profile_refuses_to_resolve() {
744        for body in ["", "[profiles]\n", "[profiles.parked]\nenabled = false\n"] {
745            let config = load_toml(body);
746            let error = config
747                .resolve_profiles()
748                .expect_err("a server with no endpoint must not start")
749                .to_string();
750            assert!(error.contains("[profiles.default]"), "{error}");
751            assert!(
752                error.contains("/profile/default/directory"),
753                "the error must show where the endpoint would answer: {error}"
754            );
755        }
756    }
757
758    #[test]
759    fn a_profile_name_outside_the_url_charset_is_refused() {
760        for name in ["Le", "my_profile", "we.b"] {
761            let config = load_toml(&format!("[profiles.\"{name}\"]\n"));
762            let error = config
763                .resolve_profiles()
764                .expect_err("{name} must be refused")
765                .to_string();
766            assert!(error.contains("invalid profile name"), "{error}");
767        }
768    }
769
770    /// A list-valued key inside a profile only survives the environment if its
771    /// *runtime* key was registered for list parsing — the whole reason
772    /// `Config::load` scans for profile names before building the sources.
773    #[test]
774    fn a_profile_list_key_round_trips_through_the_environment() {
775        let file = TempConfig::new("[profiles.le]\n");
776        let path = file.path();
777        let _guard = EnvGuard::new(&[
778            ("ACME_PROXY_CONFIG", &path),
779            (
780                "ACME_PROXY_PROFILES__LE__CHALLENGE__ENABLED",
781                "dns-01,http-01",
782            ),
783        ]);
784
785        let config = Config::load().unwrap();
786        let profiles = config.resolve_profiles().unwrap();
787        assert_eq!(
788            profiles[0].sections.challenge.enabled,
789            vec!["dns-01".to_string(), "http-01".to_string()]
790        );
791    }
792
793    /// `[admin]` is a new top-level section, so this pins that the whole
794    /// `ACME_PROXY_ADMIN__…` family actually reaches it — the trap being that
795    /// `config`'s `Environment` reuses the nested `separator` as the prefix
796    /// separator unless `prefix_separator("_")` is set, which would drop every
797    /// one of these silently.
798    ///
799    /// `ENABLED` alone is the key that matters: it is what an environment-only
800    /// deployment sets to turn the panel on at all.
801    #[test]
802    fn the_admin_section_round_trips_through_the_environment() {
803        let _guard = EnvGuard::new(&[
804            ("ACME_PROXY_ADMIN__ENABLED", "true"),
805            ("ACME_PROXY_ADMIN__BIND_ADDRESS", "127.0.0.1:9999"),
806            ("ACME_PROXY_ADMIN__BASE_URL", "https://admin.example.com"),
807            ("ACME_PROXY_ADMIN__SESSION_TTL_SECONDS", "60"),
808            ("ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS", "1"),
809            ("ACME_PROXY_ADMIN__REQUIRE_MFA", "true"),
810            ("ACME_PROXY_ADMIN__PAGE_SIZE_MAX", "10"),
811            ("ACME_PROXY_ADMIN__TLS__ENABLED", "true"),
812            ("ACME_PROXY_ADMIN__TLS__CERT_PATH", "/tmp/admin.pem"),
813        ]);
814
815        let config = Config::load().unwrap();
816        assert!(config.admin.enabled);
817        assert_eq!(config.admin.bind_address, "127.0.0.1:9999");
818        assert_eq!(config.admin.base_url, "https://admin.example.com");
819        assert_eq!(config.admin.session_ttl_seconds, 60);
820        assert_eq!(config.admin.login_max_attempts, 1);
821        assert!(config.admin.require_mfa);
822        assert_eq!(config.admin.page_size_max, 10);
823        assert!(config.admin.tls.enabled);
824        assert_eq!(config.admin.tls.cert_path, "/tmp/admin.pem");
825        // Untouched keys keep their defaults rather than resetting.
826        assert_eq!(
827            config.admin.tls.key_path,
828            AdminConfig::default().tls.key_path
829        );
830        assert_eq!(
831            config.admin.session_idle_timeout_seconds,
832            AdminConfig::default().session_idle_timeout_seconds
833        );
834    }
835
836    /// The `[proxy]` section, the other one an environment-only deployment is
837    /// likely to set without a file at all.
838    ///
839    /// The `ACME_PROXY_PROXY__` prefix reads oddly and is worth pinning for
840    /// exactly that reason: the section is `proxy`, and the crate prefix is not
841    /// dropped for a section that happens to share its name.
842    #[test]
843    fn the_proxy_section_round_trips_through_the_environment() {
844        let _guard = EnvGuard::new(&[
845            (
846                "ACME_PROXY_PROXY__HTTPS_URL",
847                "http://proxy.example.com:3128",
848            ),
849            ("ACME_PROXY_PROXY__NO_PROXY", "10.0.0.0/8,.internal.example"),
850        ]);
851
852        let config = Config::load().unwrap();
853        assert_eq!(config.proxy.https_url, "http://proxy.example.com:3128");
854        assert_eq!(
855            config.proxy.no_proxy,
856            vec!["10.0.0.0/8", ".internal.example"]
857        );
858        // Untouched keys keep their defaults rather than resetting.
859        assert_eq!(config.proxy.http_url, ProxyConfig::default().http_url);
860    }
861
862    /// `[filter.check.<name>]` entries are named tables, not a list — so unlike
863    /// an ordinary `LIST_KEYS` entry, each of their list-valued fields needs
864    /// its own `with_list_parse_key` registration, keyed by a name only known
865    /// at runtime. Without that scan every one of them is silently dropped
866    /// from the environment rather than refused, which is the single most
867    /// forgettable part of this section.
868    #[test]
869    fn env_configures_multiple_named_checks_with_all_their_lists() {
870        let _guard = EnvGuard::new(&[
871            ("ACME_PROXY_FILTER__RULES", "main"),
872            ("ACME_PROXY_FILTER__CHECK__MAIN__TYPE", "custom"),
873            (
874                "ACME_PROXY_FILTER__CHECK__MAIN__SCRIPT_PATH",
875                "/path/to/one.sh",
876            ),
877            ("ACME_PROXY_FILTER__CHECK__MAIN__ARGS", "foo,bar"),
878            ("ACME_PROXY_FILTER__CHECK__MAIN__STAGES", "connection"),
879            ("ACME_PROXY_FILTER__CHECK__EXTRA__TYPE", "identifiers"),
880            (
881                "ACME_PROXY_FILTER__CHECK__EXTRA__ALLOW",
882                "*.example.com,example.com",
883            ),
884            ("ACME_PROXY_FILTER__CHECK__EXTRA__DENY_REGEX", "secret\\..*"),
885            ("ACME_PROXY_FILTER__CHECK__EXTRA__ALLOWED_TYPES", "dns"),
886            ("ACME_PROXY_FILTER__CHECK__EXTRA__KIDS", "k1,k2"),
887        ]);
888
889        let config = Config::load().expect("load should succeed");
890        let check = &config.filter.check;
891        assert_eq!(check["main"].script_path, "/path/to/one.sh");
892        assert_eq!(check["main"].args, vec!["foo", "bar"]);
893        assert_eq!(check["main"].stages, vec!["connection"]);
894        assert_eq!(check["extra"].allow, vec!["*.example.com", "example.com"]);
895        assert_eq!(check["extra"].deny_regex, vec!["secret\\..*"]);
896        assert_eq!(check["extra"].allowed_types, vec!["dns"]);
897        assert_eq!(check["extra"].kids, vec!["k1", "k2"]);
898        assert_eq!(config.filter.rules, vec!["main"]);
899    }
900
901    /// The same, scoped to one profile — proving the runtime name scan also
902    /// covers `ACME_PROXY_PROFILES__<NAME>__FILTER__CHECK__<NAME>__…`.
903    #[test]
904    fn env_configures_a_profile_scoped_named_check() {
905        let file = TempConfig::new("[profiles.le]\n");
906        let path = file.path();
907        let _guard = EnvGuard::new(&[
908            ("ACME_PROXY_CONFIG", &path),
909            ("ACME_PROXY_PROFILES__LE__FILTER__RULES", "only"),
910            (
911                "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__TYPE",
912                "custom",
913            ),
914            (
915                "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__SCRIPT_PATH",
916                "/path/to/profile.sh",
917            ),
918            (
919                "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__ARGS",
920                "a,b,c",
921            ),
922        ]);
923
924        let config = Config::load().unwrap();
925        let profiles = config.resolve_profiles().unwrap();
926        let check = &profiles[0].sections.filter.check;
927        assert_eq!(check["main"].script_path, "/path/to/profile.sh");
928        assert_eq!(check["main"].args, vec!["a", "b", "c"]);
929        assert_eq!(profiles[0].sections.filter.rules, vec!["only"]);
930    }
931
932    /// The two `[notify]` tables, scoped to a profile — the remaining corner of
933    /// the runtime-name scan.
934    ///
935    /// All six scopes (three sections × global/per-profile) go through one loop
936    /// now, where they used to be four blocks written out separately. This is
937    /// the one those blocks covered least, and the failure it guards against is
938    /// silent: an unregistered list variable is *dropped*, so `events` would
939    /// quietly revert to all six rather than being refused.
940    #[test]
941    fn env_configures_profile_scoped_notify_tables() {
942        let file = TempConfig::new("[profiles.le]\n");
943        let path = file.path();
944        let _guard = EnvGuard::new(&[
945            ("ACME_PROXY_CONFIG", &path),
946            (
947                "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__SCRIPT_PATH",
948                "/usr/local/bin/page.sh",
949            ),
950            (
951                "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__ARGS",
952                "--urgent,--team=netops",
953            ),
954            (
955                "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__EVENTS",
956                "certificate_issued,challenge_failed",
957            ),
958            (
959                "ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__URL",
960                "https://hooks.example.com/T/B/xyz",
961            ),
962            (
963                "ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__EVENTS",
964                "certificate_revoked",
965            ),
966        ]);
967
968        let config = Config::load().unwrap();
969        let profiles = config.resolve_profiles().unwrap();
970        let notify = &profiles[0].sections.notify;
971
972        let pager = &notify.custom["pager"];
973        assert_eq!(pager.script_path, "/usr/local/bin/page.sh");
974        assert_eq!(pager.args, vec!["--urgent", "--team=netops"]);
975        assert_eq!(
976            pager.events,
977            vec!["certificate_issued", "challenge_failed"],
978            "an unregistered list key is dropped, not refused"
979        );
980
981        let slack = &notify.webhook["slack"];
982        assert_eq!(slack.url, "https://hooks.example.com/T/B/xyz");
983        assert_eq!(slack.events, vec!["certificate_revoked"]);
984    }
985
986    /// `[notify.webhook.<name>]` is the third table keyed by a runtime name, so
987    /// its `events` needs the same scan-then-register treatment as
988    /// `filter.check` and `notify.custom` — without it the variable is silently
989    /// dropped rather than refused, and the entry quietly reverts to all six
990    /// events. `headers` is the counter-case: a map, which `config` nests from
991    /// `…__HEADERS__<NAME>` with no registration at all.
992    #[test]
993    fn env_configures_a_named_webhook_with_its_list_and_its_header_map() {
994        let _guard = EnvGuard::new(&[
995            ("ACME_PROXY_NOTIFY__ENABLED", "webhook"),
996            ("ACME_PROXY_NOTIFY__WEBHOOK_ENABLED", "slack,matrix"),
997            (
998                "ACME_PROXY_NOTIFY__WEBHOOK__SLACK__URL",
999                "https://hooks.slack.example/services/T/B/x",
1000            ),
1001            (
1002                "ACME_PROXY_NOTIFY__WEBHOOK__SLACK__EVENTS",
1003                "certificate_issued,certificate_revoked",
1004            ),
1005            ("ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__METHOD", "PUT"),
1006            (
1007                "ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__HEADERS__AUTHORIZATION",
1008                "Bearer syt_xxx",
1009            ),
1010        ]);
1011
1012        let config = Config::load().expect("load should succeed");
1013        assert_eq!(config.notify.webhook_enabled, vec!["slack", "matrix"]);
1014        assert_eq!(
1015            config.notify.webhook["slack"].events,
1016            vec!["certificate_issued", "certificate_revoked"]
1017        );
1018        assert_eq!(config.notify.webhook["matrix"].method, "PUT");
1019        assert_eq!(
1020            config.notify.webhook["matrix"].headers["authorization"],
1021            "Bearer syt_xxx"
1022        );
1023        // Untouched keys keep their defaults rather than resetting.
1024        assert_eq!(
1025            config.notify.webhook["slack"].method,
1026            WebhookNotifyConfig::default().method
1027        );
1028    }
1029
1030    /// The unindexed shape (`ACME_PROXY_FILTER__CHECK__TYPE`, with no name
1031    /// segment) is a clear load-time error rather than something silently
1032    /// accepted or ignored: `filter.check` is a table of *named* entries, so
1033    /// the missing name segment makes `type`'s plain string value land exactly
1034    /// where one check's whole table is expected.
1035    #[test]
1036    fn an_unindexed_check_env_shape_is_a_clear_load_error() {
1037        let _guard = EnvGuard::new(&[("ACME_PROXY_FILTER__CHECK__TYPE", "allowed_ip")]);
1038
1039        let error = Config::load().unwrap_err().to_string();
1040        assert!(error.contains("filter.check.type"), "{error}");
1041    }
1042
1043    /// An environment-only profile: no `[profiles]` table in the file at all.
1044    #[test]
1045    fn a_profile_can_be_declared_entirely_from_the_environment() {
1046        let file = TempConfig::new("[server]\nbase_url = \"http://acme.test\"\n");
1047        let path = file.path();
1048        let _guard = EnvGuard::new(&[
1049            ("ACME_PROXY_CONFIG", &path),
1050            ("ACME_PROXY_PROFILES__LE__ENABLED", "true"),
1051            ("ACME_PROXY_PROFILES__LE__CHALLENGE__BYPASS", "false"),
1052        ]);
1053
1054        let config = Config::load().unwrap();
1055        let profiles = config.resolve_profiles().unwrap();
1056        assert_eq!(profiles.len(), 1);
1057        assert_eq!(profiles[0].name, "le");
1058        assert!(!profiles[0].sections.challenge.bypass);
1059    }
1060
1061    #[test]
1062    fn default_values_match_expected() {
1063        let _guard = EnvGuard::new(&[]);
1064        let config = Config::load().expect("defaults alone must load");
1065
1066        assert_eq!(config.database.url, "sqlite://sqlite.db");
1067        assert_eq!(config.server.bind_address, "[::]:3000");
1068        assert_eq!(config.server.base_url, "http://localhost:3000");
1069        assert!(!config.server.tls.enabled);
1070        assert_eq!(config.server.tls.cert_path, "server.pem");
1071        assert_eq!(config.server.tls.key_path, "server.key");
1072        assert_eq!(config.server.tls.handshake_timeout_ms, 10_000);
1073        assert_eq!(config.nonce.ttl_seconds, 300);
1074        assert_eq!(config.jobs.poll_interval_ms, 1_000);
1075        assert_eq!(config.jobs.max_concurrent, 8);
1076        assert_eq!(config.jobs.max_attempts, 5);
1077        assert_eq!(config.jobs.retry_base_seconds, 30);
1078        assert_eq!(config.jobs.retry_max_seconds, 3_600);
1079        assert_eq!(config.jobs.lease_seconds, 300);
1080        // Non-zero unlike `audit.retention_days`: a finished job is a receipt,
1081        // not evidence.
1082        assert_eq!(config.jobs.retention_days, 7);
1083        assert_eq!(config.logging.filter, "acme_proxy=info");
1084        assert!(!config.logging.json_format);
1085        assert_eq!(config.logging.target, "stdout");
1086        assert!(config.logging.ansi);
1087        assert_eq!(config.logging.span_events, "none");
1088        assert!(!config.logging.flatten_event);
1089        assert_eq!(config.order.validity_seconds, 604800);
1090        assert_eq!(config.signer.backend, "local_ca");
1091        assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
1092        assert_eq!(config.signer.local_ca.key_path, "ca.key");
1093        assert_eq!(config.signer.local_ca.key_type, "ecdsa-p256");
1094        assert_eq!(config.signer.local_ca.leaf_validity_days, 90);
1095        assert_eq!(config.challenge.enabled, vec!["http-01".to_string()]);
1096        // A CA that issues without proving control is not a safe out-of-the-box
1097        // posture; see `ChallengeConfig::bypass`.
1098        assert!(!config.challenge.bypass);
1099        assert_eq!(config.challenge.timeout_ms, 5000);
1100        assert_eq!(config.challenge.http_01.port, 80);
1101        assert_eq!(config.challenge.http_01.https_port, 443);
1102        assert!(config.challenge.http_01.follow_redirects);
1103        assert_eq!(config.challenge.http_01.max_redirects, 5);
1104        assert_eq!(config.challenge.http_01.max_response_bytes, 4096);
1105        assert_eq!(config.challenge.tls_alpn_01.port, 443);
1106        assert!(config.filter.rules.is_empty());
1107        assert_eq!(config.filter.default, "deny");
1108        assert!(config.filter.trusted_proxies.is_empty());
1109        assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
1110        assert!(config.filter.rule.is_empty());
1111        assert!(config.filter.check.is_empty());
1112        // The keys the policy redesign removed default to empty so that a
1113        // configuration which never set them is not refused for having them.
1114        assert!(config.filter.enabled.is_empty());
1115        assert!(config.filter.exempt_paths.is_empty());
1116        assert!(config.filter.custom_enabled.is_empty());
1117        assert!(!config.eab.enabled);
1118        assert!(config.notify.enabled.is_empty());
1119        assert!(config.notify.custom_enabled.is_empty());
1120        assert!(config.notify.custom.is_empty());
1121        assert_eq!(config.notify.template_dir, "");
1122        assert_eq!(config.notify.email.smtp_port, 587);
1123        assert_eq!(config.notify.email.smtp_security, "starttls");
1124        assert_eq!(
1125            config.notify.email.events,
1126            vec![
1127                "profile_mounted",
1128                "account_created",
1129                "account_deactivated",
1130                "certificate_issued",
1131                "certificate_revoked",
1132                "challenge_failed"
1133            ]
1134        );
1135        assert!(config.notify.webhook_enabled.is_empty());
1136        assert!(config.notify.webhook.is_empty());
1137        assert!(config.dns.resolver.is_none());
1138        assert_eq!(config.proxy.http_url, "");
1139        assert_eq!(config.proxy.https_url, "");
1140        assert!(config.proxy.no_proxy.is_empty());
1141    }
1142
1143    #[test]
1144    fn direct_construction_matches_the_loaded_defaults() {
1145        let _guard = EnvGuard::new(&[]);
1146        let loaded = Config::load().unwrap();
1147        let direct = Config::default();
1148
1149        assert_eq!(loaded.database.url, direct.database.url);
1150        assert_eq!(loaded.server.base_url, direct.server.base_url);
1151        assert_eq!(loaded.server.bind_address, direct.server.bind_address);
1152        assert_eq!(loaded.server.tls.enabled, direct.server.tls.enabled);
1153        assert_eq!(loaded.server.tls.cert_path, direct.server.tls.cert_path);
1154        assert_eq!(loaded.server.tls.key_path, direct.server.tls.key_path);
1155        assert_eq!(
1156            loaded.server.tls.handshake_timeout_ms,
1157            direct.server.tls.handshake_timeout_ms
1158        );
1159        assert_eq!(loaded.nonce.ttl_seconds, direct.nonce.ttl_seconds);
1160        assert_eq!(loaded.order.validity_seconds, direct.order.validity_seconds);
1161        assert_eq!(loaded.signer.backend, direct.signer.backend);
1162        assert_eq!(loaded.challenge.enabled, direct.challenge.enabled);
1163        assert_eq!(loaded.challenge.bypass, direct.challenge.bypass);
1164        assert_eq!(loaded.challenge.timeout_ms, direct.challenge.timeout_ms);
1165        assert_eq!(loaded.challenge.http_01.port, direct.challenge.http_01.port);
1166        assert_eq!(loaded.filter.rules, direct.filter.rules);
1167        assert_eq!(loaded.filter.default, direct.filter.default);
1168        assert_eq!(loaded.eab.enabled, direct.eab.enabled);
1169        assert_eq!(loaded.dns.resolver, direct.dns.resolver);
1170        assert_eq!(loaded.proxy.http_url, direct.proxy.http_url);
1171        assert_eq!(loaded.proxy.https_url, direct.proxy.https_url);
1172        assert_eq!(loaded.proxy.no_proxy, direct.proxy.no_proxy);
1173    }
1174
1175    #[test]
1176    fn the_example_config_documents_the_real_defaults() {
1177        // Copied as-is, the example must actually boot — which now means it has
1178        // to declare a profile, since a configuration with none is refused.
1179        let body = std::fs::read_to_string("config.toml.example").unwrap();
1180        let profiles = load_toml(&body)
1181            .resolve_profiles()
1182            .expect("config.toml.example must define at least one profile");
1183        assert_eq!(
1184            profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1185            vec!["default"]
1186        );
1187
1188        let _guard = EnvGuard::new(&[]);
1189
1190        let example = ::config::Config::builder()
1191            .add_source(
1192                ::config::File::from(std::path::Path::new("config.toml.example"))
1193                    .format(::config::FileFormat::Toml),
1194            )
1195            .build()
1196            .expect("config.toml.example must be valid TOML")
1197            .try_deserialize::<Config>()
1198            .expect("config.toml.example must deserialize into Config");
1199
1200        let defaults = Config::default();
1201        assert_eq!(example.database.url, defaults.database.url);
1202        assert_eq!(example.server.bind_address, defaults.server.bind_address);
1203        assert_eq!(example.server.base_url, defaults.server.base_url);
1204        assert_eq!(
1205            example.server.max_concurrent_requests,
1206            defaults.server.max_concurrent_requests
1207        );
1208        assert_eq!(
1209            example.server.admission_wait_ms,
1210            defaults.server.admission_wait_ms
1211        );
1212        assert_eq!(
1213            example.server.request_timeout_ms,
1214            defaults.server.request_timeout_ms
1215        );
1216        assert_eq!(
1217            example.server.max_body_bytes,
1218            defaults.server.max_body_bytes
1219        );
1220        assert_eq!(example.server.tls.enabled, defaults.server.tls.enabled);
1221        assert_eq!(example.server.tls.cert_path, defaults.server.tls.cert_path);
1222        assert_eq!(example.server.tls.key_path, defaults.server.tls.key_path);
1223        assert_eq!(
1224            example.server.tls.handshake_timeout_ms,
1225            defaults.server.tls.handshake_timeout_ms
1226        );
1227        assert_eq!(example.admin.enabled, defaults.admin.enabled);
1228        assert_eq!(example.admin.bind_address, defaults.admin.bind_address);
1229        assert_eq!(example.admin.base_url, defaults.admin.base_url);
1230        assert_eq!(
1231            example.admin.session_ttl_seconds,
1232            defaults.admin.session_ttl_seconds
1233        );
1234        assert_eq!(
1235            example.admin.session_idle_timeout_seconds,
1236            defaults.admin.session_idle_timeout_seconds
1237        );
1238        assert_eq!(
1239            example.admin.login_max_attempts,
1240            defaults.admin.login_max_attempts
1241        );
1242        assert_eq!(
1243            example.admin.login_window_seconds,
1244            defaults.admin.login_window_seconds
1245        );
1246        assert_eq!(example.admin.require_mfa, defaults.admin.require_mfa);
1247        assert_eq!(example.admin.max_body_bytes, defaults.admin.max_body_bytes);
1248        assert_eq!(example.admin.page_size_max, defaults.admin.page_size_max);
1249        assert_eq!(example.admin.template_dir, defaults.admin.template_dir);
1250        assert_eq!(example.admin.tls.enabled, defaults.admin.tls.enabled);
1251        assert_eq!(example.admin.tls.cert_path, defaults.admin.tls.cert_path);
1252        assert_eq!(example.admin.tls.key_path, defaults.admin.tls.key_path);
1253        assert_eq!(
1254            example.admin.tls.handshake_timeout_ms,
1255            defaults.admin.tls.handshake_timeout_ms
1256        );
1257        assert_eq!(example.nonce.ttl_seconds, defaults.nonce.ttl_seconds);
1258        assert_eq!(example.audit.reverse_dns, defaults.audit.reverse_dns);
1259        assert_eq!(
1260            example.audit.reverse_dns_timeout_ms,
1261            defaults.audit.reverse_dns_timeout_ms
1262        );
1263        assert_eq!(example.audit.retention_days, defaults.audit.retention_days);
1264        assert_eq!(
1265            example.jobs.poll_interval_ms,
1266            defaults.jobs.poll_interval_ms
1267        );
1268        assert_eq!(example.jobs.max_concurrent, defaults.jobs.max_concurrent);
1269        assert_eq!(example.jobs.max_attempts, defaults.jobs.max_attempts);
1270        assert_eq!(
1271            example.jobs.retry_base_seconds,
1272            defaults.jobs.retry_base_seconds
1273        );
1274        assert_eq!(
1275            example.jobs.retry_max_seconds,
1276            defaults.jobs.retry_max_seconds
1277        );
1278        assert_eq!(example.jobs.lease_seconds, defaults.jobs.lease_seconds);
1279        assert_eq!(example.jobs.retention_days, defaults.jobs.retention_days);
1280        assert_eq!(example.logging.filter, defaults.logging.filter);
1281        assert_eq!(example.logging.json_format, defaults.logging.json_format);
1282        assert_eq!(example.logging.target, defaults.logging.target);
1283        assert_eq!(example.logging.ansi, defaults.logging.ansi);
1284        assert_eq!(example.logging.span_events, defaults.logging.span_events);
1285        assert_eq!(
1286            example.logging.flatten_event,
1287            defaults.logging.flatten_event
1288        );
1289        assert_eq!(
1290            example.order.validity_seconds,
1291            defaults.order.validity_seconds
1292        );
1293        assert_eq!(example.signer.backend, defaults.signer.backend);
1294        assert_eq!(
1295            example.signer.local_ca.cert_path,
1296            defaults.signer.local_ca.cert_path
1297        );
1298        assert_eq!(
1299            example.signer.local_ca.key_path,
1300            defaults.signer.local_ca.key_path
1301        );
1302        assert_eq!(
1303            example.signer.local_ca.key_type,
1304            defaults.signer.local_ca.key_type
1305        );
1306        assert_eq!(
1307            example.signer.local_ca.leaf_validity_days,
1308            defaults.signer.local_ca.leaf_validity_days
1309        );
1310        assert_eq!(
1311            example.signer.local_ca.crl_distribution_points,
1312            defaults.signer.local_ca.crl_distribution_points
1313        );
1314        assert_eq!(
1315            example.signer.local_ca.ca_issuer_urls,
1316            defaults.signer.local_ca.ca_issuer_urls
1317        );
1318        assert_eq!(
1319            example.signer.local_ca.subject.common_name,
1320            defaults.signer.local_ca.subject.common_name
1321        );
1322        assert_eq!(
1323            example.signer.local_ca.subject.organization,
1324            defaults.signer.local_ca.subject.organization
1325        );
1326        assert_eq!(
1327            example.signer.local_ca.subject.organizational_unit,
1328            defaults.signer.local_ca.subject.organizational_unit
1329        );
1330        assert_eq!(
1331            example.signer.local_ca.subject.country,
1332            defaults.signer.local_ca.subject.country
1333        );
1334        assert_eq!(
1335            example.signer.local_ca.subject.state,
1336            defaults.signer.local_ca.subject.state
1337        );
1338        assert_eq!(
1339            example.signer.local_ca.subject.locality,
1340            defaults.signer.local_ca.subject.locality
1341        );
1342        assert_eq!(example.challenge.enabled, defaults.challenge.enabled);
1343        assert_eq!(example.challenge.bypass, defaults.challenge.bypass);
1344        assert_eq!(example.challenge.timeout_ms, defaults.challenge.timeout_ms);
1345        assert_eq!(
1346            example.challenge.http_01.port,
1347            defaults.challenge.http_01.port
1348        );
1349        assert_eq!(
1350            example.challenge.http_01.https_port,
1351            defaults.challenge.http_01.https_port
1352        );
1353        assert_eq!(
1354            example.challenge.http_01.follow_redirects,
1355            defaults.challenge.http_01.follow_redirects
1356        );
1357        assert_eq!(
1358            example.challenge.http_01.max_redirects,
1359            defaults.challenge.http_01.max_redirects
1360        );
1361        assert_eq!(
1362            example.challenge.http_01.max_response_bytes,
1363            defaults.challenge.http_01.max_response_bytes
1364        );
1365        assert_eq!(
1366            example.challenge.tls_alpn_01.port,
1367            defaults.challenge.tls_alpn_01.port
1368        );
1369        assert_eq!(example.filter.rules, defaults.filter.rules);
1370        assert_eq!(example.filter.default, defaults.filter.default);
1371        assert_eq!(
1372            example.filter.forwarded_header,
1373            defaults.filter.forwarded_header
1374        );
1375        // Every check and rule the example documents is commented out, so the
1376        // file stays an all-defaults document that still boots.
1377        assert!(example.filter.check.is_empty());
1378        assert!(example.filter.rule.is_empty());
1379        assert_eq!(example.ipam.backend, defaults.ipam.backend);
1380        assert_eq!(example.ipam.timeout_ms, defaults.ipam.timeout_ms);
1381        assert_eq!(example.ipam.netbox.url, defaults.ipam.netbox.url);
1382        assert_eq!(example.ipam.netbox.token, defaults.ipam.netbox.token);
1383        assert_eq!(
1384            example.ipam.netbox.custom_field,
1385            defaults.ipam.netbox.custom_field
1386        );
1387        assert_eq!(example.ipam.netbox.sources, defaults.ipam.netbox.sources);
1388        assert_eq!(
1389            example.ipam.netbox.vip_roles,
1390            defaults.ipam.netbox.vip_roles
1391        );
1392        assert_eq!(
1393            example.ipam.netbox.ca_cert_path,
1394            defaults.ipam.netbox.ca_cert_path
1395        );
1396        assert_eq!(
1397            example.ipam.netbox.insecure_skip_verify,
1398            defaults.ipam.netbox.insecure_skip_verify
1399        );
1400        assert_eq!(example.ipam.phpipam.url, defaults.ipam.phpipam.url);
1401        assert_eq!(example.ipam.phpipam.app_id, defaults.ipam.phpipam.app_id);
1402        assert_eq!(example.ipam.phpipam.token, defaults.ipam.phpipam.token);
1403        assert_eq!(
1404            example.ipam.phpipam.custom_field,
1405            defaults.ipam.phpipam.custom_field
1406        );
1407        assert_eq!(example.ipam.phpipam.sources, defaults.ipam.phpipam.sources);
1408        assert_eq!(
1409            example.ipam.phpipam.ca_cert_path,
1410            defaults.ipam.phpipam.ca_cert_path
1411        );
1412        assert_eq!(
1413            example.ipam.phpipam.insecure_skip_verify,
1414            defaults.ipam.phpipam.insecure_skip_verify
1415        );
1416        assert_eq!(example.eab.enabled, defaults.eab.enabled);
1417        assert_eq!(example.notify.enabled, defaults.notify.enabled);
1418        assert_eq!(
1419            example.notify.custom_enabled,
1420            defaults.notify.custom_enabled
1421        );
1422        assert_eq!(example.notify.template_dir, defaults.notify.template_dir);
1423        assert_eq!(
1424            example.notify.email.smtp_port,
1425            defaults.notify.email.smtp_port
1426        );
1427        assert_eq!(
1428            example.notify.email.smtp_security,
1429            defaults.notify.email.smtp_security
1430        );
1431        assert_eq!(example.notify.email.events, defaults.notify.email.events);
1432        assert_eq!(
1433            example.notify.webhook_enabled,
1434            defaults.notify.webhook_enabled
1435        );
1436        assert_eq!(example.dns.resolver, defaults.dns.resolver);
1437        assert_eq!(example.proxy.http_url, defaults.proxy.http_url);
1438        assert_eq!(example.proxy.https_url, defaults.proxy.https_url);
1439        assert_eq!(example.proxy.no_proxy, defaults.proxy.no_proxy);
1440    }
1441
1442    #[test]
1443    fn load_applies_env_overrides() {
1444        let _guard = EnvGuard::new(&[("ACME_PROXY_SERVER__BASE_URL", "https://acme.example.test")]);
1445
1446        let config = Config::load().expect("load should succeed with env overrides");
1447
1448        assert_eq!(config.server.base_url, "https://acme.example.test");
1449        assert_eq!(config.server.bind_address, "[::]:3000");
1450        assert_eq!(config.nonce.ttl_seconds, 300);
1451    }
1452
1453    #[test]
1454    fn load_applies_eab_env_override() {
1455        let _guard = EnvGuard::new(&[("ACME_PROXY_EAB__ENABLED", "true")]);
1456        let config = Config::load().expect("load should succeed with eab env override");
1457        assert!(config.eab.enabled);
1458    }
1459
1460    #[test]
1461    fn load_applies_dns_resolver_env_override() {
1462        let _guard = EnvGuard::new(&[("ACME_PROXY_DNS__RESOLVER", "10.60.0.2:53")]);
1463        let config = Config::load().expect("load should succeed with a dns resolver override");
1464        assert_eq!(config.dns.resolver.as_deref(), Some("10.60.0.2:53"));
1465    }
1466
1467    #[test]
1468    fn load_treats_an_empty_string_list_env_var_as_no_values() {
1469        let _guard = EnvGuard::new(&[
1470            ("ACME_PROXY_FILTER__ENABLED", ""),
1471            ("ACME_PROXY_CHALLENGE__ENABLED", ""),
1472        ]);
1473        let config = Config::load().expect("an empty list env var must not be a parse error");
1474        assert!(config.filter.enabled.is_empty());
1475        assert!(config.challenge.enabled.is_empty());
1476    }
1477
1478    #[test]
1479    fn load_applies_doubly_nested_env_overrides() {
1480        let _guard = EnvGuard::new(&[
1481            ("ACME_PROXY_SERVER__TLS__ENABLED", "true"),
1482            ("ACME_PROXY_SERVER__TLS__CERT_PATH", "/etc/acme/tls.pem"),
1483            ("ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS", "2500"),
1484        ]);
1485
1486        let config = Config::load().expect("load should succeed with nested env overrides");
1487
1488        assert!(config.server.tls.enabled);
1489        assert_eq!(config.server.tls.cert_path, "/etc/acme/tls.pem");
1490        assert_eq!(config.server.tls.handshake_timeout_ms, 2500);
1491        assert_eq!(config.server.tls.key_path, "server.key");
1492        assert_eq!(config.server.bind_address, "[::]:3000");
1493    }
1494
1495    #[test]
1496    fn load_applies_local_ca_subject_env_overrides() {
1497        let _guard = EnvGuard::new(&[
1498            (
1499                "ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COMMON_NAME",
1500                "Custom Root CA",
1501            ),
1502            (
1503                "ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATION",
1504                "Example Corp",
1505            ),
1506            ("ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COUNTRY", "US"),
1507        ]);
1508
1509        let config =
1510            Config::load().expect("load should succeed with local_ca subject env overrides");
1511
1512        assert_eq!(
1513            config.signer.local_ca.subject.common_name.as_deref(),
1514            Some("Custom Root CA")
1515        );
1516        assert_eq!(
1517            config.signer.local_ca.subject.organization.as_deref(),
1518            Some("Example Corp")
1519        );
1520        assert_eq!(
1521            config.signer.local_ca.subject.country.as_deref(),
1522            Some("US")
1523        );
1524        // Untouched keys, including sibling fields of the same nested table,
1525        // stay at their compiled defaults.
1526        assert!(config.signer.local_ca.subject.state.is_none());
1527        assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
1528    }
1529
1530    #[test]
1531    fn load_parses_list_valued_env_overrides() {
1532        let _guard = EnvGuard::new(&[
1533            ("ACME_PROXY_FILTER__RULES", "mgmt-bypass,inventory-owned"),
1534            (
1535                "ACME_PROXY_FILTER__CHECK__NET__ALLOW",
1536                "192.168.1.0/24,fd00::/8",
1537            ),
1538        ]);
1539
1540        let config = Config::load().expect("load should succeed with list env overrides");
1541
1542        assert_eq!(config.filter.rules, vec!["mgmt-bypass", "inventory-owned"]);
1543        assert_eq!(
1544            config.filter.check["net"].allow,
1545            vec!["192.168.1.0/24", "fd00::/8"]
1546        );
1547        assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
1548    }
1549
1550    #[test]
1551    fn every_registered_list_key_round_trips_through_the_environment() {
1552        let known = LIST_KEYS
1553            .iter()
1554            .filter(|key| Config::default().list_key(key).is_some())
1555            .count();
1556        assert_eq!(
1557            known,
1558            LIST_KEYS.len(),
1559            "every LIST_KEYS entry must be readable via `list_key`"
1560        );
1561        assert_eq!(
1562            LIST_KEYS.len(),
1563            20,
1564            "a config `Vec` field was added or removed: update LIST_KEYS, `list_key`, \
1565             config.toml.example and this count together"
1566        );
1567
1568        for key in LIST_KEYS {
1569            let (first, second) = match *key {
1570                "challenge.enabled" => ("http-01", "dns-01"),
1571                "filter.rules" => ("mgmt-bypass", "inventory-owned"),
1572                "filter.exempt_paths" => ("/health", "/directory"),
1573                _ => ("first-value", "second-value"),
1574            };
1575
1576            let env_key: &'static str = Box::leak(
1577                format!("ACME_PROXY_{}", key.replace('.', "__").to_uppercase()).into_boxed_str(),
1578            );
1579            let _guard = EnvGuard::new(&[(env_key, &format!("{first},{second}"))]);
1580
1581            let config = Config::load().expect("load should succeed");
1582            let actual = config.list_key(key);
1583            assert_eq!(
1584                actual,
1585                Some(vec![first.to_string(), second.to_string()]),
1586                "{key} (via {env_key}) did not parse as a two-element list; \
1587                 is it registered in LIST_KEYS and reachable from `list_key`?"
1588            );
1589        }
1590    }
1591}