1#![doc = include_str!("../README.md")]
15#![forbid(unsafe_code)]
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use cratefield_core::{
20 CertificateStatus, CustomHostname, CustomHostnameError, CustomHostnames, DnsRecordType,
21 HostnameClaim, HttpClient, ProviderStatus, Validation, check_hostname, scrub_text,
22};
23use http::header::{AUTHORIZATION, CONTENT_TYPE};
24use http::{Request, StatusCode};
25use std::sync::Arc;
26
27pub const ZONE_ID_VAR: &str = "CF_SAAS_ZONE_ID";
30pub const ZONE_NAME_VAR: &str = "CF_SAAS_ZONE_NAME";
33pub const API_TOKEN_VAR: &str = "CF_SAAS_API_TOKEN";
36pub const CNAME_TARGET_VAR: &str = "CF_SAAS_CNAME_TARGET";
39
40const API_BASE: &str = "https://api.cloudflare.com/client/v4";
42
43const CERTIFICATE_TYPE: &str = "dv";
46
47const MIN_TLS_VERSION: &str = "1.2";
49
50#[derive(Clone)]
53pub struct CloudflareSaasConfig {
54 pub zone_id: String,
56 pub zone_name: String,
59 pub api_token: String,
62 pub cname_target: Option<String>,
66}
67
68impl CloudflareSaasConfig {
69 #[must_use]
75 pub fn from_vars(
76 zone_id: Option<String>,
77 zone_name: Option<String>,
78 api_token: Option<String>,
79 cname_target: Option<String>,
80 ) -> Option<Self> {
81 let zone_id = non_empty(zone_id)?;
82 if !is_safe_zone_id(&zone_id) {
83 return None;
84 }
85 Some(Self {
86 zone_id,
87 zone_name: non_empty(zone_name)?,
88 api_token: non_empty(api_token)?,
89 cname_target: non_empty(cname_target),
90 })
91 }
92}
93
94fn non_empty(value: Option<String>) -> Option<String> {
96 value.filter(|text| !text.trim().is_empty())
97}
98
99fn is_safe_zone_id(zone_id: &str) -> bool {
104 !zone_id.is_empty()
105 && zone_id
106 .chars()
107 .all(|character| character.is_ascii_alphanumeric())
108}
109
110fn checked_id(id: &str) -> Result<&str, CustomHostnameError> {
115 if !id.is_empty()
116 && id
117 .chars()
118 .all(|character| character.is_ascii_alphanumeric() || character == '-')
119 {
120 Ok(id)
121 } else {
122 Err(CustomHostnameError::Provider(format!(
123 "the provider returned an id that is not a safe path segment: {id:?}"
124 )))
125 }
126}
127
128impl std::fmt::Debug for CloudflareSaasConfig {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("CloudflareSaasConfig")
133 .field("zone_id", &self.zone_id)
134 .field("zone_name", &self.zone_name)
135 .field("api_token", &"[redacted]")
136 .field("cname_target", &self.cname_target)
137 .finish()
138 }
139}
140
141pub struct CloudflareSaas {
143 http: Arc<dyn HttpClient>,
144 config: Option<CloudflareSaasConfig>,
145}
146
147impl std::fmt::Debug for CloudflareSaas {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("CloudflareSaas")
151 .field("config", &self.config)
152 .finish_non_exhaustive()
153 }
154}
155
156impl CloudflareSaas {
157 #[must_use]
162 pub fn new(http: Arc<dyn HttpClient>, config: CloudflareSaasConfig) -> Self {
163 if !is_safe_zone_id(&config.zone_id) {
164 tracing::warn!(
165 provider = "cloudflare-saas",
166 variable = ZONE_ID_VAR,
167 "the zone id is not alphanumeric; the adapter answers NotConfigured"
168 );
169 return Self { http, config: None };
170 }
171 Self {
172 http,
173 config: Some(config),
174 }
175 }
176
177 #[must_use]
180 pub fn not_configured(http: Arc<dyn HttpClient>) -> Self {
181 Self { http, config: None }
182 }
183
184 #[must_use]
191 pub fn from_env(http: Arc<dyn HttpClient>) -> Self {
192 let config = CloudflareSaasConfig::from_vars(
193 std::env::var(ZONE_ID_VAR).ok(),
194 std::env::var(ZONE_NAME_VAR).ok(),
195 std::env::var(API_TOKEN_VAR).ok(),
196 std::env::var(CNAME_TARGET_VAR).ok(),
197 );
198 Self { http, config }
199 }
200
201 #[must_use]
203 pub fn is_configured(&self) -> bool {
204 self.config.is_some()
205 }
206
207 fn require_config(&self) -> Result<&CloudflareSaasConfig, CustomHostnameError> {
210 self.config.as_ref().ok_or_else(|| {
211 tracing::info!(
212 provider = "cloudflare-saas",
213 outcome = "not_configured",
214 "custom hostname outcome"
215 );
216 CustomHostnameError::NotConfigured
217 })
218 }
219
220 async fn send(
223 &self,
224 config: &CloudflareSaasConfig,
225 method: http::Method,
226 uri: String,
227 body: Option<Vec<u8>>,
228 ) -> Result<(StatusCode, String), CustomHostnameError> {
229 let token = &config.api_token;
230 let mut builder = Request::builder()
231 .method(method)
232 .uri(uri)
233 .header(AUTHORIZATION, format!("Bearer {token}"));
234 if body.is_some() {
235 builder = builder.header(CONTENT_TYPE, "application/json");
236 }
237 let request = builder
238 .body(Bytes::from(body.unwrap_or_default()))
239 .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
240 let response = self
241 .http
242 .send(request)
243 .await
244 .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
245 let status = response.status();
246 let text = String::from_utf8_lossy(response.body()).to_string();
247 Ok((status, text))
248 }
249
250 fn map_error(status: StatusCode, errors: &[ApiError], body: &str) -> CustomHostnameError {
255 let code = errors.first().map(|error| error.code);
256 if status == StatusCode::FORBIDDEN && matches!(code, Some(1404 | 1405)) {
257 return CustomHostnameError::Rejected(Self::detail(errors, body));
258 }
259 match code {
260 Some(1406) => return CustomHostnameError::AlreadyExists,
261 Some(1436) => return CustomHostnameError::NotFound,
262 Some(1403 | 1000..=1005 | 10000 | 9109) => return CustomHostnameError::Unauthorized,
263 _ => {}
264 }
265 match status {
266 StatusCode::CONFLICT => CustomHostnameError::AlreadyExists,
267 StatusCode::NOT_FOUND => CustomHostnameError::NotFound,
268 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => CustomHostnameError::Unauthorized,
269 StatusCode::TOO_MANY_REQUESTS => CustomHostnameError::RateLimited,
270 status if status.is_client_error() => {
271 CustomHostnameError::Rejected(Self::detail(errors, body))
272 }
273 _ => CustomHostnameError::Provider(Self::detail(errors, body)),
274 }
275 }
276
277 fn detail(errors: &[ApiError], body: &str) -> String {
282 match errors.first() {
283 Some(error) if !error.message.is_empty() => {
284 let code = error.code;
285 let message = &error.message;
286 format!("{code}: {message}")
287 }
288 Some(error) => error.code.to_string(),
289 None => {
290 let trimmed = body.trim();
291 if trimmed.is_empty() {
292 "the provider sent no error detail".to_owned()
293 } else {
294 trimmed.to_owned()
295 }
296 }
297 }
298 }
299
300 fn parse_errors(body: &str) -> Vec<ApiError> {
303 match serde_json::from_str::<Envelope<serde_json::Value>>(body) {
304 Ok(envelope) => envelope.errors,
305 Err(_) => Vec::new(),
306 }
307 }
308
309 fn error_from(status: StatusCode, body: &str) -> CustomHostnameError {
311 Self::map_error(status, &Self::parse_errors(body), body)
312 }
313
314 async fn find(
319 &self,
320 config: &CloudflareSaasConfig,
321 hostname: &str,
322 ) -> Result<Option<WireHostname>, CustomHostnameError> {
323 let collection = collection_uri(&config.zone_id);
324 let uri = format!("{collection}?hostname={hostname}&per_page=50");
325 let (status, text) = self.send(config, http::Method::GET, uri, None).await?;
326 if !status.is_success() {
327 return Err(Self::error_from(status, &text));
328 }
329 let envelope: Envelope<Vec<WireHostname>> =
330 serde_json::from_str(&text).map_err(|err| parse_error(&err))?;
331 if !envelope.success {
332 return Err(CustomHostnameError::Provider(Self::detail(
333 &envelope.errors,
334 &text,
335 )));
336 }
337 if let Some(wire) = envelope
338 .result
339 .unwrap_or_default()
340 .into_iter()
341 .find(|wire| wire.hostname == hostname)
342 {
343 return Ok(Some(wire));
344 }
345 if envelope
350 .result_info
351 .is_some_and(|info| info.total_pages > 1)
352 {
353 return Err(CustomHostnameError::Provider(
354 "the hostname filter matched more than one page; refusing to guess".to_owned(),
355 ));
356 }
357 Ok(None)
358 }
359
360 fn success_body<T: serde::de::DeserializeOwned>(
363 status: StatusCode,
364 text: &str,
365 ) -> Result<T, CustomHostnameError> {
366 if !status.is_success() {
367 return Err(Self::error_from(status, text));
368 }
369 let envelope: Envelope<T> = serde_json::from_str(text).map_err(|err| parse_error(&err))?;
370 if !envelope.success {
371 return Err(CustomHostnameError::Provider(Self::detail(
372 &envelope.errors,
373 text,
374 )));
375 }
376 envelope.result.ok_or_else(|| {
377 CustomHostnameError::Provider("the provider's response carried no result".to_owned())
378 })
379 }
380}
381
382#[async_trait]
383impl CustomHostnames for CloudflareSaas {
384 async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError> {
385 let config = self.require_config()?;
386 let hostname = check_hostname(&claim.hostname, &config.zone_name)
387 .map_err(CustomHostnameError::Refused)?;
388 let method = claim.method.as_str();
389 let body = serde_json::to_vec(&serde_json::json!({
390 "hostname": hostname,
391 "ssl": {
392 "method": method,
393 "type": CERTIFICATE_TYPE,
394 "settings": { "min_tls_version": MIN_TLS_VERSION },
395 },
396 }))
397 .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
398 let uri = collection_uri(&config.zone_id);
399 let (status, text) = self
400 .send(config, http::Method::POST, uri, Some(body))
401 .await?;
402 if !status.is_success() {
403 let error = Self::error_from(status, &text);
404 tracing::warn!(
405 provider = "cloudflare-saas",
406 zone = %config.zone_name,
407 hostname = %hostname,
408 code = status.as_u16(),
409 error = %error,
410 "custom hostname outcome"
411 );
412 return Err(error);
413 }
414 let wire = Self::success_body::<WireHostname>(status, &text)?;
415 tracing::info!(
416 provider = "cloudflare-saas",
417 zone = %config.zone_name,
418 hostname = %wire.hostname,
419 status = %wire.status,
420 "custom hostname outcome"
421 );
422 Ok(wire.into_custom_hostname(config.cname_target.as_deref()))
423 }
424
425 async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError> {
426 let config = self.require_config()?;
427 let hostname =
428 check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
429 let found = self.find(config, &hostname).await?;
430 Ok(found.map(|wire| wire.into_custom_hostname(config.cname_target.as_deref())))
431 }
432
433 async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError> {
434 let config = self.require_config()?;
435 let hostname =
436 check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
437 let Some(found) = self.find(config, &hostname).await? else {
438 return Ok(());
440 };
441 let collection = collection_uri(&config.zone_id);
442 let id = checked_id(&found.id)?;
443 let uri = format!("{collection}/{id}");
444 let (status, text) = self.send(config, http::Method::DELETE, uri, None).await?;
445 if status.is_success() {
446 tracing::info!(
447 provider = "cloudflare-saas",
448 zone = %config.zone_name,
449 hostname = %hostname,
450 "custom hostname outcome"
451 );
452 return Ok(());
453 }
454 if status == StatusCode::NOT_FOUND
456 || Self::parse_errors(&text)
457 .first()
458 .is_some_and(|error| error.code == 1436)
459 {
460 return Ok(());
461 }
462 Err(Self::error_from(status, &text))
463 }
464
465 async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError> {
466 let config = self.require_config()?;
467 let hostname =
468 check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
469 let Some(found) = self.find(config, &hostname).await? else {
470 return Err(CustomHostnameError::NotFound);
471 };
472 let method = found
475 .ssl
476 .as_ref()
477 .map(|ssl| ssl.method.as_str())
478 .filter(|method| !method.is_empty())
479 .unwrap_or("http");
480 let body = serde_json::to_vec(&serde_json::json!({
481 "ssl": { "method": method, "type": CERTIFICATE_TYPE },
482 }))
483 .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
484 let collection = collection_uri(&config.zone_id);
485 let id = checked_id(&found.id)?;
486 let uri = format!("{collection}/{id}");
487 let (status, text) = self
488 .send(config, http::Method::PATCH, uri, Some(body))
489 .await?;
490 if !status.is_success() {
491 let error = Self::error_from(status, &text);
492 tracing::warn!(
493 provider = "cloudflare-saas",
494 zone = %config.zone_name,
495 hostname = %hostname,
496 code = status.as_u16(),
497 error = %error,
498 "custom hostname outcome"
499 );
500 return Err(error);
501 }
502 let wire = Self::success_body::<WireHostname>(status, &text)?;
503 tracing::info!(
504 provider = "cloudflare-saas",
505 zone = %config.zone_name,
506 hostname = %wire.hostname,
507 status = %wire.status,
508 "custom hostname outcome"
509 );
510 Ok(wire.into_custom_hostname(config.cname_target.as_deref()))
511 }
512}
513
514fn collection_uri(zone_id: &str) -> String {
516 format!("{API_BASE}/zones/{zone_id}/custom_hostnames")
517}
518
519fn parse_error(err: &serde_json::Error) -> CustomHostnameError {
522 CustomHostnameError::Provider(format!("the provider's response did not parse: {err}"))
523}
524
525#[derive(serde::Deserialize)]
528#[serde(bound(deserialize = "T: serde::de::Deserialize<'de>"))]
531struct Envelope<T> {
532 #[serde(default)]
533 success: bool,
534 #[serde(default)]
535 errors: Vec<ApiError>,
536 #[serde(default)]
537 result: Option<T>,
538 #[serde(default)]
539 result_info: Option<ResultInfo>,
540}
541
542#[derive(serde::Deserialize, Default)]
544struct ResultInfo {
545 #[serde(default)]
546 total_pages: u64,
547}
548
549#[derive(serde::Deserialize, Default)]
550struct ApiError {
551 #[serde(default)]
552 code: i64,
553 #[serde(default)]
554 message: String,
555}
556
557#[derive(serde::Deserialize, Default)]
560struct WireHostname {
561 #[serde(default)]
562 id: String,
563 #[serde(default)]
564 hostname: String,
565 #[serde(default)]
566 status: String,
567 #[serde(default)]
568 verification_errors: Vec<String>,
569 #[serde(default)]
570 ownership_verification: Option<OwnershipVerification>,
571 #[serde(default)]
572 ssl: Option<WireSsl>,
573}
574
575#[derive(serde::Deserialize, Default)]
576struct OwnershipVerification {
577 #[serde(default)]
578 name: String,
579 #[serde(default)]
580 value: String,
581}
582
583#[derive(serde::Deserialize, Default)]
584struct WireSsl {
585 #[serde(default)]
586 method: String,
587 #[serde(default)]
588 status: String,
589 #[serde(default)]
590 validation_errors: Vec<ValidationError>,
591 #[serde(default)]
592 validation_records: Vec<ValidationRecord>,
593}
594
595#[derive(serde::Deserialize, Default)]
596struct ValidationError {
597 #[serde(default)]
598 message: String,
599}
600
601#[derive(serde::Deserialize, Default)]
602struct ValidationRecord {
603 #[serde(default)]
604 txt_name: Option<String>,
605 #[serde(default)]
606 txt_value: Option<String>,
607}
608
609impl WireHostname {
610 fn into_custom_hostname(self, cname_target: Option<&str>) -> CustomHostname {
613 let status = self.provider_status();
614 let certificate = self.certificate_status();
615 let validation = self.validation(cname_target);
616 CustomHostname {
617 id: self.id,
618 hostname: self.hostname,
619 status,
620 certificate,
621 validation,
622 }
623 }
624
625 fn provider_status(&self) -> ProviderStatus {
629 match self.status.as_str() {
630 "active" => ProviderStatus::Active,
631 "blocked" | "moved" | "deleted" | "test_failed" | "pending_deletion" => {
632 ProviderStatus::Failed {
633 reason: scrub_text(&self.failure_reason()),
634 }
635 }
636 _ => ProviderStatus::Pending,
637 }
638 }
639
640 fn failure_reason(&self) -> String {
643 self.verification_errors
644 .first()
645 .cloned()
646 .unwrap_or_else(|| format!("hostname {}", self.status))
647 }
648
649 fn certificate_status(&self) -> CertificateStatus {
652 let Some(ssl) = self.ssl.as_ref() else {
653 return CertificateStatus::Pending;
654 };
655 match ssl.status.as_str() {
656 "active" => CertificateStatus::Active,
657 "validation_timed_out"
658 | "issuance_timed_out"
659 | "deployment_timed_out"
660 | "deletion_timed_out"
661 | "initializing_timed_out"
662 | "expired"
663 | "deleted" => CertificateStatus::Failed {
664 reason: scrub_text(&ssl.failure_reason()),
665 },
666 _ => CertificateStatus::Pending,
667 }
668 }
669
670 fn validation(&self, cname_target: Option<&str>) -> Vec<Validation> {
675 let status = self.provider_status();
676 let certificate = self.certificate_status();
677 let mut records = Vec::new();
678 if !matches!(status, ProviderStatus::Active) {
679 if let Some(target) = cname_target {
680 records.push(Validation {
681 record_type: DnsRecordType::Cname,
682 name: self.hostname.clone(),
683 value: target.to_owned(),
684 });
685 }
686 if let Some(ownership) = &self.ownership_verification {
687 records.push(Validation {
688 record_type: DnsRecordType::Txt,
689 name: ownership.name.clone(),
690 value: ownership.value.clone(),
691 });
692 }
693 }
694 if !matches!(certificate, CertificateStatus::Active)
695 && let Some(ssl) = &self.ssl
696 {
697 for record in &ssl.validation_records {
698 if let (Some(name), Some(value)) = (&record.txt_name, &record.txt_value) {
699 records.push(Validation {
700 record_type: DnsRecordType::Txt,
701 name: name.clone(),
702 value: value.clone(),
703 });
704 }
705 }
706 }
707 records
708 }
709}
710
711impl WireSsl {
712 fn failure_reason(&self) -> String {
715 self.validation_errors
716 .iter()
717 .map(|error| error.message.as_str())
718 .find(|message| !message.is_empty())
719 .map_or_else(|| format!("certificate {}", self.status), str::to_owned)
720 }
721}