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