use std::collections::BTreeMap;
use serde::Deserialize;
pub mod types;
pub use types::*;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub database: DatabaseConfig,
pub server: ServerConfig,
pub admin: AdminConfig,
pub nonce: NonceConfig,
pub audit: AuditConfig,
pub jobs: JobsConfig,
pub metrics: MetricsConfig,
pub logging: LoggingConfig,
pub order: OrderConfig,
pub signer: SignerConfig,
pub challenge: ChallengeConfig,
pub filter: FilterConfig,
pub ipam: IpamConfig,
pub eab: EabConfig,
pub notify: NotifyConfig,
pub meta: MetaConfig,
pub dns: DnsConfig,
pub proxy: ProxyConfig,
#[serde(skip)]
raw: Option<::config::Config>,
}
const PROFILE_SECTIONS: &[&str] = &[
"signer",
"filter",
"ipam",
"challenge",
"eab",
"order",
"notify",
"meta",
];
pub(crate) fn valid_config_key_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn resolve_named_entries<'a, T>(
table: &str,
enabled_key: &str,
backend: &str,
entries: &'a BTreeMap<String, T>,
enabled: &'a [String],
) -> anyhow::Result<Vec<(&'a str, &'a T)>> {
validate_key_names(table, entries.keys())?;
let subsystem = table.split('.').next().unwrap_or(table);
anyhow::ensure!(
!enabled.is_empty(),
"{table} is enabled but {enabled_key} is empty; \
list the [{table}.<name>] entries to use, or remove `{backend}` from \
{subsystem}.enabled"
);
enabled
.iter()
.map(|name| {
let entry = entries.get(name).ok_or_else(|| {
anyhow::anyhow!(
"{enabled_key} names `{name}`, but no [{table}.{name}] is configured"
)
})?;
Ok((name.as_str(), entry))
})
.collect()
}
pub fn validate_key_names<'a>(
prefix: &str,
keys: impl Iterator<Item = &'a String>,
) -> anyhow::Result<()> {
let env_prefix = prefix.to_ascii_uppercase().replace('.', "__");
for key in keys {
anyhow::ensure!(
valid_config_key_name(key),
"{prefix}.{key}: invalid name (use lowercase letters, digits and `-` — the \
name is also an environment variable segment, and the config crate \
lowercases those, so anything else could silently name a different entry \
through ACME_PROXY_{env_prefix}__… than in the file)"
);
}
Ok(())
}
fn valid_profile_name(name: &str) -> bool {
valid_config_key_name(name)
}
impl Config {
pub fn load() -> Result<Self, ::config::ConfigError> {
let path = std::env::var("ACME_PROXY_CONFIG").unwrap_or_else(|_| "config".into());
let environment = ::config::Environment::with_prefix("ACME_PROXY")
.prefix_separator("_")
.separator("__")
.try_parsing(true);
let built = ::config::Config::builder()
.add_source(::config::File::with_name(&path).required(false))
.add_source(environment)
.build()?;
let mut config: Config = built.clone().try_deserialize()?;
config.raw = Some(built);
Ok(config)
}
pub fn resolve_profiles(&self) -> anyhow::Result<Vec<ProfileConfig>> {
let raw_profiles = self
.raw
.as_ref()
.and_then(|raw| raw.get::<::config::Value>("profiles").ok())
.map(as_table)
.unwrap_or_default();
let mut profiles = Vec::new();
for (name, raw_profile) in raw_profiles.into_iter().collect::<BTreeMap<_, _>>() {
anyhow::ensure!(
valid_profile_name(&name),
"invalid profile name `{name}`: use lowercase letters, digits and `-` \
(the name is both a URL segment and an environment variable segment)"
);
let sections = self.merged_sections(&raw_profile).map_err(|error| {
anyhow::anyhow!("profile `{name}`: invalid configuration: {error}")
})?;
if sections.enabled {
profiles.push(ProfileConfig { name, sections });
}
}
anyhow::ensure!(!profiles.is_empty(), self.no_profiles_message());
Ok(profiles)
}
fn merged_sections(
&self,
raw_profile: &::config::Value,
) -> Result<ProfileSections, ::config::ConfigError> {
let profile_table = as_table(raw_profile.clone());
let mut merged = profile_table.clone();
for section in PROFILE_SECTIONS {
let global = self
.raw
.as_ref()
.and_then(|raw| raw.get::<::config::Value>(section).ok());
let overlay = profile_table.get(*section).cloned();
match (global, overlay) {
(Some(global), Some(overlay)) => {
merged.insert((*section).to_string(), merge_values(&global, &overlay));
}
(Some(global), None) => {
merged.insert((*section).to_string(), global);
}
(None, _) => {}
}
}
ProfileSections::deserialize(::config::Value::new(
None,
::config::ValueKind::Table(merged),
))
}
fn no_profiles_message(&self) -> String {
format!(
"no enabled [profiles] — acme-proxy serves nothing without one. Minimal config:\n\
\n [profiles.default]\n\n\
Its ACME directory is then at {}/profile/default/directory.",
self.server.base_url
)
}
}
fn as_table(value: ::config::Value) -> ::config::Map<String, ::config::Value> {
match value.kind {
::config::ValueKind::Table(table) => table,
_ => ::config::Map::new(),
}
}
fn merge_values(base: &::config::Value, overlay: &::config::Value) -> ::config::Value {
match (&base.kind, &overlay.kind) {
(::config::ValueKind::Table(base), ::config::ValueKind::Table(overlay)) => {
let mut merged = base.clone();
for (key, value) in overlay {
let merged_value = match merged.get(key) {
Some(existing) => merge_values(existing, value),
None => value.clone(),
};
merged.insert(key.clone(), merged_value);
}
::config::Value::new(None, ::config::ValueKind::Table(merged))
}
_ => overlay.clone(),
}
}
#[cfg(any(test, feature = "test-util"))]
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::EnvGuard;
struct TempConfig {
dir: crate::testutil::TempDir,
}
impl TempConfig {
fn new(body: &str) -> Self {
let dir = crate::testutil::TempDir::new("cfg");
dir.write("config.toml", body);
Self { dir }
}
fn path(&self) -> String {
self.dir.join("config").to_string_lossy().into_owned()
}
}
fn load_toml(body: &str) -> Config {
let file = TempConfig::new(body);
let path = file.path();
let _guard = EnvGuard::new(&[("ACME_PROXY_CONFIG", &path)]);
Config::load().expect("the configuration must load")
}
#[test]
fn the_removed_filter_sub_tables_still_parse() {
let config = load_toml(
r#"
[filter.allowed_ip]
allow = ["10.0.0.0/8"]
[filter.reverse_dns]
suffixes = ["example.com"]
[filter.identifiers]
allow = ["*.example.com"]
[filter.custom.hook]
command = "/bin/true"
[filter.netbox]
url = "https://netbox.example.com"
[profiles.le]
"#,
);
assert!(config.filter.allowed_ip.is_some());
assert!(config.filter.reverse_dns.is_some());
assert!(config.filter.identifiers.is_some());
assert!(config.filter.custom.is_some());
assert!(config.filter.netbox.is_some());
}
#[test]
fn a_bare_profile_table_inherits_every_global_section() {
let config = load_toml(
r#"
[challenge]
enabled = ["dns-01"]
bypass = false
[profiles.le]
"#,
);
let profiles = config.resolve_profiles().unwrap();
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].name, "le");
assert_eq!(profiles[0].sections.challenge.enabled, vec!["dns-01"]);
assert!(!profiles[0].sections.challenge.bypass);
assert_eq!(profiles[0].sections.signer.backend, "local_ca");
}
#[test]
fn overriding_one_key_keeps_the_rest_of_the_global_section() {
let config = load_toml(
r#"
[challenge]
enabled = ["dns-01"]
bypass = true
timeout_ms = 1234
[profiles.strict]
challenge.bypass = false
"#,
);
let profiles = config.resolve_profiles().unwrap();
let challenge = &profiles[0].sections.challenge;
assert!(!challenge.bypass, "the profile's own value wins");
assert_eq!(
challenge.enabled,
vec!["dns-01"],
"the rest of the section is inherited, not reset to the default"
);
assert_eq!(challenge.timeout_ms, 1234);
}
#[test]
fn two_profiles_may_name_different_inventories() {
let config = load_toml(
r#"
[ipam]
backend = "netbox"
timeout_ms = 1234
[ipam.netbox]
url = "https://netbox.example.com"
token = "t0ken"
[ipam.phpipam]
url = "https://ipam.example.com"
token = "appcode"
[profiles.dmz]
[profiles.internal]
ipam.backend = "phpipam"
"#,
);
let profiles = config.resolve_profiles().unwrap();
let by_name = |name: &str| {
profiles
.iter()
.find(|p| p.name == name)
.map(|p| &p.sections.ipam)
.unwrap()
};
assert_eq!(by_name("dmz").backend, "netbox");
assert_eq!(by_name("internal").backend, "phpipam");
assert_eq!(by_name("internal").timeout_ms, 1234);
assert_eq!(by_name("internal").phpipam.url, "https://ipam.example.com");
assert_eq!(by_name("internal").netbox.url, "https://netbox.example.com");
}
#[test]
fn a_profile_may_narrow_the_ipam_sources_alone() {
let config = load_toml(
r#"
[ipam]
backend = "netbox"
[ipam.netbox]
url = "https://netbox.example.com"
token = "t0ken"
sources = ["dns_name", "custom_field", "device", "fhrp"]
[profiles.strict]
ipam.netbox.sources = ["dns_name"]
"#,
);
let netbox = &config.resolve_profiles().unwrap()[0].sections.ipam.netbox;
assert_eq!(netbox.sources, vec!["dns_name"]);
assert_eq!(netbox.url, "https://netbox.example.com");
assert_eq!(netbox.token, "t0ken");
}
#[test]
fn a_profile_can_override_one_notify_key_and_keep_the_rest() {
let config = load_toml(
r#"
[notify]
enabled = ["email"]
email.smtp_host = "mail.example.com"
email.smtp_port = 2525
[profiles.staging]
notify.email.smtp_host = "mail.staging.example.com"
"#,
);
let profiles = config.resolve_profiles().unwrap();
let notify = &profiles[0].sections.notify;
assert_eq!(notify.enabled, vec!["email"], "inherited from global");
assert_eq!(notify.email.smtp_host, "mail.staging.example.com");
assert_eq!(
notify.email.smtp_port, 2525,
"the rest of the section is inherited, not reset to the default"
);
}
#[test]
fn a_profile_section_replaces_an_inherited_list_wholesale() {
let config = load_toml(
r#"
[filter]
check.names.deny = ["a.example", "b.example"]
[profiles.narrow]
filter.check.names.deny = ["c.example"]
"#,
);
let profiles = config.resolve_profiles().unwrap();
assert_eq!(
profiles[0].sections.filter.check["names"].deny,
vec!["c.example"],
"arrays are replaced, never merged"
);
}
#[test]
fn a_profile_overrides_one_field_of_an_inherited_rule() {
let config = load_toml(
r#"
[filter]
rules = ["inventory"]
rule.inventory.when = "inv"
rule.inventory.then = "allow"
rule.inventory.message = "not yours"
[profiles.staging]
filter.rule.inventory.mode = "warn"
"#,
);
let rule = &config.resolve_profiles().unwrap()[0].sections.filter.rule["inventory"];
assert_eq!(rule.mode, "warn");
assert_eq!(rule.when, "inv", "the condition is inherited");
assert_eq!(rule.message, "not yours", "so is the message");
}
#[test]
fn profiles_are_resolved_in_name_order() {
let config = load_toml(
r#"
[profiles.zulu]
[profiles.alpha]
[profiles.mike]
"#,
);
let names: Vec<_> = config
.resolve_profiles()
.unwrap()
.into_iter()
.map(|p| p.name)
.collect();
assert_eq!(names, vec!["alpha", "mike", "zulu"]);
}
#[test]
fn a_disabled_profile_is_not_mounted() {
let config = load_toml(
r#"
[profiles.live]
[profiles.parked]
enabled = false
"#,
);
let names: Vec<_> = config
.resolve_profiles()
.unwrap()
.into_iter()
.map(|p| p.name)
.collect();
assert_eq!(names, vec!["live"]);
}
#[test]
fn a_configuration_with_no_profile_refuses_to_resolve() {
for body in ["", "[profiles]\n", "[profiles.parked]\nenabled = false\n"] {
let config = load_toml(body);
let error = config
.resolve_profiles()
.expect_err("a server with no endpoint must not start")
.to_string();
assert!(error.contains("[profiles.default]"), "{error}");
assert!(
error.contains("/profile/default/directory"),
"the error must show where the endpoint would answer: {error}"
);
}
}
#[test]
fn a_profile_name_outside_the_url_charset_is_refused() {
for name in ["Le", "my_profile", "we.b"] {
let config = load_toml(&format!("[profiles.\"{name}\"]\n"));
let error = config
.resolve_profiles()
.expect_err("{name} must be refused")
.to_string();
assert!(error.contains("invalid profile name"), "{error}");
}
}
#[test]
fn a_profile_list_key_round_trips_through_the_environment() {
let file = TempConfig::new("[profiles.le]\n");
let path = file.path();
let _guard = EnvGuard::new(&[
("ACME_PROXY_CONFIG", &path),
(
"ACME_PROXY_PROFILES__LE__CHALLENGE__ENABLED",
"dns-01,http-01",
),
]);
let config = Config::load().unwrap();
let profiles = config.resolve_profiles().unwrap();
assert_eq!(
profiles[0].sections.challenge.enabled,
vec!["dns-01".to_string(), "http-01".to_string()]
);
}
#[test]
fn the_admin_section_round_trips_through_the_environment() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_ADMIN__ENABLED", "true"),
("ACME_PROXY_ADMIN__BIND_ADDRESS", "127.0.0.1:9999"),
("ACME_PROXY_ADMIN__BASE_URL", "https://admin.example.com"),
("ACME_PROXY_ADMIN__SESSION_TTL_SECONDS", "60"),
("ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS", "1"),
("ACME_PROXY_ADMIN__REQUIRE_MFA", "true"),
("ACME_PROXY_ADMIN__PAGE_SIZE_MAX", "10"),
("ACME_PROXY_ADMIN__TLS__ENABLED", "true"),
("ACME_PROXY_ADMIN__TLS__CERT_PATH", "/tmp/admin.pem"),
]);
let config = Config::load().unwrap();
assert!(config.admin.enabled);
assert_eq!(config.admin.bind_address, "127.0.0.1:9999");
assert_eq!(config.admin.base_url, "https://admin.example.com");
assert_eq!(config.admin.session_ttl_seconds, 60);
assert_eq!(config.admin.login_max_attempts, 1);
assert!(config.admin.require_mfa);
assert_eq!(config.admin.page_size_max, 10);
assert!(config.admin.tls.enabled);
assert_eq!(config.admin.tls.cert_path, "/tmp/admin.pem");
assert_eq!(
config.admin.tls.key_path,
AdminConfig::default().tls.key_path
);
assert_eq!(
config.admin.session_idle_timeout_seconds,
AdminConfig::default().session_idle_timeout_seconds
);
}
#[test]
fn the_admin_filter_round_trips_through_the_environment() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_ADMIN__FILTER__RULES", "mgmt"),
("ACME_PROXY_ADMIN__FILTER__TRUSTED_PROXIES", "172.16.0.0/12"),
("ACME_PROXY_ADMIN__FILTER__CHECK__NET__TYPE", "allowed_ip"),
(
"ACME_PROXY_ADMIN__FILTER__CHECK__NET__ALLOW",
"10.20.0.0/24,127.0.0.1/32",
),
("ACME_PROXY_ADMIN__FILTER__RULE__MGMT__WHEN", "net"),
("ACME_PROXY_ADMIN__FILTER__RULE__MGMT__THEN", "allow"),
("ACME_PROXY_FILTER__RULES", "acme-only"),
]);
let config = Config::load().unwrap();
let filter = &config.admin.filter;
assert_eq!(filter.rules, vec!["mgmt"]);
assert_eq!(filter.trusted_proxies, vec!["172.16.0.0/12"]);
assert_eq!(filter.check["net"].r#type, "allowed_ip");
assert_eq!(
filter.check["net"].allow,
vec!["10.20.0.0/24", "127.0.0.1/32"]
);
assert_eq!(filter.rule["mgmt"].when, "net");
assert_eq!(filter.default, FilterConfig::default().default);
assert_eq!(config.filter.rules, vec!["acme-only"]);
}
#[test]
fn the_proxy_section_round_trips_through_the_environment() {
let _guard = EnvGuard::new(&[
(
"ACME_PROXY_PROXY__HTTPS_URL",
"http://proxy.example.com:3128",
),
("ACME_PROXY_PROXY__NO_PROXY", "10.0.0.0/8,.internal.example"),
]);
let config = Config::load().unwrap();
assert_eq!(config.proxy.https_url, "http://proxy.example.com:3128");
assert_eq!(
config.proxy.no_proxy,
vec!["10.0.0.0/8", ".internal.example"]
);
assert_eq!(config.proxy.http_url, ProxyConfig::default().http_url);
}
#[test]
fn env_configures_multiple_named_checks_with_all_their_lists() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_FILTER__RULES", "main"),
("ACME_PROXY_FILTER__CHECK__MAIN__TYPE", "custom"),
(
"ACME_PROXY_FILTER__CHECK__MAIN__SCRIPT_PATH",
"/path/to/one.sh",
),
("ACME_PROXY_FILTER__CHECK__MAIN__ARGS", "foo,bar"),
("ACME_PROXY_FILTER__CHECK__MAIN__STAGES", "connection"),
("ACME_PROXY_FILTER__CHECK__EXTRA__TYPE", "identifiers"),
(
"ACME_PROXY_FILTER__CHECK__EXTRA__ALLOW",
"*.example.com,example.com",
),
("ACME_PROXY_FILTER__CHECK__EXTRA__DENY_REGEX", "secret\\..*"),
("ACME_PROXY_FILTER__CHECK__EXTRA__ALLOWED_TYPES", "dns"),
("ACME_PROXY_FILTER__CHECK__EXTRA__KIDS", "k1,k2"),
]);
let config = Config::load().expect("load should succeed");
let check = &config.filter.check;
assert_eq!(check["main"].script_path, "/path/to/one.sh");
assert_eq!(check["main"].args, vec!["foo", "bar"]);
assert_eq!(check["main"].stages, vec!["connection"]);
assert_eq!(check["extra"].allow, vec!["*.example.com", "example.com"]);
assert_eq!(check["extra"].deny_regex, vec!["secret\\..*"]);
assert_eq!(check["extra"].allowed_types, vec!["dns"]);
assert_eq!(check["extra"].kids, vec!["k1", "k2"]);
assert_eq!(config.filter.rules, vec!["main"]);
}
#[test]
fn env_configures_a_profile_scoped_named_check() {
let file = TempConfig::new("[profiles.le]\n");
let path = file.path();
let _guard = EnvGuard::new(&[
("ACME_PROXY_CONFIG", &path),
("ACME_PROXY_PROFILES__LE__FILTER__RULES", "only"),
(
"ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__TYPE",
"custom",
),
(
"ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__SCRIPT_PATH",
"/path/to/profile.sh",
),
(
"ACME_PROXY_PROFILES__LE__FILTER__CHECK__MAIN__ARGS",
"a,b,c",
),
]);
let config = Config::load().unwrap();
let profiles = config.resolve_profiles().unwrap();
let check = &profiles[0].sections.filter.check;
assert_eq!(check["main"].script_path, "/path/to/profile.sh");
assert_eq!(check["main"].args, vec!["a", "b", "c"]);
assert_eq!(profiles[0].sections.filter.rules, vec!["only"]);
}
#[test]
fn env_configures_profile_scoped_notify_tables() {
let file = TempConfig::new("[profiles.le]\n");
let path = file.path();
let _guard = EnvGuard::new(&[
("ACME_PROXY_CONFIG", &path),
(
"ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__SCRIPT_PATH",
"/usr/local/bin/page.sh",
),
(
"ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__ARGS",
"--urgent,--team=netops",
),
(
"ACME_PROXY_PROFILES__LE__NOTIFY__CUSTOM__PAGER__EVENTS",
"certificate_issued,challenge_failed",
),
(
"ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__URL",
"https://hooks.example.com/T/B/xyz",
),
(
"ACME_PROXY_PROFILES__LE__NOTIFY__WEBHOOK__SLACK__EVENTS",
"certificate_revoked",
),
]);
let config = Config::load().unwrap();
let profiles = config.resolve_profiles().unwrap();
let notify = &profiles[0].sections.notify;
let pager = ¬ify.custom["pager"];
assert_eq!(pager.script_path, "/usr/local/bin/page.sh");
assert_eq!(pager.args, vec!["--urgent", "--team=netops"]);
assert_eq!(
pager.events,
vec!["certificate_issued", "challenge_failed"],
"a list key under two runtime names must reach its field"
);
let slack = ¬ify.webhook["slack"];
assert_eq!(slack.url, "https://hooks.example.com/T/B/xyz");
assert_eq!(slack.events, vec!["certificate_revoked"]);
}
#[test]
fn env_configures_a_named_webhook_with_its_list_and_its_header_map() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_NOTIFY__ENABLED", "webhook"),
("ACME_PROXY_NOTIFY__WEBHOOK_ENABLED", "slack,matrix"),
(
"ACME_PROXY_NOTIFY__WEBHOOK__SLACK__URL",
"https://hooks.slack.example/services/T/B/x",
),
(
"ACME_PROXY_NOTIFY__WEBHOOK__SLACK__EVENTS",
"certificate_issued,certificate_revoked",
),
("ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__METHOD", "PUT"),
(
"ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__HEADERS__AUTHORIZATION",
"Bearer syt_xxx",
),
]);
let config = Config::load().expect("load should succeed");
assert_eq!(config.notify.webhook_enabled, vec!["slack", "matrix"]);
assert_eq!(
config.notify.webhook["slack"].events,
vec!["certificate_issued", "certificate_revoked"]
);
assert_eq!(config.notify.webhook["matrix"].method, "PUT");
assert_eq!(
config.notify.webhook["matrix"].headers["authorization"],
"Bearer syt_xxx"
);
assert_eq!(
config.notify.webhook["slack"].method,
WebhookNotifyConfig::default().method
);
}
#[test]
fn an_unindexed_check_env_shape_is_a_clear_load_error() {
let _guard = EnvGuard::new(&[("ACME_PROXY_FILTER__CHECK__TYPE", "allowed_ip")]);
let error = Config::load().unwrap_err().to_string();
assert!(error.contains("filter.check.type"), "{error}");
}
#[test]
fn a_profile_can_be_declared_entirely_from_the_environment() {
let file = TempConfig::new("[server]\nbase_url = \"http://acme.test\"\n");
let path = file.path();
let _guard = EnvGuard::new(&[
("ACME_PROXY_CONFIG", &path),
("ACME_PROXY_PROFILES__LE__ENABLED", "true"),
("ACME_PROXY_PROFILES__LE__CHALLENGE__BYPASS", "false"),
]);
let config = Config::load().unwrap();
let profiles = config.resolve_profiles().unwrap();
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].name, "le");
assert!(!profiles[0].sections.challenge.bypass);
}
#[test]
fn default_values_match_expected() {
let _guard = EnvGuard::new(&[]);
let config = Config::load().expect("defaults alone must load");
assert_eq!(config.database.url, "sqlite://sqlite.db");
assert_eq!(config.server.bind_address, "[::]:3000");
assert_eq!(config.server.base_url, "http://localhost:3000");
assert!(!config.server.tls.enabled);
assert_eq!(config.server.tls.cert_path, "server.pem");
assert_eq!(config.server.tls.key_path, "server.key");
assert_eq!(config.server.tls.handshake_timeout_ms, 10_000);
assert_eq!(config.nonce.ttl_seconds, 300);
assert_eq!(config.jobs.poll_interval_ms, 1_000);
assert_eq!(config.jobs.max_concurrent, 8);
assert_eq!(config.jobs.max_attempts, 5);
assert_eq!(config.jobs.retry_base_seconds, 30);
assert_eq!(config.jobs.retry_max_seconds, 3_600);
assert_eq!(config.jobs.lease_seconds, 300);
assert_eq!(config.jobs.retention_days, 7);
assert_eq!(config.logging.filter, "acme_proxy=info");
assert!(!config.logging.json_format);
assert_eq!(config.logging.target, "stdout");
assert!(config.logging.ansi);
assert_eq!(config.logging.span_events, "none");
assert!(!config.logging.flatten_event);
assert_eq!(config.order.validity_seconds, 604800);
assert_eq!(config.signer.backend, "local_ca");
assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
assert_eq!(config.signer.local_ca.key_path, "ca.key");
assert_eq!(config.signer.local_ca.key_type, "ecdsa-p256");
assert_eq!(config.signer.local_ca.leaf_validity_days, 90);
assert_eq!(config.challenge.enabled, vec!["http-01".to_string()]);
assert!(!config.challenge.bypass);
assert_eq!(config.challenge.timeout_ms, 5000);
assert_eq!(config.challenge.http_01.port, 80);
assert_eq!(config.challenge.http_01.https_port, 443);
assert!(config.challenge.http_01.follow_redirects);
assert_eq!(config.challenge.http_01.max_redirects, 5);
assert_eq!(config.challenge.http_01.max_response_bytes, 4096);
assert_eq!(config.challenge.tls_alpn_01.port, 443);
assert!(config.filter.rules.is_empty());
assert_eq!(config.filter.default, "deny");
assert!(config.filter.trusted_proxies.is_empty());
assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
assert!(config.filter.rule.is_empty());
assert!(config.filter.check.is_empty());
assert!(config.filter.enabled.is_empty());
assert!(config.filter.exempt_paths.is_empty());
assert!(config.filter.custom_enabled.is_empty());
assert!(config.filter.allowed_ip.is_none());
assert!(config.filter.reverse_dns.is_none());
assert!(config.filter.identifiers.is_none());
assert!(config.filter.custom.is_none());
assert!(config.filter.netbox.is_none());
assert!(!config.eab.enabled);
assert!(config.notify.enabled.is_empty());
assert!(config.notify.custom_enabled.is_empty());
assert!(config.notify.custom.is_empty());
assert_eq!(config.notify.template_dir, "");
assert_eq!(config.notify.expiry.lead_days, 0);
assert_eq!(config.notify.expiry.interval_days, 7);
assert_eq!(config.notify.expiry.max_entries, 50);
assert_eq!(config.notify.email.smtp_port, 587);
assert_eq!(config.notify.email.smtp_security, "starttls");
assert_eq!(
config.notify.email.events,
vec![
"profile_mounted",
"account_created",
"account_deactivated",
"certificate_issued",
"certificate_revoked",
"challenge_failed",
"certificates_expiring",
"admin_sign_in",
"admin_credential_changed"
]
);
assert!(config.notify.webhook_enabled.is_empty());
assert!(config.notify.webhook.is_empty());
assert!(config.dns.resolver.is_none());
assert_eq!(config.proxy.http_url, "");
assert_eq!(config.proxy.https_url, "");
assert!(config.proxy.no_proxy.is_empty());
}
#[test]
fn direct_construction_matches_the_loaded_defaults() {
let _guard = EnvGuard::new(&[]);
let loaded = Config::load().unwrap();
let direct = Config::default();
assert_eq!(loaded.database.url, direct.database.url);
assert_eq!(loaded.server.base_url, direct.server.base_url);
assert_eq!(loaded.server.bind_address, direct.server.bind_address);
assert_eq!(loaded.server.tls.enabled, direct.server.tls.enabled);
assert_eq!(loaded.server.tls.cert_path, direct.server.tls.cert_path);
assert_eq!(loaded.server.tls.key_path, direct.server.tls.key_path);
assert_eq!(
loaded.server.tls.handshake_timeout_ms,
direct.server.tls.handshake_timeout_ms
);
assert_eq!(loaded.nonce.ttl_seconds, direct.nonce.ttl_seconds);
assert_eq!(loaded.order.validity_seconds, direct.order.validity_seconds);
assert_eq!(loaded.signer.backend, direct.signer.backend);
assert_eq!(loaded.challenge.enabled, direct.challenge.enabled);
assert_eq!(loaded.challenge.bypass, direct.challenge.bypass);
assert_eq!(loaded.challenge.timeout_ms, direct.challenge.timeout_ms);
assert_eq!(loaded.challenge.http_01.port, direct.challenge.http_01.port);
assert_eq!(loaded.filter.rules, direct.filter.rules);
assert_eq!(loaded.filter.default, direct.filter.default);
assert_eq!(loaded.eab.enabled, direct.eab.enabled);
assert_eq!(loaded.dns.resolver, direct.dns.resolver);
assert_eq!(loaded.proxy.http_url, direct.proxy.http_url);
assert_eq!(loaded.proxy.https_url, direct.proxy.https_url);
assert_eq!(loaded.proxy.no_proxy, direct.proxy.no_proxy);
}
#[test]
fn the_example_config_documents_the_real_defaults() {
let example_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config.toml.example");
let body = std::fs::read_to_string(&example_path).unwrap();
let profiles = load_toml(&body)
.resolve_profiles()
.expect("config.toml.example must define at least one profile");
assert_eq!(
profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
vec!["default"]
);
let _guard = EnvGuard::new(&[]);
let example = ::config::Config::builder()
.add_source(
::config::File::from(example_path.as_path()).format(::config::FileFormat::Toml),
)
.build()
.expect("config.toml.example must be valid TOML")
.try_deserialize::<Config>()
.expect("config.toml.example must deserialize into Config");
let defaults = Config::default();
assert_eq!(example.database.url, defaults.database.url);
assert_eq!(example.server.bind_address, defaults.server.bind_address);
assert_eq!(example.server.base_url, defaults.server.base_url);
assert_eq!(
example.server.max_concurrent_requests,
defaults.server.max_concurrent_requests
);
assert_eq!(
example.server.admission_wait_ms,
defaults.server.admission_wait_ms
);
assert_eq!(
example.server.request_timeout_ms,
defaults.server.request_timeout_ms
);
assert_eq!(
example.server.max_body_bytes,
defaults.server.max_body_bytes
);
assert_eq!(example.server.tls.enabled, defaults.server.tls.enabled);
assert_eq!(example.server.tls.cert_path, defaults.server.tls.cert_path);
assert_eq!(example.server.tls.key_path, defaults.server.tls.key_path);
assert_eq!(
example.server.tls.handshake_timeout_ms,
defaults.server.tls.handshake_timeout_ms
);
assert_eq!(example.admin.enabled, defaults.admin.enabled);
assert_eq!(example.admin.bind_address, defaults.admin.bind_address);
assert_eq!(example.admin.base_url, defaults.admin.base_url);
assert_eq!(
example.admin.session_ttl_seconds,
defaults.admin.session_ttl_seconds
);
assert_eq!(
example.admin.session_idle_timeout_seconds,
defaults.admin.session_idle_timeout_seconds
);
assert_eq!(
example.admin.login_max_attempts,
defaults.admin.login_max_attempts
);
assert_eq!(
example.admin.login_window_seconds,
defaults.admin.login_window_seconds
);
assert_eq!(example.admin.require_mfa, defaults.admin.require_mfa);
assert_eq!(example.admin.max_body_bytes, defaults.admin.max_body_bytes);
assert_eq!(example.admin.page_size_max, defaults.admin.page_size_max);
assert_eq!(example.admin.template_dir, defaults.admin.template_dir);
assert_eq!(example.admin.tls.enabled, defaults.admin.tls.enabled);
assert_eq!(example.admin.tls.cert_path, defaults.admin.tls.cert_path);
assert_eq!(example.admin.tls.key_path, defaults.admin.tls.key_path);
assert_eq!(
example.admin.tls.handshake_timeout_ms,
defaults.admin.tls.handshake_timeout_ms
);
assert_eq!(example.nonce.ttl_seconds, defaults.nonce.ttl_seconds);
assert_eq!(example.audit.reverse_dns, defaults.audit.reverse_dns);
assert_eq!(
example.audit.reverse_dns_timeout_ms,
defaults.audit.reverse_dns_timeout_ms
);
assert_eq!(example.audit.retention_days, defaults.audit.retention_days);
assert_eq!(
example.jobs.poll_interval_ms,
defaults.jobs.poll_interval_ms
);
assert_eq!(example.jobs.max_concurrent, defaults.jobs.max_concurrent);
assert_eq!(example.jobs.max_attempts, defaults.jobs.max_attempts);
assert_eq!(
example.jobs.retry_base_seconds,
defaults.jobs.retry_base_seconds
);
assert_eq!(
example.jobs.retry_max_seconds,
defaults.jobs.retry_max_seconds
);
assert_eq!(example.jobs.lease_seconds, defaults.jobs.lease_seconds);
assert_eq!(example.jobs.retention_days, defaults.jobs.retention_days);
assert_eq!(example.logging.filter, defaults.logging.filter);
assert_eq!(example.logging.json_format, defaults.logging.json_format);
assert_eq!(example.logging.target, defaults.logging.target);
assert_eq!(example.logging.ansi, defaults.logging.ansi);
assert_eq!(example.logging.span_events, defaults.logging.span_events);
assert_eq!(
example.logging.flatten_event,
defaults.logging.flatten_event
);
assert_eq!(
example.order.validity_seconds,
defaults.order.validity_seconds
);
assert_eq!(
example.order.max_identifiers,
defaults.order.max_identifiers
);
assert_eq!(example.order.retention_days, defaults.order.retention_days);
assert_eq!(example.signer.backend, defaults.signer.backend);
assert_eq!(
example.signer.local_ca.cert_path,
defaults.signer.local_ca.cert_path
);
assert_eq!(
example.signer.local_ca.key_path,
defaults.signer.local_ca.key_path
);
assert_eq!(
example.signer.local_ca.key_type,
defaults.signer.local_ca.key_type
);
assert_eq!(
example.signer.local_ca.leaf_validity_days,
defaults.signer.local_ca.leaf_validity_days
);
assert_eq!(
example.signer.local_ca.crl_distribution_points,
defaults.signer.local_ca.crl_distribution_points
);
assert_eq!(
example.signer.local_ca.ca_issuer_urls,
defaults.signer.local_ca.ca_issuer_urls
);
assert_eq!(
example.signer.local_ca.subject.common_name,
defaults.signer.local_ca.subject.common_name
);
assert_eq!(
example.signer.local_ca.subject.organization,
defaults.signer.local_ca.subject.organization
);
assert_eq!(
example.signer.local_ca.subject.organizational_unit,
defaults.signer.local_ca.subject.organizational_unit
);
assert_eq!(
example.signer.local_ca.subject.country,
defaults.signer.local_ca.subject.country
);
assert_eq!(
example.signer.local_ca.subject.state,
defaults.signer.local_ca.subject.state
);
assert_eq!(
example.signer.local_ca.subject.locality,
defaults.signer.local_ca.subject.locality
);
assert_eq!(example.challenge.enabled, defaults.challenge.enabled);
assert_eq!(example.challenge.bypass, defaults.challenge.bypass);
assert_eq!(example.challenge.timeout_ms, defaults.challenge.timeout_ms);
assert_eq!(
example.challenge.http_01.port,
defaults.challenge.http_01.port
);
assert_eq!(
example.challenge.http_01.https_port,
defaults.challenge.http_01.https_port
);
assert_eq!(
example.challenge.http_01.follow_redirects,
defaults.challenge.http_01.follow_redirects
);
assert_eq!(
example.challenge.http_01.max_redirects,
defaults.challenge.http_01.max_redirects
);
assert_eq!(
example.challenge.http_01.max_response_bytes,
defaults.challenge.http_01.max_response_bytes
);
assert_eq!(
example.challenge.tls_alpn_01.port,
defaults.challenge.tls_alpn_01.port
);
assert_eq!(example.filter.rules, defaults.filter.rules);
assert_eq!(example.filter.default, defaults.filter.default);
assert_eq!(
example.filter.forwarded_header,
defaults.filter.forwarded_header
);
assert!(example.filter.check.is_empty());
assert!(example.filter.rule.is_empty());
assert_eq!(example.ipam.backend, defaults.ipam.backend);
assert_eq!(example.ipam.timeout_ms, defaults.ipam.timeout_ms);
assert_eq!(example.ipam.netbox.url, defaults.ipam.netbox.url);
assert_eq!(example.ipam.netbox.token, defaults.ipam.netbox.token);
assert_eq!(
example.ipam.netbox.custom_field,
defaults.ipam.netbox.custom_field
);
assert_eq!(example.ipam.netbox.sources, defaults.ipam.netbox.sources);
assert_eq!(
example.ipam.netbox.vip_roles,
defaults.ipam.netbox.vip_roles
);
assert_eq!(
example.ipam.netbox.ca_cert_path,
defaults.ipam.netbox.ca_cert_path
);
assert_eq!(
example.ipam.netbox.insecure_skip_verify,
defaults.ipam.netbox.insecure_skip_verify
);
assert_eq!(example.ipam.phpipam.url, defaults.ipam.phpipam.url);
assert_eq!(example.ipam.phpipam.app_id, defaults.ipam.phpipam.app_id);
assert_eq!(example.ipam.phpipam.token, defaults.ipam.phpipam.token);
assert_eq!(
example.ipam.phpipam.custom_field,
defaults.ipam.phpipam.custom_field
);
assert_eq!(example.ipam.phpipam.sources, defaults.ipam.phpipam.sources);
assert_eq!(
example.ipam.phpipam.ca_cert_path,
defaults.ipam.phpipam.ca_cert_path
);
assert_eq!(
example.ipam.phpipam.insecure_skip_verify,
defaults.ipam.phpipam.insecure_skip_verify
);
assert_eq!(
example.ipam.custom.script_path,
defaults.ipam.custom.script_path
);
assert_eq!(example.ipam.custom.args, defaults.ipam.custom.args);
assert_eq!(example.eab.enabled, defaults.eab.enabled);
assert_eq!(example.notify.enabled, defaults.notify.enabled);
assert_eq!(
example.notify.custom_enabled,
defaults.notify.custom_enabled
);
assert_eq!(example.notify.template_dir, defaults.notify.template_dir);
assert_eq!(
example.notify.email.smtp_port,
defaults.notify.email.smtp_port
);
assert_eq!(
example.notify.email.smtp_security,
defaults.notify.email.smtp_security
);
assert_eq!(example.notify.email.events, defaults.notify.email.events);
assert_eq!(
example.notify.webhook_enabled,
defaults.notify.webhook_enabled
);
assert_eq!(example.dns.resolver, defaults.dns.resolver);
assert_eq!(example.proxy.http_url, defaults.proxy.http_url);
assert_eq!(example.proxy.https_url, defaults.proxy.https_url);
assert_eq!(example.proxy.no_proxy, defaults.proxy.no_proxy);
}
#[test]
fn load_applies_env_overrides() {
let _guard = EnvGuard::new(&[("ACME_PROXY_SERVER__BASE_URL", "https://acme.example.test")]);
let config = Config::load().expect("load should succeed with env overrides");
assert_eq!(config.server.base_url, "https://acme.example.test");
assert_eq!(config.server.bind_address, "[::]:3000");
assert_eq!(config.nonce.ttl_seconds, 300);
}
#[test]
fn load_applies_eab_env_override() {
let _guard = EnvGuard::new(&[("ACME_PROXY_EAB__ENABLED", "true")]);
let config = Config::load().expect("load should succeed with eab env override");
assert!(config.eab.enabled);
}
#[test]
fn load_applies_dns_resolver_env_override() {
let _guard = EnvGuard::new(&[("ACME_PROXY_DNS__RESOLVER", "10.60.0.2:53")]);
let config = Config::load().expect("load should succeed with a dns resolver override");
assert_eq!(config.dns.resolver.as_deref(), Some("10.60.0.2:53"));
}
#[test]
fn load_treats_an_empty_string_list_env_var_as_no_values() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_FILTER__ENABLED", ""),
("ACME_PROXY_CHALLENGE__ENABLED", ""),
]);
let config = Config::load().expect("an empty list env var must not be a parse error");
assert!(config.filter.enabled.is_empty());
assert!(config.challenge.enabled.is_empty());
}
#[test]
fn load_applies_doubly_nested_env_overrides() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_SERVER__TLS__ENABLED", "true"),
("ACME_PROXY_SERVER__TLS__CERT_PATH", "/etc/acme/tls.pem"),
("ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS", "2500"),
]);
let config = Config::load().expect("load should succeed with nested env overrides");
assert!(config.server.tls.enabled);
assert_eq!(config.server.tls.cert_path, "/etc/acme/tls.pem");
assert_eq!(config.server.tls.handshake_timeout_ms, 2500);
assert_eq!(config.server.tls.key_path, "server.key");
assert_eq!(config.server.bind_address, "[::]:3000");
}
#[test]
fn load_applies_local_ca_subject_env_overrides() {
let _guard = EnvGuard::new(&[
(
"ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COMMON_NAME",
"Custom Root CA",
),
(
"ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATION",
"Example Corp",
),
("ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COUNTRY", "US"),
]);
let config =
Config::load().expect("load should succeed with local_ca subject env overrides");
assert_eq!(
config.signer.local_ca.subject.common_name.as_deref(),
Some("Custom Root CA")
);
assert_eq!(
config.signer.local_ca.subject.organization.as_deref(),
Some("Example Corp")
);
assert_eq!(
config.signer.local_ca.subject.country.as_deref(),
Some("US")
);
assert!(config.signer.local_ca.subject.state.is_none());
assert_eq!(config.signer.local_ca.cert_path, "ca.pem");
}
#[test]
fn load_parses_list_valued_env_overrides() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_FILTER__RULES", "mgmt-bypass,inventory-owned"),
(
"ACME_PROXY_FILTER__CHECK__NET__ALLOW",
"192.168.1.0/24,fd00::/8",
),
]);
let config = Config::load().expect("load should succeed with list env overrides");
assert_eq!(config.filter.rules, vec!["mgmt-bypass", "inventory-owned"]);
assert_eq!(
config.filter.check["net"].allow,
vec!["192.168.1.0/24", "fd00::/8"]
);
assert_eq!(config.filter.forwarded_header, "x-forwarded-for");
}
#[test]
fn the_admin_notify_section_parses_from_the_environment() {
let _guard = EnvGuard::new(&[
("ACME_PROXY_ADMIN__NOTIFY__ENABLED", "email,webhook,custom"),
(
"ACME_PROXY_ADMIN__NOTIFY__EMAIL__EVENTS",
"admin_sign_in,admin_credential_changed",
),
("ACME_PROXY_ADMIN__NOTIFY__EMAIL__TO", "ops@example.com"),
("ACME_PROXY_ADMIN__NOTIFY__WEBHOOK_ENABLED", "slack"),
("ACME_PROXY_ADMIN__NOTIFY__CUSTOM_ENABLED", "pager"),
(
"ACME_PROXY_ADMIN__NOTIFY__WEBHOOK__SLACK__EVENTS",
"admin_sign_in",
),
(
"ACME_PROXY_ADMIN__NOTIFY__CUSTOM__PAGER__ARGS",
"--now,--loud",
),
]);
let config = Config::load().expect("[admin.notify] must load from the environment");
assert_eq!(config.admin.notify.enabled, ["email", "webhook", "custom"]);
assert_eq!(
config.admin.notify.email.events,
["admin_sign_in", "admin_credential_changed"]
);
assert_eq!(config.admin.notify.email.to, ["ops@example.com"]);
assert_eq!(config.admin.notify.webhook_enabled, ["slack"]);
assert_eq!(config.admin.notify.custom_enabled, ["pager"]);
assert_eq!(
config.admin.notify.webhook["slack"].events,
["admin_sign_in"]
);
assert_eq!(
config.admin.notify.custom["pager"].args,
["--now", "--loud"]
);
assert!(config.notify.enabled.is_empty());
}
#[test]
fn every_list_field_reads_a_comma_separated_string() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/config/types");
let mut fields = 0;
let mut missing = Vec::new();
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
let source = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = source.lines().collect();
for (index, line) in lines.iter().enumerate() {
let line = line.trim();
if !(line.starts_with("pub ") && line.contains(": Vec<")) {
continue;
}
fields += 1;
let covered = lines[..index]
.iter()
.rev()
.map(|above| above.trim())
.take_while(|above| above.starts_with("#[") || above.starts_with("//"))
.any(|above| above.starts_with("#[") && above.contains("string_list\""));
if !covered {
missing.push(format!("{}: {line}", path.display()));
}
}
}
assert!(fields >= 30, "the scan found only {fields} list fields");
assert!(
missing.is_empty(),
"list fields without `deserialize_with = \"string_list\"`:\n{}",
missing.join("\n")
);
}
}