use std::sync::Arc;
use acme_proxy_core::config;
use acme_proxy_core::config::Config;
use acme_proxy_net::challenge;
use acme_proxy_policy::filter;
use acme_proxy_policy::ipam;
use acme_proxy_protocol::profile::Profile;
use acme_proxy_protocol::profile::ProfileParts;
use acme_proxy_store::db::Database;
use super::{Assembly, GenerationParts};
pub fn build_all(
config: &Config,
database: Arc<Database>,
jobs: &acme_proxy_jobs::jobs::JobQueue,
) -> anyhow::Result<Vec<Arc<Profile>>> {
let resolved = config.resolve_profiles()?;
let (_assembly, first) = Assembly::new(
super::RoleSet::default(),
&resolved,
database,
jobs.clone(),
config,
)?;
build_all_with(config, &resolved, &first)
}
pub fn build_all_with(
config: &Config,
resolved: &[config::ProfileConfig],
generation: &GenerationParts,
) -> anyhow::Result<Vec<Arc<Profile>>> {
let egress = &generation.egress;
let dispatchers = &generation.dispatchers;
let mut profiles = Vec::with_capacity(resolved.len());
for profile in resolved {
let sections = &profile.sections;
let span = tracing::info_span!("profile", profile = %profile.name);
let (filter, challenges) = span.in_scope(|| {
let ipam = ipam::from_config(§ions.ipam, egress.outbound())
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
let filter =
filter::from_config(§ions.filter, &config.dns, ipam, sections.eab.enabled)
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
let challenges =
challenge::from_config(§ions.challenge, &config.dns, egress.proxies.clone())
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
check_request_timeout(config, profile.name.as_str(), sections)?;
Ok::<_, anyhow::Error>((filter, challenges))
})?;
profiles.push(Arc::new(Profile::new(
&profile.name,
&config.server.base_url,
ProfileParts {
signer_info: generation
.infos
.get(&profile.name)
.ok_or_else(|| {
anyhow::anyhow!("profile `{}`: no signer read side", profile.name)
})?
.clone(),
filter,
challenges,
order: sections.order.clone(),
eab: sections.eab.clone(),
meta: sections.meta.clone(),
notify: dispatchers[&profile.name].clone(),
},
)));
}
Ok(profiles)
}
fn check_request_timeout(
config: &Config,
name: &str,
sections: &config::ProfileSections,
) -> anyhow::Result<()> {
let deadline = config.server.request_timeout_ms;
let custom = §ions.signer.custom;
let budget = if sections.signer.backend == "custom"
&& (custom.supports_crl || custom.supports_renewal_info)
{
custom.timeout_ms
} else {
0
};
anyhow::ensure!(
deadline > budget,
"profile `{name}`: server.request_timeout_ms ({deadline}) must exceed \
signer.custom.timeout_ms ({budget}) — the script's `crl`/`renewal_info` hooks run inside \
the request, so a shorter deadline would cut off an answer that was coming and report it \
to the client as a server failure",
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from(body: &str) -> Config {
let _lock = acme_proxy_core::config::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = acme_proxy_core::testutil::TempDir::new("lib");
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
fn config_with_ca_in(dir: impl AsRef<std::path::Path>, body: &str) -> Config {
let ca = dir.as_ref().join("ca");
config_from(&format!(
r#"
[signer.local_ca]
cert_path = "{ca}.pem"
key_path = "{ca}.key"
crl_path = "{ca}.crl"
{body}"#,
ca = ca.display(),
))
}
fn two_profiles_config(dir: impl AsRef<std::path::Path>) -> Config {
let dir = dir.as_ref();
let a = dir.join("a");
let b = dir.join("b");
config_from(&format!(
r#"
[challenge]
enabled = ["http-01"]
bypass = true
[profiles.a]
signer.local_ca.cert_path = "{a}.pem"
signer.local_ca.key_path = "{a}.key"
signer.local_ca.crl_path = "{a}.crl"
[profiles.b]
challenge.bypass = false
signer.local_ca.cert_path = "{b}.pem"
signer.local_ca.key_path = "{b}.key"
signer.local_ca.crl_path = "{b}.crl"
"#,
a = a.display(),
b = b.display(),
))
}
async fn database() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
#[tokio::test]
async fn build_all_assembles_every_endpoint_from_its_own_configuration() {
let dir = acme_proxy_core::testutil::TempDir::new("build");
let config = two_profiles_config(&dir);
let profiles = crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
)
.unwrap();
assert_eq!(profiles.len(), 2);
assert_eq!(profiles[0].name, "a");
assert_eq!(profiles[0].path, "/profile/a");
assert_eq!(profiles[0].base_url, "http://localhost:3000/profile/a");
assert!(profiles[0].challenges.is_bypassed());
assert!(!profiles[1].challenges.is_bypassed());
assert_eq!(profiles[1].challenges.enabled_types(), ["http-01"]);
}
#[tokio::test]
async fn build_all_refuses_a_configuration_that_mounts_nothing() {
let config = config_from("[server]\nbase_url = \"http://acme.test\"\n");
let error = match crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("a server with no endpoint must not start"),
};
assert!(error.contains("[profiles.default]"), "{error}");
}
#[tokio::test]
async fn build_all_names_the_profile_a_failure_came_from() {
let dir = acme_proxy_core::testutil::TempDir::new("names");
let config = config_with_ca_in(
&dir,
r#"
[profiles.le]
challenge.enabled = ["not-a-challenge"]
"#,
);
let error = match crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("an unknown challenge type is a startup error"),
};
assert!(error.contains("profile `le`"), "{error}");
assert!(error.contains("not-a-challenge"), "{error}");
}
#[tokio::test]
async fn build_all_refuses_a_deadline_shorter_than_an_inline_hook() {
let config = config_from(
r#"
[server]
request_timeout_ms = 1000
[profiles.le]
signer.backend = "custom"
signer.custom.script_path = "/bin/true"
signer.custom.timeout_ms = 5000
signer.custom.supports_crl = true
"#,
);
let error = match crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("a deadline below signer.custom.timeout_ms is a startup error"),
};
assert!(error.contains("profile `le`"), "{error}");
assert!(error.contains("request_timeout_ms"), "{error}");
assert!(error.contains("signer.custom.timeout_ms"), "{error}");
}
#[tokio::test]
async fn a_custom_issue_hook_above_the_deadline_is_no_longer_refused() {
let config = config_from(
r#"
[server]
request_timeout_ms = 1000
[profiles.le]
signer.backend = "custom"
signer.custom.script_path = "/bin/true"
signer.custom.timeout_ms = 5000
"#,
);
crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
)
.expect("issuance no longer runs inside the request");
}
#[tokio::test]
async fn a_challenge_timeout_above_the_deadline_is_no_longer_refused() {
let dir = acme_proxy_core::testutil::TempDir::new("challenge");
let config = config_with_ca_in(
&dir,
r#"
[server]
request_timeout_ms = 1000
[profiles.le]
challenge.timeout_ms = 5000
"#,
);
crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await),
)
.expect("challenge validation no longer runs inside the request");
}
#[tokio::test]
async fn an_unused_custom_signer_timeout_does_not_constrain_the_deadline() {
let dir = acme_proxy_core::testutil::TempDir::new("unused");
let config = config_with_ca_in(
&dir,
r#"
[server]
request_timeout_ms = 2000
[signer.custom]
script_path = "/bin/true"
timeout_ms = 30000
[profiles.le]
challenge.timeout_ms = 1000
"#,
);
assert!(
crate::profile::build_all(
&config,
database().await,
&acme_proxy_jobs::testutil::idle_job_queue(database().await)
)
.is_ok()
);
}
}