Skip to main content

postrust_proxy/tls/
cert_store.rs

1//! Certificate storage with database metadata and file caching.
2
3use crate::error::ProxyResult;
4use sqlx::PgPool;
5use std::path::{Path, PathBuf};
6use tokio::sync::RwLock;
7use tracing::{debug, info};
8
9/// Certificate and key pair.
10#[derive(Clone)]
11pub struct Certificate {
12    /// Domain name
13    pub domain: String,
14    /// Certificate chain in PEM format
15    pub cert_pem: Vec<u8>,
16    /// Private key in PEM format
17    pub key_pem: Vec<u8>,
18    /// Expiry timestamp
19    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
20}
21
22/// Certificate store with database metadata and file caching.
23pub struct CertificateStore {
24    /// Database pool for metadata
25    pool: PgPool,
26    /// Cache directory for certificate files
27    cache_dir: PathBuf,
28    /// In-memory certificate cache
29    cache: RwLock<std::collections::HashMap<String, Certificate>>,
30}
31
32impl CertificateStore {
33    /// Create a new certificate store.
34    pub async fn new(pool: PgPool, cache_dir: impl AsRef<Path>) -> ProxyResult<Self> {
35        let cache_dir = cache_dir.as_ref().to_path_buf();
36
37        // Ensure cache directory exists
38        tokio::fs::create_dir_all(&cache_dir).await?;
39
40        Ok(Self {
41            pool,
42            cache_dir,
43            cache: RwLock::new(std::collections::HashMap::new()),
44        })
45    }
46
47    /// Get a certificate for a domain.
48    pub async fn get(&self, domain: &str) -> Option<Certificate> {
49        // Check in-memory cache first
50        {
51            let cache = self.cache.read().await;
52            if let Some(cert) = cache.get(domain) {
53                return Some(cert.clone());
54            }
55        }
56
57        // Try to load from file cache
58        if let Ok(cert) = self.load_from_file(domain).await {
59            let mut cache = self.cache.write().await;
60            cache.insert(domain.to_string(), cert.clone());
61            return Some(cert);
62        }
63
64        // Try to load from database
65        if let Ok(Some(cert)) = self.load_from_database(domain).await {
66            // Cache to file and memory
67            let _ = self.save_to_file(&cert).await;
68            let mut cache = self.cache.write().await;
69            cache.insert(domain.to_string(), cert.clone());
70            return Some(cert);
71        }
72
73        None
74    }
75
76    /// Save a certificate.
77    pub async fn save(&self, cert: Certificate) -> ProxyResult<()> {
78        // Save to database
79        self.save_to_database(&cert).await?;
80
81        // Save to file cache
82        self.save_to_file(&cert).await?;
83
84        // Update in-memory cache
85        let mut cache = self.cache.write().await;
86        cache.insert(cert.domain.clone(), cert);
87
88        Ok(())
89    }
90
91    /// Remove a certificate.
92    pub async fn remove(&self, domain: &str) -> ProxyResult<()> {
93        // Remove from database
94        sqlx::query("DELETE FROM proxy_certificates WHERE domain = $1")
95            .bind(domain)
96            .execute(&self.pool)
97            .await?;
98
99        // Remove from file cache
100        let cert_path = self.cache_dir.join(format!("{}.crt", domain));
101        let key_path = self.cache_dir.join(format!("{}.key", domain));
102        let _ = tokio::fs::remove_file(cert_path).await;
103        let _ = tokio::fs::remove_file(key_path).await;
104
105        // Remove from memory cache
106        let mut cache = self.cache.write().await;
107        cache.remove(domain);
108
109        info!("Removed certificate for domain: {}", domain);
110        Ok(())
111    }
112
113    /// List all stored domains.
114    pub async fn list_domains(&self) -> ProxyResult<Vec<String>> {
115        let rows: Vec<(String,)> = sqlx::query_as("SELECT domain FROM proxy_certificates")
116            .fetch_all(&self.pool)
117            .await?;
118
119        Ok(rows.into_iter().map(|(d,)| d).collect())
120    }
121
122    async fn load_from_file(&self, domain: &str) -> ProxyResult<Certificate> {
123        let cert_path = self.cache_dir.join(format!("{}.crt", domain));
124        let key_path = self.cache_dir.join(format!("{}.key", domain));
125
126        let cert_pem = tokio::fs::read(&cert_path).await?;
127        let key_pem = tokio::fs::read(&key_path).await?;
128
129        debug!("Loaded certificate from file: {}", domain);
130
131        Ok(Certificate {
132            domain: domain.to_string(),
133            cert_pem,
134            key_pem,
135            expires_at: None, // Would need to parse cert to get expiry
136        })
137    }
138
139    async fn save_to_file(&self, cert: &Certificate) -> ProxyResult<()> {
140        let cert_path = self.cache_dir.join(format!("{}.crt", cert.domain));
141        let key_path = self.cache_dir.join(format!("{}.key", cert.domain));
142
143        tokio::fs::write(&cert_path, &cert.cert_pem).await?;
144        tokio::fs::write(&key_path, &cert.key_pem).await?;
145
146        debug!("Saved certificate to file: {}", cert.domain);
147        Ok(())
148    }
149
150    async fn load_from_database(&self, domain: &str) -> ProxyResult<Option<Certificate>> {
151        let row: Option<(String, Vec<u8>, Vec<u8>, Option<chrono::DateTime<chrono::Utc>>)> =
152            sqlx::query_as(
153                "SELECT domain, cert_pem, key_pem, expires_at FROM proxy_certificates WHERE domain = $1",
154            )
155            .bind(domain)
156            .fetch_optional(&self.pool)
157            .await?;
158
159        Ok(
160            row.map(|(domain, cert_pem, key_pem, expires_at)| Certificate {
161                domain,
162                cert_pem,
163                key_pem,
164                expires_at,
165            }),
166        )
167    }
168
169    async fn save_to_database(&self, cert: &Certificate) -> ProxyResult<()> {
170        sqlx::query(
171            r#"
172            INSERT INTO proxy_certificates (domain, cert_pem, key_pem, expires_at, updated_at)
173            VALUES ($1, $2, $3, $4, NOW())
174            ON CONFLICT (domain) DO UPDATE SET
175                cert_pem = EXCLUDED.cert_pem,
176                key_pem = EXCLUDED.key_pem,
177                expires_at = EXCLUDED.expires_at,
178                updated_at = NOW()
179            "#,
180        )
181        .bind(&cert.domain)
182        .bind(&cert.cert_pem)
183        .bind(&cert.key_pem)
184        .bind(cert.expires_at)
185        .execute(&self.pool)
186        .await?;
187
188        info!("Saved certificate to database: {}", cert.domain);
189        Ok(())
190    }
191}