1use std::collections::BTreeMap;
26
27use serde::Deserialize;
28
29pub mod types;
30pub use types::*;
31
32#[derive(Debug, Clone, Default, Deserialize)]
42#[serde(default)]
43pub struct Config {
44 pub database: DatabaseConfig,
45 pub server: ServerConfig,
46 pub admin: AdminConfig,
49 pub nonce: NonceConfig,
50 pub audit: AuditConfig,
53 pub jobs: JobsConfig,
56 pub metrics: MetricsConfig,
59 pub logging: LoggingConfig,
60 pub order: OrderConfig,
61 pub signer: SignerConfig,
62 pub challenge: ChallengeConfig,
63 pub filter: FilterConfig,
64 pub ipam: IpamConfig,
68 pub eab: EabConfig,
69 pub notify: NotifyConfig,
70 pub meta: MetaConfig,
71 pub dns: DnsConfig,
72 pub proxy: ProxyConfig,
76 #[serde(skip)]
83 raw: Option<::config::Config>,
84}
85
86const PROFILE_SECTIONS: &[&str] = &[
88 "signer",
89 "filter",
90 "ipam",
91 "challenge",
92 "eab",
93 "order",
94 "notify",
95 "meta",
96];
97
98pub(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
117pub 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
161pub 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
186fn valid_profile_name(name: &str) -> bool {
189 valid_config_key_name(name)
190}
191
192impl Config {
193 pub fn load() -> Result<Self, ::config::ConfigError> {
195 let path = std::env::var("ACME_PROXY_CONFIG").unwrap_or_else(|_| "config".into());
196
197 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 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 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 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 (None, _) => {}
283 }
284 }
285
286 ProfileSections::deserialize(::config::Value::new(
287 None,
288 ::config::ValueKind::Table(merged),
289 ))
290 }
291
292 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
304fn 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
312fn 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#[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 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 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 #[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 assert_eq!(profiles[0].sections.signer.backend, "local_ca");
428 }
429
430 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 assert_eq!(config.proxy.http_url, ProxyConfig::default().http_url);
790 }
791
792 #[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 #[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 #[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 = ¬ify.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 = ¬ify.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 #[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 assert_eq!(
950 config.notify.webhook["slack"].method,
951 WebhookNotifyConfig::default().method
952 );
953 }
954
955 #[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 #[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 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 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 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 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 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 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 #[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 assert!(config.notify.enabled.is_empty());
1546 }
1547
1548 #[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 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}