1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::Deserialize;
6
7pub mod types;
8pub use types::*;
9
10#[derive(Debug, Clone, Default, Deserialize)]
20#[serde(default)]
21pub struct Config {
22 pub database: DatabaseConfig,
23 pub server: ServerConfig,
24 pub admin: AdminConfig,
27 pub nonce: NonceConfig,
28 pub audit: AuditConfig,
31 pub jobs: JobsConfig,
34 pub metrics: MetricsConfig,
37 pub logging: LoggingConfig,
38 pub order: OrderConfig,
39 pub signer: SignerConfig,
40 pub challenge: ChallengeConfig,
41 pub filter: FilterConfig,
42 pub ipam: IpamConfig,
46 pub eab: EabConfig,
47 pub notify: NotifyConfig,
48 pub meta: MetaConfig,
49 pub dns: DnsConfig,
50 pub proxy: ProxyConfig,
54 #[serde(skip)]
61 raw: Option<::config::Config>,
62}
63
64const PROFILE_SECTIONS: &[&str] = &[
66 "signer",
67 "filter",
68 "ipam",
69 "challenge",
70 "eab",
71 "order",
72 "notify",
73 "meta",
74];
75
76pub(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
95pub(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
139pub(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
164fn valid_profile_name(name: &str) -> bool {
167 valid_config_key_name(name)
168}
169
170const 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 "filter.enabled",
196 "filter.exempt_paths",
197 "filter.custom_enabled",
198 "ipam.netbox.sources",
199 "ipam.netbox.vip_roles",
200 "ipam.phpipam.sources",
201 "signer.relay.contact",
202 "signer.custom.args",
203 "signer.local_ca.crl_distribution_points",
204 "signer.local_ca.ca_issuer_urls",
205 "notify.enabled",
206 "notify.email.to",
207 "notify.email.events",
208 "notify.webhook_enabled",
209 "notify.custom_enabled",
210 "meta.caa_identities",
211 "proxy.no_proxy",
212];
213
214impl Config {
215 #[cfg(test)]
216 fn list_key(&self, key: &str) -> Option<Vec<String>> {
217 let value = match key {
218 "challenge.enabled" => &self.challenge.enabled,
219 "filter.rules" => &self.filter.rules,
220 "filter.trusted_proxies" => &self.filter.trusted_proxies,
221 "filter.enabled" => &self.filter.enabled,
222 "filter.exempt_paths" => &self.filter.exempt_paths,
223 "filter.custom_enabled" => &self.filter.custom_enabled,
224 "ipam.netbox.sources" => &self.ipam.netbox.sources,
225 "ipam.netbox.vip_roles" => &self.ipam.netbox.vip_roles,
226 "ipam.phpipam.sources" => &self.ipam.phpipam.sources,
227 "signer.relay.contact" => &self.signer.relay.contact,
228 "signer.custom.args" => &self.signer.custom.args,
229 "signer.local_ca.crl_distribution_points" => {
230 &self.signer.local_ca.crl_distribution_points
231 }
232 "signer.local_ca.ca_issuer_urls" => &self.signer.local_ca.ca_issuer_urls,
233 "notify.enabled" => &self.notify.enabled,
234 "notify.email.to" => &self.notify.email.to,
235 "notify.email.events" => &self.notify.email.events,
236 "notify.webhook_enabled" => &self.notify.webhook_enabled,
237 "notify.custom_enabled" => &self.notify.custom_enabled,
238 "meta.caa_identities" => &self.meta.caa_identities,
239 "proxy.no_proxy" => &self.proxy.no_proxy,
240 _ => return None,
241 };
242 Some(value.clone())
243 }
244
245 pub fn load() -> Result<Self, ::config::ConfigError> {
247 let path = std::env::var("ACME_PROXY_CONFIG").unwrap_or_else(|_| "config".into());
248
249 let mut environment = ::config::Environment::with_prefix("ACME_PROXY")
250 .prefix_separator("_")
251 .separator("__")
252 .try_parsing(true)
253 .list_separator(",");
254 let profiles_in_env = profile_names_in_env();
261 for key in LIST_KEYS {
262 environment = environment.with_list_parse_key(key);
263 for name in &profiles_in_env {
264 environment = environment.with_list_parse_key(&format!("profiles.{name}.{key}"));
265 }
266 }
267 const NAMED_TABLES: &[(&str, &[&str])] = &[
282 ("filter.check", CHECK_LIST_KEYS),
283 ("notify.custom", &["args", "events"]),
284 ("notify.webhook", &["events"]),
285 ];
286 let scopes = std::iter::once(None).chain(profiles_in_env.iter().map(Some));
287 for profile in scopes {
288 for (section, keys) in NAMED_TABLES {
289 let (env_prefix, key_prefix) = match profile {
290 None => (
291 format!("ACME_PROXY_{}__", env_segment(section)),
292 (*section).to_string(),
293 ),
294 Some(name) => (
295 format!(
296 "ACME_PROXY_PROFILES__{}__{}__",
297 name.to_ascii_uppercase(),
298 env_segment(section)
299 ),
300 format!("profiles.{name}.{section}"),
301 ),
302 };
303 for entry in names_in_env(&env_prefix) {
304 for key in *keys {
305 environment =
306 environment.with_list_parse_key(&format!("{key_prefix}.{entry}.{key}"));
307 }
308 }
309 }
310 }
311
312 let built = ::config::Config::builder()
313 .add_source(::config::File::with_name(&path).required(false))
314 .add_source(environment)
315 .build()?;
316
317 let mut config: Config = built.clone().try_deserialize()?;
318 config.raw = Some(built);
319 Ok(config)
320 }
321
322 pub fn resolve_profiles(&self) -> anyhow::Result<Vec<ProfileConfig>> {
331 let raw_profiles = self
332 .raw
333 .as_ref()
334 .and_then(|raw| raw.get::<::config::Value>("profiles").ok())
335 .map(as_table)
336 .unwrap_or_default();
337
338 let mut profiles = Vec::new();
339 for (name, raw_profile) in raw_profiles.into_iter().collect::<BTreeMap<_, _>>() {
342 anyhow::ensure!(
343 valid_profile_name(&name),
344 "invalid profile name `{name}`: use lowercase letters, digits and `-` \
345 (the name is both a URL segment and an environment variable segment)"
346 );
347
348 let sections = self.merged_sections(&raw_profile).map_err(|error| {
349 anyhow::anyhow!("profile `{name}`: invalid configuration: {error}")
350 })?;
351
352 if sections.enabled {
353 profiles.push(ProfileConfig { name, sections });
354 }
355 }
356
357 anyhow::ensure!(!profiles.is_empty(), self.no_profiles_message());
358 Ok(profiles)
359 }
360
361 fn merged_sections(
366 &self,
367 raw_profile: &::config::Value,
368 ) -> Result<ProfileSections, ::config::ConfigError> {
369 let profile_table = as_table(raw_profile.clone());
370 let mut merged = profile_table.clone();
371
372 for section in PROFILE_SECTIONS {
373 let global = self
374 .raw
375 .as_ref()
376 .and_then(|raw| raw.get::<::config::Value>(section).ok());
377 let overlay = profile_table.get(*section).cloned();
378
379 match (global, overlay) {
380 (Some(global), Some(overlay)) => {
381 merged.insert((*section).to_string(), merge_values(&global, &overlay));
382 }
383 (Some(global), None) => {
384 merged.insert((*section).to_string(), global);
385 }
386 (None, _) => {}
389 }
390 }
391
392 ProfileSections::deserialize(::config::Value::new(
393 None,
394 ::config::ValueKind::Table(merged),
395 ))
396 }
397
398 fn no_profiles_message(&self) -> String {
401 format!(
402 "no enabled [profiles] — acme-proxy serves nothing without one. Minimal config:\n\
403 \n [profiles.default]\n\n\
404 Its ACME directory is then at {}/profile/default/directory.",
405 self.server.base_url
406 )
407 }
408}
409
410fn env_segment(section: &str) -> String {
416 section.to_ascii_uppercase().replace('.', "__")
417}
418
419fn names_in_env(prefix: &str) -> BTreeSet<String> {
425 std::env::vars()
426 .filter_map(|(key, _)| {
427 let rest = key.strip_prefix(prefix)?;
428 let name = rest.split("__").next()?;
429 (!name.is_empty()).then(|| name.to_ascii_lowercase())
430 })
431 .collect()
432}
433
434fn profile_names_in_env() -> BTreeSet<String> {
436 names_in_env("ACME_PROXY_PROFILES__")
437}
438
439fn as_table(value: ::config::Value) -> ::config::Map<String, ::config::Value> {
441 match value.kind {
442 ::config::ValueKind::Table(table) => table,
443 _ => ::config::Map::new(),
444 }
445}
446
447fn merge_values(base: &::config::Value, overlay: &::config::Value) -> ::config::Value {
452 match (&base.kind, &overlay.kind) {
453 (::config::ValueKind::Table(base), ::config::ValueKind::Table(overlay)) => {
454 let mut merged = base.clone();
455 for (key, value) in overlay {
456 let merged_value = match merged.get(key) {
457 Some(existing) => merge_values(existing, value),
458 None => value.clone(),
459 };
460 merged.insert(key.clone(), merged_value);
461 }
462 ::config::Value::new(None, ::config::ValueKind::Table(merged))
463 }
464 _ => overlay.clone(),
465 }
466}
467
468#[cfg(test)]
476pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::testutil::EnvGuard;
482
483 struct TempConfig {
489 dir: crate::testutil::TempDir,
490 }
491
492 impl TempConfig {
493 fn new(body: &str) -> Self {
494 let dir = crate::testutil::TempDir::new("cfg");
495 dir.write("config.toml", body);
496 Self { dir }
497 }
498
499 fn path(&self) -> String {
500 self.dir.join("config").to_string_lossy().into_owned()
501 }
502 }
503
504 fn load_toml(body: &str) -> Config {
506 let file = TempConfig::new(body);
507 let path = file.path();
508 let _guard = EnvGuard::new(&[("ACME_PROXY_CONFIG", &path)]);
509 Config::load().expect("the configuration must load")
510 }
511
512 #[test]
513 fn a_bare_profile_table_inherits_every_global_section() {
514 let config = load_toml(
515 r#"
516 [challenge]
517 enabled = ["dns-01"]
518 bypass = false
519
520 [profiles.le]
521 "#,
522 );
523
524 let profiles = config.resolve_profiles().unwrap();
525 assert_eq!(profiles.len(), 1);
526 assert_eq!(profiles[0].name, "le");
527 assert_eq!(profiles[0].sections.challenge.enabled, vec!["dns-01"]);
528 assert!(!profiles[0].sections.challenge.bypass);
529 assert_eq!(profiles[0].sections.signer.backend, "local_ca");
531 }
532
533 #[test]
537 fn overriding_one_key_keeps_the_rest_of_the_global_section() {
538 let config = load_toml(
539 r#"
540 [challenge]
541 enabled = ["dns-01"]
542 bypass = true
543 timeout_ms = 1234
544
545 [profiles.strict]
546 challenge.bypass = false
547 "#,
548 );
549
550 let profiles = config.resolve_profiles().unwrap();
551 let challenge = &profiles[0].sections.challenge;
552 assert!(!challenge.bypass, "the profile's own value wins");
553 assert_eq!(
554 challenge.enabled,
555 vec!["dns-01"],
556 "the rest of the section is inherited, not reset to the default"
557 );
558 assert_eq!(challenge.timeout_ms, 1234);
559 }
560
561 #[test]
565 fn two_profiles_may_name_different_inventories() {
566 let config = load_toml(
567 r#"
568 [ipam]
569 backend = "netbox"
570 timeout_ms = 1234
571
572 [ipam.netbox]
573 url = "https://netbox.example.com"
574 token = "t0ken"
575
576 [ipam.phpipam]
577 url = "https://ipam.example.com"
578 token = "appcode"
579
580 [profiles.dmz]
581
582 [profiles.internal]
583 ipam.backend = "phpipam"
584 "#,
585 );
586
587 let profiles = config.resolve_profiles().unwrap();
588 let by_name = |name: &str| {
589 profiles
590 .iter()
591 .find(|p| p.name == name)
592 .map(|p| &p.sections.ipam)
593 .unwrap()
594 };
595
596 assert_eq!(by_name("dmz").backend, "netbox");
597 assert_eq!(by_name("internal").backend, "phpipam");
598 assert_eq!(by_name("internal").timeout_ms, 1234);
601 assert_eq!(by_name("internal").phpipam.url, "https://ipam.example.com");
602 assert_eq!(by_name("internal").netbox.url, "https://netbox.example.com");
603 }
604
605 #[test]
609 fn a_profile_may_narrow_the_ipam_sources_alone() {
610 let config = load_toml(
611 r#"
612 [ipam]
613 backend = "netbox"
614
615 [ipam.netbox]
616 url = "https://netbox.example.com"
617 token = "t0ken"
618 sources = ["dns_name", "custom_field", "device", "fhrp"]
619
620 [profiles.strict]
621 ipam.netbox.sources = ["dns_name"]
622 "#,
623 );
624
625 let netbox = &config.resolve_profiles().unwrap()[0].sections.ipam.netbox;
626 assert_eq!(netbox.sources, vec!["dns_name"]);
627 assert_eq!(netbox.url, "https://netbox.example.com");
628 assert_eq!(netbox.token, "t0ken");
629 }
630
631 #[test]
635 fn a_profile_can_override_one_notify_key_and_keep_the_rest() {
636 let config = load_toml(
637 r#"
638 [notify]
639 enabled = ["email"]
640 email.smtp_host = "mail.example.com"
641 email.smtp_port = 2525
642
643 [profiles.staging]
644 notify.email.smtp_host = "mail.staging.example.com"
645 "#,
646 );
647
648 let profiles = config.resolve_profiles().unwrap();
649 let notify = &profiles[0].sections.notify;
650 assert_eq!(notify.enabled, vec!["email"], "inherited from global");
651 assert_eq!(notify.email.smtp_host, "mail.staging.example.com");
652 assert_eq!(
653 notify.email.smtp_port, 2525,
654 "the rest of the section is inherited, not reset to the default"
655 );
656 }
657
658 #[test]
659 fn a_profile_section_replaces_an_inherited_list_wholesale() {
660 let config = load_toml(
661 r#"
662 [filter]
663 check.names.deny = ["a.example", "b.example"]
664
665 [profiles.narrow]
666 filter.check.names.deny = ["c.example"]
667 "#,
668 );
669
670 let profiles = config.resolve_profiles().unwrap();
671 assert_eq!(
672 profiles[0].sections.filter.check["names"].deny,
673 vec!["c.example"],
674 "arrays are replaced, never merged"
675 );
676 }
677
678 #[test]
683 fn a_profile_overrides_one_field_of_an_inherited_rule() {
684 let config = load_toml(
685 r#"
686 [filter]
687 rules = ["inventory"]
688 rule.inventory.when = "inv"
689 rule.inventory.then = "allow"
690 rule.inventory.message = "not yours"
691
692 [profiles.staging]
693 filter.rule.inventory.mode = "warn"
694 "#,
695 );
696
697 let rule = &config.resolve_profiles().unwrap()[0].sections.filter.rule["inventory"];
698 assert_eq!(rule.mode, "warn");
699 assert_eq!(rule.when, "inv", "the condition is inherited");
700 assert_eq!(rule.message, "not yours", "so is the message");
701 }
702
703 #[test]
704 fn profiles_are_resolved_in_name_order() {
705 let config = load_toml(
706 r#"
707 [profiles.zulu]
708 [profiles.alpha]
709 [profiles.mike]
710 "#,
711 );
712
713 let names: Vec<_> = config
714 .resolve_profiles()
715 .unwrap()
716 .into_iter()
717 .map(|p| p.name)
718 .collect();
719 assert_eq!(names, vec!["alpha", "mike", "zulu"]);
720 }
721
722 #[test]
723 fn a_disabled_profile_is_not_mounted() {
724 let config = load_toml(
725 r#"
726 [profiles.live]
727
728 [profiles.parked]
729 enabled = false
730 "#,
731 );
732
733 let names: Vec<_> = config
734 .resolve_profiles()
735 .unwrap()
736 .into_iter()
737 .map(|p| p.name)
738 .collect();
739 assert_eq!(names, vec!["live"]);
740 }
741
742 #[test]
743 fn a_configuration_with_no_profile_refuses_to_resolve() {
744 for body in ["", "[profiles]\n", "[profiles.parked]\nenabled = false\n"] {
745 let config = load_toml(body);
746 let error = config
747 .resolve_profiles()
748 .expect_err("a server with no endpoint must not start")
749 .to_string();
750 assert!(error.contains("[profiles.default]"), "{error}");
751 assert!(
752 error.contains("/profile/default/directory"),
753 "the error must show where the endpoint would answer: {error}"
754 );
755 }
756 }
757
758 #[test]
759 fn a_profile_name_outside_the_url_charset_is_refused() {
760 for name in ["Le", "my_profile", "we.b"] {
761 let config = load_toml(&format!("[profiles.\"{name}\"]\n"));
762 let error = config
763 .resolve_profiles()
764 .expect_err("{name} must be refused")
765 .to_string();
766 assert!(error.contains("invalid profile name"), "{error}");
767 }
768 }
769
770 #[test]
774 fn a_profile_list_key_round_trips_through_the_environment() {
775 let file = TempConfig::new("[profiles.le]\n");
776 let path = file.path();
777 let _guard = EnvGuard::new(&[
778 ("ACME_PROXY_CONFIG", &path),
779 (
780 "ACME_PROXY_PROFILES__LE__CHALLENGE__ENABLED",
781 "dns-01,http-01",
782 ),
783 ]);
784
785 let config = Config::load().unwrap();
786 let profiles = config.resolve_profiles().unwrap();
787 assert_eq!(
788 profiles[0].sections.challenge.enabled,
789 vec!["dns-01".to_string(), "http-01".to_string()]
790 );
791 }
792
793 #[test]
802 fn the_admin_section_round_trips_through_the_environment() {
803 let _guard = EnvGuard::new(&[
804 ("ACME_PROXY_ADMIN__ENABLED", "true"),
805 ("ACME_PROXY_ADMIN__BIND_ADDRESS", "127.0.0.1:9999"),
806 ("ACME_PROXY_ADMIN__BASE_URL", "https://admin.example.com"),
807 ("ACME_PROXY_ADMIN__SESSION_TTL_SECONDS", "60"),
808 ("ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS", "1"),
809 ("ACME_PROXY_ADMIN__REQUIRE_MFA", "true"),
810 ("ACME_PROXY_ADMIN__PAGE_SIZE_MAX", "10"),
811 ("ACME_PROXY_ADMIN__TLS__ENABLED", "true"),
812 ("ACME_PROXY_ADMIN__TLS__CERT_PATH", "/tmp/admin.pem"),
813 ]);
814
815 let config = Config::load().unwrap();
816 assert!(config.admin.enabled);
817 assert_eq!(config.admin.bind_address, "127.0.0.1:9999");
818 assert_eq!(config.admin.base_url, "https://admin.example.com");
819 assert_eq!(config.admin.session_ttl_seconds, 60);
820 assert_eq!(config.admin.login_max_attempts, 1);
821 assert!(config.admin.require_mfa);
822 assert_eq!(config.admin.page_size_max, 10);
823 assert!(config.admin.tls.enabled);
824 assert_eq!(config.admin.tls.cert_path, "/tmp/admin.pem");
825 assert_eq!(
827 config.admin.tls.key_path,
828 AdminConfig::default().tls.key_path
829 );
830 assert_eq!(
831 config.admin.session_idle_timeout_seconds,
832 AdminConfig::default().session_idle_timeout_seconds
833 );
834 }
835
836 #[test]
843 fn the_proxy_section_round_trips_through_the_environment() {
844 let _guard = EnvGuard::new(&[
845 (
846 "ACME_PROXY_PROXY__HTTPS_URL",
847 "http://proxy.example.com:3128",
848 ),
849 ("ACME_PROXY_PROXY__NO_PROXY", "10.0.0.0/8,.internal.example"),
850 ]);
851
852 let config = Config::load().unwrap();
853 assert_eq!(config.proxy.https_url, "http://proxy.example.com:3128");
854 assert_eq!(
855 config.proxy.no_proxy,
856 vec!["10.0.0.0/8", ".internal.example"]
857 );
858 assert_eq!(config.proxy.http_url, ProxyConfig::default().http_url);
860 }
861
862 #[test]
869 fn env_configures_multiple_named_checks_with_all_their_lists() {
870 let _guard = EnvGuard::new(&[
871 ("ACME_PROXY_FILTER__RULES", "main"),
872 ("ACME_PROXY_FILTER__CHECK__MAIN__TYPE", "custom"),
873 (
874 "ACME_PROXY_FILTER__CHECK__MAIN__SCRIPT_PATH",
875 "/path/to/one.sh",
876 ),
877 ("ACME_PROXY_FILTER__CHECK__MAIN__ARGS", "foo,bar"),
878 ("ACME_PROXY_FILTER__CHECK__MAIN__STAGES", "connection"),
879 ("ACME_PROXY_FILTER__CHECK__EXTRA__TYPE", "identifiers"),
880 (
881 "ACME_PROXY_FILTER__CHECK__EXTRA__ALLOW",
882 "*.example.com,example.com",
883 ),
884 ("ACME_PROXY_FILTER__CHECK__EXTRA__DENY_REGEX", "secret\\..*"),
885 ("ACME_PROXY_FILTER__CHECK__EXTRA__ALLOWED_TYPES", "dns"),
886 ("ACME_PROXY_FILTER__CHECK__EXTRA__KIDS", "k1,k2"),
887 ]);
888
889 let config = Config::load().expect("load should succeed");
890 let check = &config.filter.check;
891 assert_eq!(check["main"].script_path, "/path/to/one.sh");
892 assert_eq!(check["main"].args, vec!["foo", "bar"]);
893 assert_eq!(check["main"].stages, vec!["connection"]);
894 assert_eq!(check["extra"].allow, vec!["*.example.com", "example.com"]);
895 assert_eq!(check["extra"].deny_regex, vec!["secret\\..*"]);
896 assert_eq!(check["extra"].allowed_types, vec!["dns"]);
897 assert_eq!(check["extra"].kids, vec!["k1", "k2"]);
898 assert_eq!(config.filter.rules, vec!["main"]);
899 }
900
901 #[test]
904 fn env_configures_a_profile_scoped_named_check() {
905 let file = TempConfig::new("[profiles.le]\n");
906 let path = file.path();
907 let _guard = EnvGuard::new(&[
908 ("ACME_PROXY_CONFIG", &path),
909 ("ACME_PROXY_PROFILES__LE__FILTER__RULES", "only"),
910 (
911 "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__TYPE",
912 "custom",
913 ),
914 (
915 "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__SCRIPT_PATH",
916 "/path/to/profile.sh",
917 ),
918 (
919 "ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__ARGS",
920 "a,b,c",
921 ),
922 ]);
923
924 let config = Config::load().unwrap();
925 let profiles = config.resolve_profiles().unwrap();
926 let check = &profiles[0].sections.filter.check;
927 assert_eq!(check["main"].script_path, "/path/to/profile.sh");
928 assert_eq!(check["main"].args, vec!["a", "b", "c"]);
929 assert_eq!(profiles[0].sections.filter.rules, vec!["only"]);
930 }
931
932 #[test]
941 fn env_configures_profile_scoped_notify_tables() {
942 let file = TempConfig::new("[profiles.le]\n");
943 let path = file.path();
944 let _guard = EnvGuard::new(&[
945 ("ACME_PROXY_CONFIG", &path),
946 (
947 "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__SCRIPT_PATH",
948 "/usr/local/bin/page.sh",
949 ),
950 (
951 "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__ARGS",
952 "--urgent,--team=netops",
953 ),
954 (
955 "ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__EVENTS",
956 "certificate_issued,challenge_failed",
957 ),
958 (
959 "ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__URL",
960 "https://hooks.example.com/T/B/xyz",
961 ),
962 (
963 "ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__EVENTS",
964 "certificate_revoked",
965 ),
966 ]);
967
968 let config = Config::load().unwrap();
969 let profiles = config.resolve_profiles().unwrap();
970 let notify = &profiles[0].sections.notify;
971
972 let pager = ¬ify.custom["pager"];
973 assert_eq!(pager.script_path, "/usr/local/bin/page.sh");
974 assert_eq!(pager.args, vec!["--urgent", "--team=netops"]);
975 assert_eq!(
976 pager.events,
977 vec!["certificate_issued", "challenge_failed"],
978 "an unregistered list key is dropped, not refused"
979 );
980
981 let slack = ¬ify.webhook["slack"];
982 assert_eq!(slack.url, "https://hooks.example.com/T/B/xyz");
983 assert_eq!(slack.events, vec!["certificate_revoked"]);
984 }
985
986 #[test]
993 fn env_configures_a_named_webhook_with_its_list_and_its_header_map() {
994 let _guard = EnvGuard::new(&[
995 ("ACME_PROXY_NOTIFY__ENABLED", "webhook"),
996 ("ACME_PROXY_NOTIFY__WEBHOOK_ENABLED", "slack,matrix"),
997 (
998 "ACME_PROXY_NOTIFY__WEBHOOK__SLACK__URL",
999 "https://hooks.slack.example/services/T/B/x",
1000 ),
1001 (
1002 "ACME_PROXY_NOTIFY__WEBHOOK__SLACK__EVENTS",
1003 "certificate_issued,certificate_revoked",
1004 ),
1005 ("ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__METHOD", "PUT"),
1006 (
1007 "ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__HEADERS__AUTHORIZATION",
1008 "Bearer syt_xxx",
1009 ),
1010 ]);
1011
1012 let config = Config::load().expect("load should succeed");
1013 assert_eq!(config.notify.webhook_enabled, vec!["slack", "matrix"]);
1014 assert_eq!(
1015 config.notify.webhook["slack"].events,
1016 vec!["certificate_issued", "certificate_revoked"]
1017 );
1018 assert_eq!(config.notify.webhook["matrix"].method, "PUT");
1019 assert_eq!(
1020 config.notify.webhook["matrix"].headers["authorization"],
1021 "Bearer syt_xxx"
1022 );
1023 assert_eq!(
1025 config.notify.webhook["slack"].method,
1026 WebhookNotifyConfig::default().method
1027 );
1028 }
1029
1030 #[test]
1036 fn an_unindexed_check_env_shape_is_a_clear_load_error() {
1037 let _guard = EnvGuard::new(&[("ACME_PROXY_FILTER__CHECK__TYPE", "allowed_ip")]);
1038
1039 let error = Config::load().unwrap_err().to_string();
1040 assert!(error.contains("filter.check.type"), "{error}");
1041 }
1042
1043 #[test]
1045 fn a_profile_can_be_declared_entirely_from_the_environment() {
1046 let file = TempConfig::new("[server]\nbase_url = \"http://acme.test\"\n");
1047 let path = file.path();
1048 let _guard = EnvGuard::new(&[
1049 ("ACME_PROXY_CONFIG", &path),
1050 ("ACME_PROXY_PROFILES__LE__ENABLED", "true"),
1051 ("ACME_PROXY_PROFILES__LE__CHALLENGE__BYPASS", "false"),
1052 ]);
1053
1054 let config = Config::load().unwrap();
1055 let profiles = config.resolve_profiles().unwrap();
1056 assert_eq!(profiles.len(), 1);
1057 assert_eq!(profiles[0].name, "le");
1058 assert!(!profiles[0].sections.challenge.bypass);
1059 }
1060
1061 #[test]
1062 fn default_values_match_expected() {
1063 let _guard = EnvGuard::new(&[]);
1064 let config = Config::load().expect("defaults alone must load");
1065
1066 assert_eq!(config.database.url, "sqlite://sqlite.db");
1067 assert_eq!(config.server.bind_address, "[::]:3000");
1068 assert_eq!(config.server.base_url, "http://localhost:3000");
1069 assert!(!config.server.tls.enabled);
1070 assert_eq!(config.server.tls.cert_path, "server.pem");
1071 assert_eq!(config.server.tls.key_path, "server.key");
1072 assert_eq!(config.server.tls.handshake_timeout_ms, 10_000);
1073 assert_eq!(config.nonce.ttl_seconds, 300);
1074 assert_eq!(config.jobs.poll_interval_ms, 1_000);
1075 assert_eq!(config.jobs.max_concurrent, 8);
1076 assert_eq!(config.jobs.max_attempts, 5);
1077 assert_eq!(config.jobs.retry_base_seconds, 30);
1078 assert_eq!(config.jobs.retry_max_seconds, 3_600);
1079 assert_eq!(config.jobs.lease_seconds, 300);
1080 assert_eq!(config.jobs.retention_days, 7);
1083 assert_eq!(config.logging.filter, "acme_proxy=info");
1084 assert!(!config.logging.json_format);
1085 assert_eq!(config.logging.target, "stdout");
1086 assert!(config.logging.ansi);
1087 assert_eq!(config.logging.span_events, "none");
1088 assert!(!config.logging.flatten_event);
1089 assert_eq!(config.order.validity_seconds, 604800);
1090 assert_eq!(config.signer.backend, "local_ca");
1091 assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
1092 assert_eq!(config.signer.local_ca.key_path, "ca.key");
1093 assert_eq!(config.signer.local_ca.key_type, "ecdsa-p256");
1094 assert_eq!(config.signer.local_ca.leaf_validity_days, 90);
1095 assert_eq!(config.challenge.enabled, vec!["http-01".to_string()]);
1096 assert!(!config.challenge.bypass);
1099 assert_eq!(config.challenge.timeout_ms, 5000);
1100 assert_eq!(config.challenge.http_01.port, 80);
1101 assert_eq!(config.challenge.http_01.https_port, 443);
1102 assert!(config.challenge.http_01.follow_redirects);
1103 assert_eq!(config.challenge.http_01.max_redirects, 5);
1104 assert_eq!(config.challenge.http_01.max_response_bytes, 4096);
1105 assert_eq!(config.challenge.tls_alpn_01.port, 443);
1106 assert!(config.filter.rules.is_empty());
1107 assert_eq!(config.filter.default, "deny");
1108 assert!(config.filter.trusted_proxies.is_empty());
1109 assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
1110 assert!(config.filter.rule.is_empty());
1111 assert!(config.filter.check.is_empty());
1112 assert!(config.filter.enabled.is_empty());
1115 assert!(config.filter.exempt_paths.is_empty());
1116 assert!(config.filter.custom_enabled.is_empty());
1117 assert!(!config.eab.enabled);
1118 assert!(config.notify.enabled.is_empty());
1119 assert!(config.notify.custom_enabled.is_empty());
1120 assert!(config.notify.custom.is_empty());
1121 assert_eq!(config.notify.template_dir, "");
1122 assert_eq!(config.notify.email.smtp_port, 587);
1123 assert_eq!(config.notify.email.smtp_security, "starttls");
1124 assert_eq!(
1125 config.notify.email.events,
1126 vec![
1127 "profile_mounted",
1128 "account_created",
1129 "account_deactivated",
1130 "certificate_issued",
1131 "certificate_revoked",
1132 "challenge_failed"
1133 ]
1134 );
1135 assert!(config.notify.webhook_enabled.is_empty());
1136 assert!(config.notify.webhook.is_empty());
1137 assert!(config.dns.resolver.is_none());
1138 assert_eq!(config.proxy.http_url, "");
1139 assert_eq!(config.proxy.https_url, "");
1140 assert!(config.proxy.no_proxy.is_empty());
1141 }
1142
1143 #[test]
1144 fn direct_construction_matches_the_loaded_defaults() {
1145 let _guard = EnvGuard::new(&[]);
1146 let loaded = Config::load().unwrap();
1147 let direct = Config::default();
1148
1149 assert_eq!(loaded.database.url, direct.database.url);
1150 assert_eq!(loaded.server.base_url, direct.server.base_url);
1151 assert_eq!(loaded.server.bind_address, direct.server.bind_address);
1152 assert_eq!(loaded.server.tls.enabled, direct.server.tls.enabled);
1153 assert_eq!(loaded.server.tls.cert_path, direct.server.tls.cert_path);
1154 assert_eq!(loaded.server.tls.key_path, direct.server.tls.key_path);
1155 assert_eq!(
1156 loaded.server.tls.handshake_timeout_ms,
1157 direct.server.tls.handshake_timeout_ms
1158 );
1159 assert_eq!(loaded.nonce.ttl_seconds, direct.nonce.ttl_seconds);
1160 assert_eq!(loaded.order.validity_seconds, direct.order.validity_seconds);
1161 assert_eq!(loaded.signer.backend, direct.signer.backend);
1162 assert_eq!(loaded.challenge.enabled, direct.challenge.enabled);
1163 assert_eq!(loaded.challenge.bypass, direct.challenge.bypass);
1164 assert_eq!(loaded.challenge.timeout_ms, direct.challenge.timeout_ms);
1165 assert_eq!(loaded.challenge.http_01.port, direct.challenge.http_01.port);
1166 assert_eq!(loaded.filter.rules, direct.filter.rules);
1167 assert_eq!(loaded.filter.default, direct.filter.default);
1168 assert_eq!(loaded.eab.enabled, direct.eab.enabled);
1169 assert_eq!(loaded.dns.resolver, direct.dns.resolver);
1170 assert_eq!(loaded.proxy.http_url, direct.proxy.http_url);
1171 assert_eq!(loaded.proxy.https_url, direct.proxy.https_url);
1172 assert_eq!(loaded.proxy.no_proxy, direct.proxy.no_proxy);
1173 }
1174
1175 #[test]
1176 fn the_example_config_documents_the_real_defaults() {
1177 let body = std::fs::read_to_string("config.toml.example").unwrap();
1180 let profiles = load_toml(&body)
1181 .resolve_profiles()
1182 .expect("config.toml.example must define at least one profile");
1183 assert_eq!(
1184 profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1185 vec!["default"]
1186 );
1187
1188 let _guard = EnvGuard::new(&[]);
1189
1190 let example = ::config::Config::builder()
1191 .add_source(
1192 ::config::File::from(std::path::Path::new("config.toml.example"))
1193 .format(::config::FileFormat::Toml),
1194 )
1195 .build()
1196 .expect("config.toml.example must be valid TOML")
1197 .try_deserialize::<Config>()
1198 .expect("config.toml.example must deserialize into Config");
1199
1200 let defaults = Config::default();
1201 assert_eq!(example.database.url, defaults.database.url);
1202 assert_eq!(example.server.bind_address, defaults.server.bind_address);
1203 assert_eq!(example.server.base_url, defaults.server.base_url);
1204 assert_eq!(
1205 example.server.max_concurrent_requests,
1206 defaults.server.max_concurrent_requests
1207 );
1208 assert_eq!(
1209 example.server.admission_wait_ms,
1210 defaults.server.admission_wait_ms
1211 );
1212 assert_eq!(
1213 example.server.request_timeout_ms,
1214 defaults.server.request_timeout_ms
1215 );
1216 assert_eq!(
1217 example.server.max_body_bytes,
1218 defaults.server.max_body_bytes
1219 );
1220 assert_eq!(example.server.tls.enabled, defaults.server.tls.enabled);
1221 assert_eq!(example.server.tls.cert_path, defaults.server.tls.cert_path);
1222 assert_eq!(example.server.tls.key_path, defaults.server.tls.key_path);
1223 assert_eq!(
1224 example.server.tls.handshake_timeout_ms,
1225 defaults.server.tls.handshake_timeout_ms
1226 );
1227 assert_eq!(example.admin.enabled, defaults.admin.enabled);
1228 assert_eq!(example.admin.bind_address, defaults.admin.bind_address);
1229 assert_eq!(example.admin.base_url, defaults.admin.base_url);
1230 assert_eq!(
1231 example.admin.session_ttl_seconds,
1232 defaults.admin.session_ttl_seconds
1233 );
1234 assert_eq!(
1235 example.admin.session_idle_timeout_seconds,
1236 defaults.admin.session_idle_timeout_seconds
1237 );
1238 assert_eq!(
1239 example.admin.login_max_attempts,
1240 defaults.admin.login_max_attempts
1241 );
1242 assert_eq!(
1243 example.admin.login_window_seconds,
1244 defaults.admin.login_window_seconds
1245 );
1246 assert_eq!(example.admin.require_mfa, defaults.admin.require_mfa);
1247 assert_eq!(example.admin.max_body_bytes, defaults.admin.max_body_bytes);
1248 assert_eq!(example.admin.page_size_max, defaults.admin.page_size_max);
1249 assert_eq!(example.admin.template_dir, defaults.admin.template_dir);
1250 assert_eq!(example.admin.tls.enabled, defaults.admin.tls.enabled);
1251 assert_eq!(example.admin.tls.cert_path, defaults.admin.tls.cert_path);
1252 assert_eq!(example.admin.tls.key_path, defaults.admin.tls.key_path);
1253 assert_eq!(
1254 example.admin.tls.handshake_timeout_ms,
1255 defaults.admin.tls.handshake_timeout_ms
1256 );
1257 assert_eq!(example.nonce.ttl_seconds, defaults.nonce.ttl_seconds);
1258 assert_eq!(example.audit.reverse_dns, defaults.audit.reverse_dns);
1259 assert_eq!(
1260 example.audit.reverse_dns_timeout_ms,
1261 defaults.audit.reverse_dns_timeout_ms
1262 );
1263 assert_eq!(example.audit.retention_days, defaults.audit.retention_days);
1264 assert_eq!(
1265 example.jobs.poll_interval_ms,
1266 defaults.jobs.poll_interval_ms
1267 );
1268 assert_eq!(example.jobs.max_concurrent, defaults.jobs.max_concurrent);
1269 assert_eq!(example.jobs.max_attempts, defaults.jobs.max_attempts);
1270 assert_eq!(
1271 example.jobs.retry_base_seconds,
1272 defaults.jobs.retry_base_seconds
1273 );
1274 assert_eq!(
1275 example.jobs.retry_max_seconds,
1276 defaults.jobs.retry_max_seconds
1277 );
1278 assert_eq!(example.jobs.lease_seconds, defaults.jobs.lease_seconds);
1279 assert_eq!(example.jobs.retention_days, defaults.jobs.retention_days);
1280 assert_eq!(example.logging.filter, defaults.logging.filter);
1281 assert_eq!(example.logging.json_format, defaults.logging.json_format);
1282 assert_eq!(example.logging.target, defaults.logging.target);
1283 assert_eq!(example.logging.ansi, defaults.logging.ansi);
1284 assert_eq!(example.logging.span_events, defaults.logging.span_events);
1285 assert_eq!(
1286 example.logging.flatten_event,
1287 defaults.logging.flatten_event
1288 );
1289 assert_eq!(
1290 example.order.validity_seconds,
1291 defaults.order.validity_seconds
1292 );
1293 assert_eq!(example.signer.backend, defaults.signer.backend);
1294 assert_eq!(
1295 example.signer.local_ca.cert_path,
1296 defaults.signer.local_ca.cert_path
1297 );
1298 assert_eq!(
1299 example.signer.local_ca.key_path,
1300 defaults.signer.local_ca.key_path
1301 );
1302 assert_eq!(
1303 example.signer.local_ca.key_type,
1304 defaults.signer.local_ca.key_type
1305 );
1306 assert_eq!(
1307 example.signer.local_ca.leaf_validity_days,
1308 defaults.signer.local_ca.leaf_validity_days
1309 );
1310 assert_eq!(
1311 example.signer.local_ca.crl_distribution_points,
1312 defaults.signer.local_ca.crl_distribution_points
1313 );
1314 assert_eq!(
1315 example.signer.local_ca.ca_issuer_urls,
1316 defaults.signer.local_ca.ca_issuer_urls
1317 );
1318 assert_eq!(
1319 example.signer.local_ca.subject.common_name,
1320 defaults.signer.local_ca.subject.common_name
1321 );
1322 assert_eq!(
1323 example.signer.local_ca.subject.organization,
1324 defaults.signer.local_ca.subject.organization
1325 );
1326 assert_eq!(
1327 example.signer.local_ca.subject.organizational_unit,
1328 defaults.signer.local_ca.subject.organizational_unit
1329 );
1330 assert_eq!(
1331 example.signer.local_ca.subject.country,
1332 defaults.signer.local_ca.subject.country
1333 );
1334 assert_eq!(
1335 example.signer.local_ca.subject.state,
1336 defaults.signer.local_ca.subject.state
1337 );
1338 assert_eq!(
1339 example.signer.local_ca.subject.locality,
1340 defaults.signer.local_ca.subject.locality
1341 );
1342 assert_eq!(example.challenge.enabled, defaults.challenge.enabled);
1343 assert_eq!(example.challenge.bypass, defaults.challenge.bypass);
1344 assert_eq!(example.challenge.timeout_ms, defaults.challenge.timeout_ms);
1345 assert_eq!(
1346 example.challenge.http_01.port,
1347 defaults.challenge.http_01.port
1348 );
1349 assert_eq!(
1350 example.challenge.http_01.https_port,
1351 defaults.challenge.http_01.https_port
1352 );
1353 assert_eq!(
1354 example.challenge.http_01.follow_redirects,
1355 defaults.challenge.http_01.follow_redirects
1356 );
1357 assert_eq!(
1358 example.challenge.http_01.max_redirects,
1359 defaults.challenge.http_01.max_redirects
1360 );
1361 assert_eq!(
1362 example.challenge.http_01.max_response_bytes,
1363 defaults.challenge.http_01.max_response_bytes
1364 );
1365 assert_eq!(
1366 example.challenge.tls_alpn_01.port,
1367 defaults.challenge.tls_alpn_01.port
1368 );
1369 assert_eq!(example.filter.rules, defaults.filter.rules);
1370 assert_eq!(example.filter.default, defaults.filter.default);
1371 assert_eq!(
1372 example.filter.forwarded_header,
1373 defaults.filter.forwarded_header
1374 );
1375 assert!(example.filter.check.is_empty());
1378 assert!(example.filter.rule.is_empty());
1379 assert_eq!(example.ipam.backend, defaults.ipam.backend);
1380 assert_eq!(example.ipam.timeout_ms, defaults.ipam.timeout_ms);
1381 assert_eq!(example.ipam.netbox.url, defaults.ipam.netbox.url);
1382 assert_eq!(example.ipam.netbox.token, defaults.ipam.netbox.token);
1383 assert_eq!(
1384 example.ipam.netbox.custom_field,
1385 defaults.ipam.netbox.custom_field
1386 );
1387 assert_eq!(example.ipam.netbox.sources, defaults.ipam.netbox.sources);
1388 assert_eq!(
1389 example.ipam.netbox.vip_roles,
1390 defaults.ipam.netbox.vip_roles
1391 );
1392 assert_eq!(
1393 example.ipam.netbox.ca_cert_path,
1394 defaults.ipam.netbox.ca_cert_path
1395 );
1396 assert_eq!(
1397 example.ipam.netbox.insecure_skip_verify,
1398 defaults.ipam.netbox.insecure_skip_verify
1399 );
1400 assert_eq!(example.ipam.phpipam.url, defaults.ipam.phpipam.url);
1401 assert_eq!(example.ipam.phpipam.app_id, defaults.ipam.phpipam.app_id);
1402 assert_eq!(example.ipam.phpipam.token, defaults.ipam.phpipam.token);
1403 assert_eq!(
1404 example.ipam.phpipam.custom_field,
1405 defaults.ipam.phpipam.custom_field
1406 );
1407 assert_eq!(example.ipam.phpipam.sources, defaults.ipam.phpipam.sources);
1408 assert_eq!(
1409 example.ipam.phpipam.ca_cert_path,
1410 defaults.ipam.phpipam.ca_cert_path
1411 );
1412 assert_eq!(
1413 example.ipam.phpipam.insecure_skip_verify,
1414 defaults.ipam.phpipam.insecure_skip_verify
1415 );
1416 assert_eq!(example.eab.enabled, defaults.eab.enabled);
1417 assert_eq!(example.notify.enabled, defaults.notify.enabled);
1418 assert_eq!(
1419 example.notify.custom_enabled,
1420 defaults.notify.custom_enabled
1421 );
1422 assert_eq!(example.notify.template_dir, defaults.notify.template_dir);
1423 assert_eq!(
1424 example.notify.email.smtp_port,
1425 defaults.notify.email.smtp_port
1426 );
1427 assert_eq!(
1428 example.notify.email.smtp_security,
1429 defaults.notify.email.smtp_security
1430 );
1431 assert_eq!(example.notify.email.events, defaults.notify.email.events);
1432 assert_eq!(
1433 example.notify.webhook_enabled,
1434 defaults.notify.webhook_enabled
1435 );
1436 assert_eq!(example.dns.resolver, defaults.dns.resolver);
1437 assert_eq!(example.proxy.http_url, defaults.proxy.http_url);
1438 assert_eq!(example.proxy.https_url, defaults.proxy.https_url);
1439 assert_eq!(example.proxy.no_proxy, defaults.proxy.no_proxy);
1440 }
1441
1442 #[test]
1443 fn load_applies_env_overrides() {
1444 let _guard = EnvGuard::new(&[("ACME_PROXY_SERVER__BASE_URL", "https://acme.example.test")]);
1445
1446 let config = Config::load().expect("load should succeed with env overrides");
1447
1448 assert_eq!(config.server.base_url, "https://acme.example.test");
1449 assert_eq!(config.server.bind_address, "[::]:3000");
1450 assert_eq!(config.nonce.ttl_seconds, 300);
1451 }
1452
1453 #[test]
1454 fn load_applies_eab_env_override() {
1455 let _guard = EnvGuard::new(&[("ACME_PROXY_EAB__ENABLED", "true")]);
1456 let config = Config::load().expect("load should succeed with eab env override");
1457 assert!(config.eab.enabled);
1458 }
1459
1460 #[test]
1461 fn load_applies_dns_resolver_env_override() {
1462 let _guard = EnvGuard::new(&[("ACME_PROXY_DNS__RESOLVER", "10.60.0.2:53")]);
1463 let config = Config::load().expect("load should succeed with a dns resolver override");
1464 assert_eq!(config.dns.resolver.as_deref(), Some("10.60.0.2:53"));
1465 }
1466
1467 #[test]
1468 fn load_treats_an_empty_string_list_env_var_as_no_values() {
1469 let _guard = EnvGuard::new(&[
1470 ("ACME_PROXY_FILTER__ENABLED", ""),
1471 ("ACME_PROXY_CHALLENGE__ENABLED", ""),
1472 ]);
1473 let config = Config::load().expect("an empty list env var must not be a parse error");
1474 assert!(config.filter.enabled.is_empty());
1475 assert!(config.challenge.enabled.is_empty());
1476 }
1477
1478 #[test]
1479 fn load_applies_doubly_nested_env_overrides() {
1480 let _guard = EnvGuard::new(&[
1481 ("ACME_PROXY_SERVER__TLS__ENABLED", "true"),
1482 ("ACME_PROXY_SERVER__TLS__CERT_PATH", "/etc/acme/tls.pem"),
1483 ("ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS", "2500"),
1484 ]);
1485
1486 let config = Config::load().expect("load should succeed with nested env overrides");
1487
1488 assert!(config.server.tls.enabled);
1489 assert_eq!(config.server.tls.cert_path, "/etc/acme/tls.pem");
1490 assert_eq!(config.server.tls.handshake_timeout_ms, 2500);
1491 assert_eq!(config.server.tls.key_path, "server.key");
1492 assert_eq!(config.server.bind_address, "[::]:3000");
1493 }
1494
1495 #[test]
1496 fn load_applies_local_ca_subject_env_overrides() {
1497 let _guard = EnvGuard::new(&[
1498 (
1499 "ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COMMON_NAME",
1500 "Custom Root CA",
1501 ),
1502 (
1503 "ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATION",
1504 "Example Corp",
1505 ),
1506 ("ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COUNTRY", "US"),
1507 ]);
1508
1509 let config =
1510 Config::load().expect("load should succeed with local_ca subject env overrides");
1511
1512 assert_eq!(
1513 config.signer.local_ca.subject.common_name.as_deref(),
1514 Some("Custom Root CA")
1515 );
1516 assert_eq!(
1517 config.signer.local_ca.subject.organization.as_deref(),
1518 Some("Example Corp")
1519 );
1520 assert_eq!(
1521 config.signer.local_ca.subject.country.as_deref(),
1522 Some("US")
1523 );
1524 assert!(config.signer.local_ca.subject.state.is_none());
1527 assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
1528 }
1529
1530 #[test]
1531 fn load_parses_list_valued_env_overrides() {
1532 let _guard = EnvGuard::new(&[
1533 ("ACME_PROXY_FILTER__RULES", "mgmt-bypass,inventory-owned"),
1534 (
1535 "ACME_PROXY_FILTER__CHECK__NET__ALLOW",
1536 "192.168.1.0/24,fd00::/8",
1537 ),
1538 ]);
1539
1540 let config = Config::load().expect("load should succeed with list env overrides");
1541
1542 assert_eq!(config.filter.rules, vec!["mgmt-bypass", "inventory-owned"]);
1543 assert_eq!(
1544 config.filter.check["net"].allow,
1545 vec!["192.168.1.0/24", "fd00::/8"]
1546 );
1547 assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
1548 }
1549
1550 #[test]
1551 fn every_registered_list_key_round_trips_through_the_environment() {
1552 let known = LIST_KEYS
1553 .iter()
1554 .filter(|key| Config::default().list_key(key).is_some())
1555 .count();
1556 assert_eq!(
1557 known,
1558 LIST_KEYS.len(),
1559 "every LIST_KEYS entry must be readable via `list_key`"
1560 );
1561 assert_eq!(
1562 LIST_KEYS.len(),
1563 20,
1564 "a config `Vec` field was added or removed: update LIST_KEYS, `list_key`, \
1565 config.toml.example and this count together"
1566 );
1567
1568 for key in LIST_KEYS {
1569 let (first, second) = match *key {
1570 "challenge.enabled" => ("http-01", "dns-01"),
1571 "filter.rules" => ("mgmt-bypass", "inventory-owned"),
1572 "filter.exempt_paths" => ("/health", "/directory"),
1573 _ => ("first-value", "second-value"),
1574 };
1575
1576 let env_key: &'static str = Box::leak(
1577 format!("ACME_PROXY_{}", key.replace('.', "__").to_uppercase()).into_boxed_str(),
1578 );
1579 let _guard = EnvGuard::new(&[(env_key, &format!("{first},{second}"))]);
1580
1581 let config = Config::load().expect("load should succeed");
1582 let actual = config.list_key(key);
1583 assert_eq!(
1584 actual,
1585 Some(vec![first.to_string(), second.to_string()]),
1586 "{key} (via {env_key}) did not parse as a two-element list; \
1587 is it registered in LIST_KEYS and reachable from `list_key`?"
1588 );
1589 }
1590 }
1591}