Skip to main content

acme_proxy_core/config/
mod.rs

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