coil-tls 0.1.0

TLS management primitives for the Coil framework.
Documentation
use super::common::{
    CLOUDFLARE_API_BASE_URL, CLOUDFLARE_ORIGIN_VALIDITY_DAYS, ProviderSecret,
    build_certificate_request, build_record, generate_private_key, private_key_to_pem,
    provider_error,
};
use crate::TlsCertificateExecutor;
use crate::material::{CertificateMaterial, TlsMaterialProtector};
use crate::runtime::execution::{ChallengeValidation, ChallengeValidationCheck};
use crate::runtime::planning::IssuancePlan;
use crate::{
    CertificateId, CertificateProviderKind, CertificateRecord, CloudflareEncryptionMode,
    HostnameBinding, TlsInstant, TlsModelError,
};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone)]
pub struct CloudflareTlsCertificateExecutor {
    provider: CertificateProviderKind,
    control_plane: crate::runtime::TlsControlPlaneRuntime,
    protector: TlsMaterialProtector,
    account_secret_ref: Option<String>,
}

impl CloudflareTlsCertificateExecutor {
    pub fn new(
        provider: CertificateProviderKind,
        control_plane: crate::runtime::TlsControlPlaneRuntime,
        protector: TlsMaterialProtector,
        account_secret_ref: Option<String>,
    ) -> Self {
        Self {
            provider,
            control_plane,
            protector,
            account_secret_ref,
        }
    }
}

#[async_trait::async_trait]
impl TlsCertificateExecutor for CloudflareTlsCertificateExecutor {
    fn import_manual_certificate(
        &self,
        _bundle: crate::material::ManualCertificateBundle,
    ) -> Result<(), TlsModelError> {
        Err(TlsModelError::ManualModeRequiresImportedCertificate)
    }

    fn issue_certificate(
        &self,
        plan: &crate::runtime::planning::IssuancePlan,
        certificate_id: CertificateId,
        issued_at: TlsInstant,
    ) -> Result<CertificateRecord, TlsModelError> {
        match self.provider {
            CertificateProviderKind::CloudflareOriginCa => super::common::run_blocking(
                self.provider,
                "issue_cloudflare_origin_certificate",
                issue_cloudflare_origin_certificate(
                    self.provider,
                    self.protector.clone(),
                    self.account_secret_ref.clone(),
                    plan.bindings.clone(),
                    plan.state_store,
                    plan.cloudflare_mode,
                    certificate_id,
                    issued_at,
                ),
            ),
            CertificateProviderKind::CloudflareDns | CertificateProviderKind::Acme => {
                super::common::run_blocking(
                    self.provider,
                    "issue_acme_certificate",
                    super::acme::issue_acme_certificate(
                        self.provider,
                        self.protector.clone(),
                        self.account_secret_ref.clone(),
                        plan.bindings.clone(),
                        plan.challenge,
                        plan.state_store,
                        plan.cloudflare_mode,
                        certificate_id,
                        issued_at,
                    ),
                )
            }
            CertificateProviderKind::ManualImport => {
                Err(TlsModelError::ManualModeRequiresImportedCertificate)
            }
        }
    }

    fn renew_certificate(
        &self,
        plan: &crate::runtime::planning::RenewalPlan,
        _certificate_id: CertificateId,
        replacement_certificate_id: CertificateId,
        issued_at: TlsInstant,
    ) -> Result<CertificateRecord, TlsModelError> {
        let existing = self
            .control_plane
            .inventory()
            .record(&plan.certificate_id)
            .cloned()
            .ok_or_else(|| TlsModelError::UnknownCertificate {
                certificate_id: plan.certificate_id.to_string(),
            })?;

        match self.provider {
            CertificateProviderKind::CloudflareOriginCa => super::common::run_blocking(
                self.provider,
                "renew_cloudflare_origin_certificate",
                issue_cloudflare_origin_certificate(
                    self.provider,
                    self.protector.clone(),
                    self.account_secret_ref.clone(),
                    existing.bindings,
                    existing.store,
                    existing.cloudflare_mode,
                    replacement_certificate_id,
                    issued_at,
                ),
            ),
            CertificateProviderKind::CloudflareDns | CertificateProviderKind::Acme => {
                super::common::run_blocking(
                    self.provider,
                    "renew_acme_certificate",
                    super::acme::issue_acme_certificate(
                        self.provider,
                        self.protector.clone(),
                        self.account_secret_ref.clone(),
                        existing.bindings,
                        plan.challenge,
                        existing.store,
                        existing.cloudflare_mode,
                        replacement_certificate_id,
                        issued_at,
                    ),
                )
            }
            CertificateProviderKind::ManualImport => {
                Err(TlsModelError::ManualModeRequiresImportedCertificate)
            }
        }
    }

