coil-tls 0.1.1

TLS management primitives for the Coil framework.
Documentation
use std::fmt;
use std::time::Duration;

use coil_config::AcmeChallenge;
use serde::{Deserialize, Serialize};

use crate::TlsModelError;
use crate::material::EncryptedCertificateMaterial;
use crate::validation::validate_token;

macro_rules! token_type {
    ($name:ident, $field:literal) => {
        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
        pub struct $name(String);

        impl $name {
            pub fn new(value: impl Into<String>) -> Result<Self, TlsModelError> {
                Ok(Self(validate_token($field, value.into())?))
            }

            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str(&self.0)
            }
        }
    };
}

token_type!(CertificateId, "certificate_id");
token_type!(Hostname, "hostname");
token_type!(CustomerAppId, "customer_app_id");
token_type!(CertificateFingerprint, "certificate_fingerprint");

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SecretMaterialRef(String);

impl SecretMaterialRef {
    pub fn new(value: impl Into<String>) -> Result<Self, TlsModelError> {
        let value = value.into();
        let trimmed = value.trim();
        if trimmed.is_empty() {
            return Err(TlsModelError::EmptyField {
                field: "secret_material_ref",
            });
        }

        if trimmed.chars().any(|ch| ch.is_whitespace() || ch == '\0') {
            return Err(TlsModelError::InvalidToken {
                field: "secret_material_ref",
                value: trimmed.to_string(),
            });
        }

        Ok(Self(trimmed.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for SecretMaterialRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct TlsInstant(u64);

impl TlsInstant {
    pub const fn from_unix_seconds(seconds: u64) -> Self {
        Self(seconds)
    }

    pub const fn as_unix_seconds(self) -> u64 {
        self.0
    }

    pub fn saturating_sub(self, duration: Duration) -> Self {
        Self(self.0.saturating_sub(duration.as_secs()))
    }
}

impl fmt::Display for TlsInstant {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChallengeStrategy {
    Http01,
    TlsAlpn01,
    Dns01,
}

impl ChallengeStrategy {
    pub fn supports_wildcards(self) -> bool {
        matches!(self, Self::Dns01)
    }
}

impl fmt::Display for ChallengeStrategy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Http01 => f.write_str("http-01"),
            Self::TlsAlpn01 => f.write_str("tls-alpn-01"),
            Self::Dns01 => f.write_str("dns-01"),
        }
    }
}

impl From<AcmeChallenge> for ChallengeStrategy {
    fn from(value: AcmeChallenge) -> Self {
        match value {
            AcmeChallenge::Http01 => Self::Http01,
            AcmeChallenge::TlsAlpn01 => Self::TlsAlpn01,
            AcmeChallenge::Dns01 => Self::Dns01,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeMode {
    DirectTermination,
    ExternalTermination,
    CloudflareOriginOnly,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateProviderKind {
    Acme,
    CloudflareDns,
    CloudflareOriginCa,
    ManualImport,
}

impl fmt::Display for CertificateProviderKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Acme => f.write_str("acme"),
            Self::CloudflareDns => f.write_str("cloudflare_dns"),
            Self::CloudflareOriginCa => f.write_str("cloudflare_origin_ca"),
            Self::ManualImport => f.write_str("manual_import"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateStateStore {
    SharedSecrets,
    ExternalTermination,
    OperatorManaged,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CloudflareEncryptionMode {
    FullStrict,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateStatus {
    PendingIssuance,
    Active,
    RenewalDue,
    Renewing,
    Failed,
    Superseded,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenewalWindow {
    pub starts_at: TlsInstant,
    pub must_complete_by: TlsInstant,
    pub retry_interval: Duration,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostnameBinding {
    pub hostname: Hostname,
    pub customer_app: CustomerAppId,
    pub sni_enabled: bool,
}

impl HostnameBinding {
    pub fn new(hostname: Hostname, customer_app: CustomerAppId) -> Self {
        Self {
            hostname,
            customer_app,
            sni_enabled: true,
        }
    }

    pub fn is_wildcard(&self) -> bool {
        self.hostname.as_str().starts_with("*.")
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CertificateRecord {
    pub id: CertificateId,
    pub provider: CertificateProviderKind,
    pub status: CertificateStatus,
    pub fingerprint: CertificateFingerprint,
    pub issued_at: TlsInstant,
    pub not_after: TlsInstant,
    pub material_ref: SecretMaterialRef,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub material: Option<EncryptedCertificateMaterial>,
    pub bindings: Vec<HostnameBinding>,
    pub store: CertificateStateStore,
    pub cloudflare_mode: Option<CloudflareEncryptionMode>,
    pub replacing_certificate: Option<CertificateId>,
}

impl CertificateRecord {
    pub fn new(
        id: CertificateId,
        provider: CertificateProviderKind,
        status: CertificateStatus,
        fingerprint: CertificateFingerprint,
        issued_at: TlsInstant,
        not_after: TlsInstant,
        material_ref: SecretMaterialRef,
        store: CertificateStateStore,
    ) -> Self {
        Self {
            id,
            provider,
            status,
            fingerprint,
            issued_at,
            not_after,
            material_ref,
            material: None,
            bindings: Vec::new(),
            store,
            cloudflare_mode: None,
            replacing_certificate: None,
        }
    }

    pub fn with_binding(mut self, binding: HostnameBinding) -> Self {
        self.bindings.push(binding);
        self
    }

    pub fn with_material(mut self, material: EncryptedCertificateMaterial) -> Self {
        self.material = Some(material);
        self
    }

    pub fn with_cloudflare_mode(mut self, mode: CloudflareEncryptionMode) -> Self {
        self.cloudflare_mode = Some(mode);
        self
    }

    pub fn renewal_window(&self) -> RenewalWindow {
        RenewalWindow {
            starts_at: self
                .not_after
                .saturating_sub(Duration::from_secs(30 * 24 * 60 * 60)),
            must_complete_by: self.not_after,
            retry_interval: Duration::from_secs(6 * 60 * 60),
        }
    }
}