Skip to main content

coil_runtime/
tls.rs

1use super::*;
2use crate::server::SecretResolutionError;
3use coil_tls::{
4    AcmeTlsCertificateExecutor, CertificateMaterial, ChallengeValidation,
5    CloudflareTlsCertificateExecutor, HostnameBinding, ManualCertificateBundle,
6    ManualImportTlsCertificateExecutor, TlsCertificateExecutor, TlsMaterialProtector,
7};
8use std::sync::Arc;
9
10#[cfg(not(test))]
11const TLS_MATERIAL_KEY_ENV: &str = "COIL_TLS_MATERIAL_KEY";
12const TLS_PREVIOUS_MATERIAL_KEYS_ENV: &str = "COIL_TLS_PREVIOUS_MATERIAL_KEYS";
13
14#[derive(Debug, Error, PartialEq, Eq)]
15pub enum RuntimeTlsError {
16    #[error(transparent)]
17    Tls(#[from] TlsModelError),
18    #[error(transparent)]
19    Data(#[from] coil_data::DataModelError),
20    #[error(transparent)]
21    Secret(#[from] SecretResolutionError),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct TlsStatusSnapshot {
26    pub customer_app: String,
27    pub mode: coil_config::TlsMode,
28    pub edge_mode: EdgeMode,
29    pub provider: Option<CertificateProviderKind>,
30    pub inventory: CertificateInventory,
31    pub queued_renewals: Vec<RenewalPlan>,
32    pub pending_challenges: Vec<ChallengeTicket>,
33    pub hot_reload_events: Vec<HotReloadEvent>,
34}
35
36#[derive(Debug, Clone)]
37pub struct TlsHost {
38    pub customer_app: String,
39    pub runtime: TlsRuntimeServices,
40    control_plane: TlsControlPlaneRuntime,
41    certificate_executor: Arc<dyn TlsCertificateExecutor>,
42}
43
44impl TlsHost {
45    fn build_executor(
46        customer_app: &str,
47        shared_backend_namespace: &str,
48        runtime: &TlsRuntimeServices,
49        control_plane: TlsControlPlaneRuntime,
50        account_secret: Option<String>,
51        material_protector: TlsMaterialProtector,
52    ) -> Result<Self, RuntimeTlsError> {
53        let certificate_executor: Arc<dyn TlsCertificateExecutor> = match runtime.provider {
54            Some(coil_tls::CertificateProviderKind::Acme) => {
55                Arc::new(AcmeTlsCertificateExecutor::new(
56                    control_plane.clone(),
57                    material_protector,
58                    account_secret.clone(),
59                ))
60            }
61            Some(coil_tls::CertificateProviderKind::CloudflareDns)
62            | Some(coil_tls::CertificateProviderKind::CloudflareOriginCa) => {
63                Arc::new(CloudflareTlsCertificateExecutor::new(
64                    runtime
65                        .provider
66                        .expect("cloudflare provider is selected when creating executor"),
67                    control_plane.clone(),
68                    material_protector,
69                    account_secret.clone(),
70                ))
71            }
72            Some(coil_tls::CertificateProviderKind::ManualImport) | None => Arc::new(
73                ManualImportTlsCertificateExecutor::new(control_plane.clone(), material_protector),
74            ),
75        };
76        Ok(Self {
77            customer_app: customer_app.to_string(),
78            runtime: runtime.clone(),
79            control_plane,
80            certificate_executor,
81        })
82    }
83
84    pub(crate) fn new(
85        customer_app: String,
86        runtime: TlsRuntimeServices,
87        _data_runtime: DataRuntimeServices,
88        shared_backend_namespace: String,
89        account_secret: Option<String>,
90    ) -> Result<Self, RuntimeTlsError> {
91        #[cfg(test)]
92        let material_protector = TlsMaterialProtector::from_seed(format!(
93            "test-tls-material:{}:{}",
94            customer_app, shared_backend_namespace
95        ))?;
96        #[cfg(not(test))]
97        let material_protector = runtime_material_protector()?;
98        #[cfg(test)]
99        let control_plane =
100            TlsControlPlaneRuntime::in_memory_control_plane_for_tests(runtime.clone());
101        #[cfg(not(test))]
102        let control_plane = TlsControlPlaneRuntime::with_distributed_postgres_control_plane(
103            runtime.clone(),
104            &_data_runtime,
105            format!("customer-app:{}:{}", customer_app, shared_backend_namespace),
106        )?;
107        Self::build_executor(
108            &customer_app,
109            &shared_backend_namespace,
110            &runtime,
111            control_plane,
112            account_secret,
113            material_protector,
114        )
115    }
116
117    pub(crate) fn new_for_validation(
118        customer_app: String,
119        runtime: TlsRuntimeServices,
120        shared_backend_namespace: String,
121        account_secret: Option<String>,
122    ) -> Result<Self, RuntimeTlsError> {
123        let material_protector = TlsMaterialProtector::from_seed(format!(
124            "tls-validation:{}:{}",
125            customer_app, shared_backend_namespace
126        ))?;
127        let control_plane =
128            TlsControlPlaneRuntime::in_memory_control_plane_for_tests(runtime.clone());
129        Self::build_executor(
130            &customer_app,
131            &shared_backend_namespace,
132            &runtime,
133            control_plane,
134            account_secret,
135            material_protector,
136        )
137    }
138
139    pub fn status(&self) -> TlsStatusSnapshot {
140        let snapshot = self.control_plane.snapshot();
141        TlsStatusSnapshot {
142            customer_app: self.customer_app.clone(),
143            mode: self.runtime.mode,
144            edge_mode: self.runtime.edge_mode,
145            provider: self.runtime.provider,
146            inventory: snapshot.inventory,
147            queued_renewals: snapshot.renewal_queue,
148            pending_challenges: snapshot.pending_challenges,
149            hot_reload_events: snapshot.hot_reload_events,
150        }
151    }
152
153    pub fn issue_for_bindings(
154        &self,
155        bindings: Vec<HostnameBinding>,
156    ) -> Result<IssuancePlan, RuntimeTlsError> {
157        Ok(self.runtime.planner().issue_for_bindings(bindings)?)
158    }
159
160    pub fn validate_challenge_for_bindings(
161        &self,
162        bindings: Vec<HostnameBinding>,
163    ) -> Result<ChallengeValidation, RuntimeTlsError> {
164        let plan = self.issue_for_bindings(bindings)?;
165        Ok(self.certificate_executor.validate_issuance_plan(&plan)?)
166    }
167
168    pub fn import_certificate(&mut self, record: CertificateRecord) -> Result<(), RuntimeTlsError> {
169        Ok(self.control_plane.import_certificate(record)?)
170    }
171
172    pub fn import_manual_certificate(
173        &mut self,
174        bundle: ManualCertificateBundle,
175    ) -> Result<(), RuntimeTlsError> {
176        let bundle = self.runtime.planner().import_manual_certificate(bundle)?;
177        Ok(self
178            .certificate_executor
179            .import_manual_certificate(bundle)?)
180    }
181
182    pub fn certificate_material(
183        &self,
184        certificate_id: &CertificateId,
185    ) -> Result<CertificateMaterial, RuntimeTlsError> {
186        Ok(self
187            .certificate_executor
188            .certificate_material(certificate_id)?)
189    }
190
191    pub fn issue_certificate(
192        &mut self,
193        bindings: Vec<HostnameBinding>,
194        certificate_id: CertificateId,
195        now: TlsInstant,
196    ) -> Result<CertificateRecord, RuntimeTlsError> {
197        let issuance = self.issue_for_bindings(bindings)?;
198        let record = self
199            .certificate_executor
200            .issue_certificate(&issuance, certificate_id, now)?;
201        self.control_plane.import_certificate(record.clone())?;
202        Ok(record)
203    }
204
205    pub fn renew_certificate(
206        &mut self,
207        certificate_id: &CertificateId,
208        replacement_certificate_id: CertificateId,
209        now: TlsInstant,
210    ) -> Result<CertificateRecord, RuntimeTlsError> {
211        let renewal_plan = self.queue_renewal(certificate_id, now)?;
212        let _ticket = self.begin_renewal(certificate_id, replacement_certificate_id.clone())?;
213        let record = match self.certificate_executor.renew_certificate(
214            &renewal_plan,
215            certificate_id.clone(),
216            replacement_certificate_id,
217            now,
218        ) {
219            Ok(record) => record,
220            Err(error) => {
221                let _ = self.fail_renewal(certificate_id);
222                return Err(error.into());
223            }
224        };
225        self.control_plane
226            .activate_replacement(certificate_id, record.clone())?;
227        Ok(record)
228    }
229
230    pub fn queue_renewal(
231        &mut self,
232        certificate_id: &CertificateId,
233        now: TlsInstant,
234    ) -> Result<RenewalPlan, RuntimeTlsError> {
235        Ok(self.control_plane.queue_renewal(certificate_id, now)?)
236    }
237
238    pub fn begin_renewal(
239        &mut self,
240        certificate_id: &CertificateId,
241        replacement_certificate_id: CertificateId,
242    ) -> Result<ChallengeTicket, RuntimeTlsError> {
243        Ok(self
244            .control_plane
245            .begin_renewal(certificate_id, replacement_certificate_id)?)
246    }
247
248    pub fn fail_renewal(
249        &mut self,
250        certificate_id: &CertificateId,
251    ) -> Result<CertificateRecord, RuntimeTlsError> {
252        Ok(self.control_plane.fail_renewal(certificate_id)?)
253    }
254
255    pub fn activate_replacement(
256        &mut self,
257        certificate_id: &CertificateId,
258        replacement: CertificateRecord,
259    ) -> Result<HotReloadEvent, RuntimeTlsError> {
260        Ok(self
261            .control_plane
262            .activate_replacement(certificate_id, replacement)?)
263    }
264
265    pub fn control_plane(&self) -> &TlsControlPlaneRuntime {
266        &self.control_plane
267    }
268}
269
270#[cfg(not(test))]
271fn runtime_material_protector() -> Result<TlsMaterialProtector, RuntimeTlsError> {
272    let active_key = std::env::var(TLS_MATERIAL_KEY_ENV).map_err(|_| {
273        TlsModelError::InvalidConfiguration {
274            field: "tls.material_encryption_key",
275            reason: format!(
276                "set `{TLS_MATERIAL_KEY_ENV}` so certificate material is encrypted with a dedicated TLS secret"
277            ),
278        }
279    })?;
280    let active_key = active_key.trim().to_string();
281    if active_key.is_empty() {
282        return Err(TlsModelError::InvalidConfiguration {
283            field: "tls.material_encryption_key",
284            reason: format!(
285                "`{TLS_MATERIAL_KEY_ENV}` must not be empty when TLS certificate material is stored by the platform"
286            ),
287        }
288        .into());
289    }
290    let previous_keys =
291        parse_previous_material_keys(std::env::var(TLS_PREVIOUS_MATERIAL_KEYS_ENV).ok())?;
292    Ok(TlsMaterialProtector::from_seed_ring(
293        active_key,
294        previous_keys,
295    )?)
296}
297
298fn parse_previous_material_keys(value: Option<String>) -> Result<Vec<String>, RuntimeTlsError> {
299    let Some(value) = value else {
300        return Ok(Vec::new());
301    };
302
303    let keys = value
304        .split([',', '\n'])
305        .map(str::trim)
306        .filter(|segment| !segment.is_empty())
307        .map(ToOwned::to_owned)
308        .collect::<Vec<_>>();
309
310    if keys.is_empty() {
311        return Err(TlsModelError::InvalidConfiguration {
312            field: "tls.previous_material_encryption_keys",
313            reason: format!(
314                "`{TLS_PREVIOUS_MATERIAL_KEYS_ENV}` was set but did not contain any usable key material"
315            ),
316        }
317        .into());
318    }
319
320    Ok(keys)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::RuntimeBuilder;
327    use coil_auth::DefaultAuthModelPackage;
328    use coil_config::{AcmeChallenge, PlatformConfig, SecretRef, TlsMode};
329    use coil_tls::{
330        CertificateId, CertificateProviderKind, CustomerAppId, Hostname, HostnameBinding,
331    };
332
333    const TLS_RUNTIME_TEST_CONFIG: &str = r#"
334[app]
335name = "showcase-events"
336environment = "production"
337
338[server]
339bind = "0.0.0.0:8080"
340trusted_proxies = ["10.0.0.0/8"]
341
342[http.session]
343store = "redis"
344idle_timeout_secs = 3600
345absolute_timeout_secs = 86400
346
347[http.session_cookie]
348name = "coil_session"
349path = "/"
350same_site = "lax"
351secure = true
352http_only = true
353
354[http.flash_cookie]
355name = "coil_flash"
356path = "/"
357same_site = "lax"
358secure = true
359http_only = true
360
361[http.csrf]
362enabled = true
363field_name = "_csrf"
364header_name = "x-csrf-token"
365
366[tls]
367mode = "acme"
368challenge = "dns-01"
369provider = "cloudflare-dns"
370
371[storage]
372default_class = "public_upload"
373single_node_escape_hatch = "explicit_single_node"
374object_store = "s3"
375object_store_secret = { kind = "env", var = "OBJECT_STORE_URL" }
376local_root = "/tmp/coil-runtime-tests"
377deployment = "single_node"
378
379[cache]
380l1 = "moka"
381l2 = "redis"
382
383[i18n]
384default_locale = "en-GB"
385supported_locales = ["en-GB", "fr-FR"]
386fallback_locale = "en-GB"
387localized_routes = true
388
389[seo]
390canonical_host = "www.example.com"
391emit_json_ld = true
392
393[auth]
394package = "coil-default-auth"
395explain_api = false
396tenant_id = 101
397
398[modules]
399enabled = ["cms-pages", "admin-shell"]
400
401[wasm]
402directory = "extensions"
403default_time_limit_ms = 50
404allow_network = false
405
406[jobs]
407backend = "redis"
408
409[observability]
410metrics = true
411tracing = true
412
413[assets]
414publish_manifest = true
415cdn_base_url = "https://cdn.example.com"
416"#;
417
418    fn tls_runtime_test_config() -> PlatformConfig {
419        PlatformConfig::from_toml_str(TLS_RUNTIME_TEST_CONFIG).unwrap()
420    }
421
422    fn binding(hostname: &str) -> HostnameBinding {
423        HostnameBinding::new(
424            Hostname::new(hostname).unwrap(),
425            CustomerAppId::new("showcase-events").unwrap(),
426        )
427    }
428
429    #[test]
430    fn previous_tls_material_key_list_accepts_comma_and_newline_delimiters() {
431        let keys = parse_previous_material_keys(Some("old-a,\nold-b\nold-c".to_string())).unwrap();
432
433        assert_eq!(keys, vec!["old-a", "old-b", "old-c"]);
434    }
435
436    #[test]
437    fn previous_tls_material_key_list_rejects_empty_configured_values() {
438        let error = parse_previous_material_keys(Some(" , \n ".to_string())).unwrap_err();
439
440        assert_eq!(
441            error,
442            RuntimeTlsError::Tls(TlsModelError::InvalidConfiguration {
443                field: "tls.previous_material_encryption_keys",
444                reason: format!(
445                    "`{TLS_PREVIOUS_MATERIAL_KEYS_ENV}` was set but did not contain any usable key material"
446                ),
447            })
448        );
449    }
450
451    #[test]
452    fn tls_host_uses_real_acme_executor_in_tests() {
453        let mut config = tls_runtime_test_config();
454        config.tls.mode = TlsMode::Acme;
455        config.tls.challenge = Some(AcmeChallenge::TlsAlpn01);
456        config.tls.provider = None;
457        config.tls.account_secret = Some(SecretRef::SecretManager {
458            provider: "vault".to_string(),
459            key: "tls/acme".to_string(),
460        });
461        let plan = RuntimeBuilder::new(config, DefaultAuthModelPackage::default())
462            .build()
463            .unwrap();
464        let resolver = crate::server::StaticSecretResolver::new()
465            .with_secret(
466                SecretRef::SecretManager {
467                    provider: "vault".to_string(),
468                    key: "tls/acme".to_string(),
469                },
470                r#"{"tls_alpn_bind_address":"not-a-socket-address"}"#,
471            )
472            .unwrap();
473        let mut host = plan.tls_host_with_secret_resolver(&resolver).unwrap();
474
475        let error = host
476            .issue_certificate(
477                vec![binding("www.example.com")],
478                CertificateId::new("cert-real-acme-runtime").unwrap(),
479                TlsInstant::from_unix_seconds(1_700_000_000),
480            )
481            .unwrap_err();
482
483        assert!(matches!(
484            error,
485            RuntimeTlsError::Tls(TlsModelError::ProviderRequestFailed {
486                provider,
487                operation,
488                ..
489            }) if provider == CertificateProviderKind::Acme.to_string()
490                && operation == "parse_tls_alpn_bind_address"
491        ));
492    }
493}