Skip to main content

acme_proxy_server/
profile.rs

1//! How a configuration generation builds every [`Profile`] it mounts.
2//!
3//! [`build_all`] resolves the profiles, gives each its filter policy, challenge
4//! registry and signer handle, and fails as a whole: a configuration with one
5//! broken endpoint mounts none, at startup and on a reload alike. The startup
6//! checks that span sections — `server.request_timeout_ms` against a custom
7//! signer's timeout — live here too.
8
9use 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
22/// Builds every endpoint this configuration mounts, ready to serve.
23///
24/// Lives here rather than in `server::serve_on` because it is the assembly
25/// step, not dispatch: it resolves the profiles, builds the signer backends
26/// (deduplicated by configuration — see
27/// [`signer::build_backends`](acme_proxy_signer::build_backends)), and gives
28/// each profile its own filter chain and challenge registry. Every failure
29/// is fatal at startup, so they come back as one error for the caller to
30/// report and exit on.
31///
32/// Each profile's subsystems are built inside a span naming it, so the
33/// warnings they emit at build time (`filter_disabled`,
34/// `challenge_validation_bypassed`) say *which* endpoint is wide open —
35/// with several mounted, an unattributed warning is worse than none.
36/// `jobs` is the enqueue side of the durable queue, handed in rather than
37/// built here for the reason the `Auditor` is built in `serve_on_with`:
38/// `[jobs]` is process-wide, one queue drained by one runner, and a profile
39/// is not the thing that owns it.
40pub 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    // All roles: this builder is the CLI's and the tests' path, where the
47    // process is doing everything it is going to do.
48    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
58/// One generation of profiles, over an [`Assembly`] that outlives it.
59///
60/// The half of [`build_all`] a configuration reload runs
61/// again. Everything it touches is cheap and side-effect-free to rebuild —
62/// a filter policy, an IPAM client, a challenge registry — which is exactly
63/// why the *stateful* half lives in the `Assembly` instead. A profile takes
64/// its signer's read side from `generation.infos` — built, like the
65/// backends, only where the configuration moved (see
66/// [`signer::build_infos`](acme_proxy_signer::build_infos)) — and never the
67/// backend itself, which stays with the job handlers.
68pub 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            // Built per profile with no dedup pass, unlike
82            // `signer::build_backends`. Sharing a signer backend is a
83            // correctness requirement — two `LocalCa` over one CRL file
84            // would clobber each other's ledger — whereas an IPAM client
85            // owns no files and holds no mutable state, so two profiles
86            // naming the same inventory each building one costs nothing
87            // but a `rustls::ClientConfig`.
88            let ipam = ipam::from_config(&sections.ipam, egress.outbound())
89                .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
90            let filter =
91                filter::from_config(&sections.filter, &config.dns, ipam, sections.eab.enabled)
92                    .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
93            let challenges =
94                challenge::from_config(&sections.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
123/// Refuses a `server.request_timeout_ms` shorter than the work the server does
124/// *inside* a request.
125///
126/// One hook still runs inline in a handler: a `custom` signer's read-only
127/// script hooks, `crl` (`GET /crl`) and `renewal_info` (`GET /renewalInfo`),
128/// each only when its `supports_*` flag is on. If the request deadline is the
129/// shorter of the two budgets, an answer that was coming is cut off and the
130/// client is told the server failed — a misconfiguration that would look like
131/// an intermittent outage and be miserable to diagnose. Cheaper to refuse to
132/// start and say which two numbers disagree.
133///
134/// **Two budgets used to be checked here and deliberately are not any more.**
135/// `challenge.timeout_ms` went when validation moved into the job queue
136/// (`acme::validate`), and the script's `issue` hook when finalize did
137/// (`acme::issue`): both now bound a job attempt rather than a request. A
138/// revocation a `custom` profile delegates waits on its job for at most the
139/// request's own deadline, so it needs no check either.
140fn 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 = &sections.signer.custom;
147    // Only when that backend is the one installed, and only for the hooks a
148    // request runs; an unused `[signer.custom]` section, or one whose read
149    // hooks are off, says nothing about how long this profile's requests take.
150    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    /// Loads a whole configuration file, the only way profile resolution can be
173    /// exercised (it reads the raw sources — see `Config::resolve_profiles`).
174    ///
175    /// Holds the crate-wide `ENV_LOCK` while it does: this points
176    /// `ACME_PROXY_CONFIG` at its own file, and the environment is process-wide.
177    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        // SAFETY: single-threaded test; the variable is removed before return.
184        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    /// `body` over a global `[signer.local_ca]` whose files live in `dir`.
195    ///
196    /// A `local_ca` profile left on the default paths generates `ca.pem` and
197    /// `ca.key` in the crate directory, and nextest runs every test in its own
198    /// process at once: one reads the certificate of one generation beside the
199    /// key of another, and fails only when the files were absent to begin with.
200    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    /// A CA-material-free configuration: `local_ca` writes files at startup, so
214    /// each profile gets its own throwaway directory.
215    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        // `a` inherits the global challenge section wholesale…
262        assert!(profiles[0].challenges.is_bypassed());
263        // …while `b` overrides one key of it and keeps the rest.
264        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    /// A subsystem that cannot be built names the endpoint it belongs to —
283    /// with several mounted, "unknown challenge type" alone would not say where.
284    #[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    /// A request deadline shorter than a hook that runs inside the request is a
307    /// misconfiguration that would look like an intermittent outage: an answer
308    /// that was coming gets cut off and reported to the client as a server
309    /// failure. Refuse to start and name both numbers.
310    #[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    /// The script's `issue` hook runs in the `signer_issue` job now, so a
338    /// `custom` profile whose read hooks are off has nothing inline for the
339    /// deadline to cut off. A configuration the old check refused must start.
340    #[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    /// `challenge.timeout_ms` is deliberately **not** checked against the
362    /// request deadline any more: validation runs in the job queue, so that
363    /// budget bounds an attempt rather than a request and the two numbers are
364    /// independent. A configuration the old check refused must now start.
365    #[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    /// The same check must not fire on `signer.custom.timeout_ms` when that
387    /// backend is not the one installed — an unused `[signer.custom]` section
388    /// says nothing about how long this profile's requests take.
389    #[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}