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}