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