1use std::sync::Arc;
10
11use acme_proxy_core::config;
12use acme_proxy_core::config::Config;
13use acme_proxy_net::challenge;
14use acme_proxy_policy::filter;
15use acme_proxy_policy::ipam;
16use acme_proxy_protocol::profile::Profile;
17use acme_proxy_protocol::profile::ProfileParts;
18use acme_proxy_store::db::Database;
19
20use super::{Assembly, GenerationParts};
21
22pub fn build_all(
41 config: &Config,
42 database: Arc<Database>,
43 jobs: &acme_proxy_jobs::jobs::JobQueue,
44) -> anyhow::Result<Vec<Arc<Profile>>> {
45 let resolved = config.resolve_profiles()?;
46 let (_assembly, first) = Assembly::new(
49 super::RoleSet::default(),
50 &resolved,
51 database,
52 jobs.clone(),
53 config,
54 )?;
55 build_all_with(config, &resolved, &first)
56}
57
58pub fn build_all_with(
69 config: &Config,
70 resolved: &[config::ProfileConfig],
71 generation: &GenerationParts,
72) -> anyhow::Result<Vec<Arc<Profile>>> {
73 let egress = &generation.egress;
74 let dispatchers = &generation.dispatchers;
75
76 let mut profiles = Vec::with_capacity(resolved.len());
77 for profile in resolved {
78 let sections = &profile.sections;
79 let span = tracing::info_span!("profile", profile = %profile.name);
80 let (filter, challenges) = span.in_scope(|| {
81 let ipam = ipam::from_config(§ions.ipam, egress.outbound())
89 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
90 let filter =
91 filter::from_config(§ions.filter, &config.dns, ipam, sections.eab.enabled)
92 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
93 let challenges =
94 challenge::from_config(§ions.challenge, &config.dns, egress.proxies.clone())
95 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
96 check_request_timeout(config, profile.name.as_str(), sections)?;
97 Ok::<_, anyhow::Error>((filter, challenges))
98 })?;
99
100 profiles.push(Arc::new(Profile::new(
101 &profile.name,
102 &config.server.base_url,
103 ProfileParts {
104 signer_info: generation
105 .infos
106 .get(&profile.name)
107 .ok_or_else(|| {
108 anyhow::anyhow!("profile `{}`: no signer read side", profile.name)
109 })?
110 .clone(),
111 filter,
112 challenges,
113 order: sections.order.clone(),
114 eab: sections.eab.clone(),
115 meta: sections.meta.clone(),
116 notify: dispatchers[&profile.name].clone(),
117 },
118 )));
119 }
120 Ok(profiles)
121}
122
123fn check_request_timeout(
141 config: &Config,
142 name: &str,
143 sections: &config::ProfileSections,
144) -> anyhow::Result<()> {
145 let deadline = config.server.request_timeout_ms;
146 let custom = §ions.signer.custom;
147 let budget = if sections.signer.backend == "custom"
151 && (custom.supports_crl || custom.supports_renewal_info)
152 {
153 custom.timeout_ms
154 } else {
155 0
156 };
157
158 anyhow::ensure!(
159 deadline > budget,
160 "profile `{name}`: server.request_timeout_ms ({deadline}) must exceed \
161 signer.custom.timeout_ms ({budget}) — the script's `crl`/`renewal_info` hooks run inside \
162 the request, so a shorter deadline would cut off an answer that was coming and report it \
163 to the client as a server failure",
164 );
165 Ok(())
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn config_from(body: &str) -> Config {
178 let _lock = acme_proxy_core::config::ENV_LOCK
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner);
181 let dir = acme_proxy_core::testutil::TempDir::new("lib");
182 std::fs::write(dir.join("config.toml"), body).unwrap();
183 unsafe {
185 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
186 }
187 let config = Config::load().expect("the configuration must load");
188 unsafe {
189 std::env::remove_var("ACME_PROXY_CONFIG");
190 }
191 config
192 }
193
194 fn config_with_ca_in(dir: impl AsRef<std::path::Path>, body: &str) -> Config {
201 let ca = dir.as_ref().join("ca");
202 config_from(&format!(
203 r#"
204 [signer.local_ca]
205 cert_path = "{ca}.pem"
206 key_path = "{ca}.key"
207 crl_path = "{ca}.crl"
208 {body}"#,
209 ca = ca.display(),
210 ))
211 }
212
213 fn two_profiles_config(dir: impl AsRef<std::path::Path>) -> Config {
216 let dir = dir.as_ref();
217 let a = dir.join("a");
218 let b = dir.join("b");
219 config_from(&format!(
220 r#"
221 [challenge]
222 enabled = ["http-01"]
223 bypass = true
224
225 [profiles.a]
226 signer.local_ca.cert_path = "{a}.pem"
227 signer.local_ca.key_path = "{a}.key"
228 signer.local_ca.crl_path = "{a}.crl"
229
230 [profiles.b]
231 challenge.bypass = false
232 signer.local_ca.cert_path = "{b}.pem"
233 signer.local_ca.key_path = "{b}.key"
234 signer.local_ca.crl_path = "{b}.crl"
235 "#,
236 a = a.display(),
237 b = b.display(),
238 ))
239 }
240
241 async fn database() -> Arc<Database> {
242 Arc::new(Database::connect_in_memory().await.unwrap())
243 }
244
245 #[tokio::test]
246 async fn build_all_assembles_every_endpoint_from_its_own_configuration() {
247 let dir = acme_proxy_core::testutil::TempDir::new("build");
248 let config = two_profiles_config(&dir);
249
250 let profiles = crate::profile::build_all(
251 &config,
252 database().await,
253 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
254 )
255 .unwrap();
256 assert_eq!(profiles.len(), 2);
257
258 assert_eq!(profiles[0].name, "a");
259 assert_eq!(profiles[0].path, "/profile/a");
260 assert_eq!(profiles[0].base_url, "http://localhost:3000/profile/a");
261 assert!(profiles[0].challenges.is_bypassed());
263 assert!(!profiles[1].challenges.is_bypassed());
265 assert_eq!(profiles[1].challenges.enabled_types(), ["http-01"]);
266 }
267
268 #[tokio::test]
269 async fn build_all_refuses_a_configuration_that_mounts_nothing() {
270 let config = config_from("[server]\nbase_url = \"http://acme.test\"\n");
271 let error = match crate::profile::build_all(
272 &config,
273 database().await,
274 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
275 ) {
276 Err(error) => error.to_string(),
277 Ok(_) => panic!("a server with no endpoint must not start"),
278 };
279 assert!(error.contains("[profiles.default]"), "{error}");
280 }
281
282 #[tokio::test]
285 async fn build_all_names_the_profile_a_failure_came_from() {
286 let dir = acme_proxy_core::testutil::TempDir::new("names");
287 let config = config_with_ca_in(
288 &dir,
289 r#"
290 [profiles.le]
291 challenge.enabled = ["not-a-challenge"]
292 "#,
293 );
294 let error = match crate::profile::build_all(
295 &config,
296 database().await,
297 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
298 ) {
299 Err(error) => error.to_string(),
300 Ok(_) => panic!("an unknown challenge type is a startup error"),
301 };
302 assert!(error.contains("profile `le`"), "{error}");
303 assert!(error.contains("not-a-challenge"), "{error}");
304 }
305
306 #[tokio::test]
311 async fn build_all_refuses_a_deadline_shorter_than_an_inline_hook() {
312 let config = config_from(
313 r#"
314 [server]
315 request_timeout_ms = 1000
316
317 [profiles.le]
318 signer.backend = "custom"
319 signer.custom.script_path = "/bin/true"
320 signer.custom.timeout_ms = 5000
321 signer.custom.supports_crl = true
322 "#,
323 );
324 let error = match crate::profile::build_all(
325 &config,
326 database().await,
327 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
328 ) {
329 Err(error) => error.to_string(),
330 Ok(_) => panic!("a deadline below signer.custom.timeout_ms is a startup error"),
331 };
332 assert!(error.contains("profile `le`"), "{error}");
333 assert!(error.contains("request_timeout_ms"), "{error}");
334 assert!(error.contains("signer.custom.timeout_ms"), "{error}");
335 }
336
337 #[tokio::test]
341 async fn a_custom_issue_hook_above_the_deadline_is_no_longer_refused() {
342 let config = config_from(
343 r#"
344 [server]
345 request_timeout_ms = 1000
346
347 [profiles.le]
348 signer.backend = "custom"
349 signer.custom.script_path = "/bin/true"
350 signer.custom.timeout_ms = 5000
351 "#,
352 );
353 crate::profile::build_all(
354 &config,
355 database().await,
356 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
357 )
358 .expect("issuance no longer runs inside the request");
359 }
360
361 #[tokio::test]
366 async fn a_challenge_timeout_above_the_deadline_is_no_longer_refused() {
367 let dir = acme_proxy_core::testutil::TempDir::new("challenge");
368 let config = config_with_ca_in(
369 &dir,
370 r#"
371 [server]
372 request_timeout_ms = 1000
373
374 [profiles.le]
375 challenge.timeout_ms = 5000
376 "#,
377 );
378 crate::profile::build_all(
379 &config,
380 database().await,
381 &acme_proxy_jobs::testutil::idle_job_queue(database().await),
382 )
383 .expect("challenge validation no longer runs inside the request");
384 }
385
386 #[tokio::test]
390 async fn an_unused_custom_signer_timeout_does_not_constrain_the_deadline() {
391 let dir = acme_proxy_core::testutil::TempDir::new("unused");
392 let config = config_with_ca_in(
393 &dir,
394 r#"
395 [server]
396 request_timeout_ms = 2000
397
398 [signer.custom]
399 script_path = "/bin/true"
400 timeout_ms = 30000
401
402 [profiles.le]
403 challenge.timeout_ms = 1000
404 "#,
405 );
406 assert!(
407 crate::profile::build_all(
408 &config,
409 database().await,
410 &acme_proxy_jobs::testutil::idle_job_queue(database().await)
411 )
412 .is_ok()
413 );
414 }
415}