    fn certificate_material(
        &self,
        certificate_id: &CertificateId,
    ) -> Result<CertificateMaterial, TlsModelError> {
        super::common::decrypt_material(&self.control_plane, &self.protector, certificate_id)
    }

    fn validate_issuance_plan(
        &self,
        plan: &IssuancePlan,
    ) -> Result<ChallengeValidation, TlsModelError> {
        match self.provider {
            CertificateProviderKind::CloudflareOriginCa => {
                let secret =
                    ProviderSecret::resolve(self.provider, self.account_secret_ref.as_deref())?;
                secret.cloudflare_headers()?;
                Ok(ChallengeValidation {
                    provider: self.provider,
                    configured_challenge: plan.challenge,
                    effective_challenge: None,
                    shared_across_nodes: plan.shared_across_nodes,
                    requires_hot_reload: plan.requires_hot_reload,
                    checks: vec![ChallengeValidationCheck {
                        name: "cloudflare_headers",
                        ok: true,
                        detail:
                            "cloudflare origin-ca credentials resolved into authenticated API headers"
                                .to_string(),
                    }],
                })
            }
            CertificateProviderKind::CloudflareDns | CertificateProviderKind::Acme => {
                super::acme::validate_acme_issuance_plan(
                    self.provider,
                    self.account_secret_ref.as_deref(),
                    plan,
                )
            }
            CertificateProviderKind::ManualImport => {
                Err(TlsModelError::ManualModeRequiresImportedCertificate)
            }
        }
    }
}

async fn issue_cloudflare_origin_certificate(
    provider: CertificateProviderKind,
    protector: TlsMaterialProtector,
    account_secret_ref: Option<String>,
    bindings: Vec<HostnameBinding>,
    state_store: crate::CertificateStateStore,
    cloudflare_mode: Option<CloudflareEncryptionMode>,
    certificate_id: CertificateId,
    issued_at: TlsInstant,
) -> Result<CertificateRecord, TlsModelError> {
    let secret = ProviderSecret::resolve(provider, account_secret_ref.as_deref())?;
    let client = Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|error| provider_error(provider, "build_http_client", error))?;
    let private_key = generate_private_key(provider)?;
    let csr = build_certificate_request(provider, &private_key, &bindings)?;
    let hostnames = bindings
        .iter()
        .map(|binding| binding.hostname.as_str().to_string())
        .collect::<Vec<_>>();
    let response = client
        .post(format!("{CLOUDFLARE_API_BASE_URL}/certificates"))
        .headers(secret.cloudflare_headers()?)
        .json(&CloudflareOriginCaRequest {
            csr,
            hostnames,
            request_type: "origin-rsa".to_string(),
            requested_validity: CLOUDFLARE_ORIGIN_VALIDITY_DAYS,
        })
        .send()
        .and_then(|response| response.error_for_status())
        .map_err(|error| provider_error(provider, "create_origin_certificate", error))?
        .json::<CloudflareEnvelope<CloudflareOriginCaResult>>()
        .map_err(|error| provider_error(provider, "decode_origin_certificate", error))?;

    if !response.success {
        return Err(TlsModelError::ProviderRequestFailed {
            provider: provider.to_string(),
            operation: "create_origin_certificate",
            reason: response
                .errors
                .into_iter()
                .map(|error| error.message)
                .collect::<Vec<_>>()
                .join("; "),
        });
    }

    build_record(
        provider,
        certificate_id,
        &bindings,
        state_store,
        cloudflare_mode,
        issued_at,
        response.result.certificate,
        private_key_to_pem(provider, &private_key)?,
        &protector,
    )
}

#[derive(Debug, Serialize)]
struct CloudflareOriginCaRequest {
    csr: String,
    hostnames: Vec<String>,
    request_type: String,
    requested_validity: u64,
}

#[derive(Debug, Deserialize)]
struct CloudflareEnvelope<T> {
    success: bool,
    #[serde(default)]
    errors: Vec<CloudflareApiError>,
    result: T,
}

#[derive(Debug, Deserialize)]
struct CloudflareApiError {
    message: String,
}

#[derive(Debug, Deserialize)]
struct CloudflareOriginCaResult {
    certificate: String,
}