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///
11/// `Debug` prints the domain and expiry but never the key or the chain: a
12/// private key that reaches a log is a private key that has to be rotated.
13#[derive(Clone)]
14pub struct Certificate {
15    /// Domain name
16    pub domain: String,
17    /// Certificate chain in PEM format
18    pub cert_pem: Vec<u8>,
19    /// Private key in PEM format
20    pub key_pem: Vec<u8>,
21    /// Expiry timestamp
22    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
23}
24
25impl std::fmt::Debug for Certificate {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("Certificate")
28            .field("domain", &self.domain)
29            .field("expires_at", &self.expires_at)
30            .field("cert_pem", &format_args!("<{} bytes>", self.cert_pem.len()))
31            .field("key_pem", &format_args!("<redacted>"))
32            .finish()
33    }
34}
35
36/// Certificate store with database metadata and file caching.
37pub struct CertificateStore {
38    /// Database pool for metadata
39    pool: PgPool,
40    /// Cache directory for certificate files
41    cache_dir: PathBuf,
42    /// In-memory certificate cache
43    cache: RwLock<std::collections::HashMap<String, Certificate>>,
44}
45
46impl CertificateStore {
47    /// Create a new certificate store.
48    pub async fn new(pool: PgPool, cache_dir: impl AsRef<Path>) -> ProxyResult<Self> {
49        let cache_dir = cache_dir.as_ref().to_path_buf();
50
51        // Ensure cache directory exists
52        tokio::fs::create_dir_all(&cache_dir).await?;
53
54        Ok(Self {
55            pool,
56            cache_dir,
57            cache: RwLock::new(std::collections::HashMap::new()),
58        })
59    }
60
61    /// Get a certificate for a domain.
62    pub async fn get(&self, domain: &str) -> Option<Certificate> {
63        // Check in-memory cache first
64        {
65            let cache = self.cache.read().await;
66            if let Some(cert) = cache.get(domain) {
67                return Some(cert.clone());
68            }
69        }
70
71        // Try to load from file cache
72        if let Ok(cert) = self.load_from_file(domain).await {
73            let mut cache = self.cache.write().await;
74            cache.insert(domain.to_string(), cert.clone());
75            return Some(cert);
76        }
77
78        // Try to load from database
79        if let Ok(Some(cert)) = self.load_from_database(domain).await {
80            // Cache to file and memory
81            let _ = self.save_to_file(&cert).await;
82            let mut cache = self.cache.write().await;
83            cache.insert(domain.to_string(), cert.clone());
84            return Some(cert);
85        }
86
87        None
88    }
89
90    /// Save a certificate.
91    pub async fn save(&self, cert: Certificate) -> ProxyResult<()> {
92        // Save to database
93        self.save_to_database(&cert).await?;
94
95        // Save to file cache
96        self.save_to_file(&cert).await?;
97
98        // Update in-memory cache
99        let mut cache = self.cache.write().await;
100        cache.insert(cert.domain.clone(), cert);
101
102        Ok(())
103    }
104
105    /// Remove a certificate.
106    pub async fn remove(&self, domain: &str) -> ProxyResult<()> {
107        // Remove from database
108        sqlx::query("DELETE FROM proxy_certificates WHERE domain = $1")
109            .bind(domain)
110            .execute(&self.pool)
111            .await?;
112
113        // Remove from file cache
114        let cert_path = self.cache_dir.join(format!("{}.crt", domain));
115        let key_path = self.cache_dir.join(format!("{}.key", domain));
116        let _ = tokio::fs::remove_file(cert_path).await;
117        let _ = tokio::fs::remove_file(key_path).await;
118
119        // Remove from memory cache
120        let mut cache = self.cache.write().await;
121        cache.remove(domain);
122
123        info!("Removed certificate for domain: {}", domain);
124        Ok(())
125    }
126
127    /// List all stored domains.
128    pub async fn list_domains(&self) -> ProxyResult<Vec<String>> {
129        let rows: Vec<(String,)> = sqlx::query_as("SELECT domain FROM proxy_certificates")
130            .fetch_all(&self.pool)
131            .await?;
132
133        Ok(rows.into_iter().map(|(d,)| d).collect())
134    }
135
136    async fn load_from_file(&self, domain: &str) -> ProxyResult<Certificate> {
137        let cert_path = self.cache_dir.join(format!("{}.crt", domain));
138        let key_path = self.cache_dir.join(format!("{}.key", domain));
139
140        let cert_pem = tokio::fs::read(&cert_path).await?;
141        let key_pem = tokio::fs::read(&key_path).await?;
142
143        debug!("Loaded certificate from file: {}", domain);
144
145        Ok(Certificate {
146            domain: domain.to_string(),
147            cert_pem,
148            key_pem,
149            expires_at: None, // Would need to parse cert to get expiry
150        })
151    }
152
153    async fn save_to_file(&self, cert: &Certificate) -> ProxyResult<()> {
154        let cert_path = self.cache_dir.join(format!("{}.crt", cert.domain));
155        let key_path = self.cache_dir.join(format!("{}.key", cert.domain));
156
157        tokio::fs::write(&cert_path, &cert.cert_pem).await?;
158        tokio::fs::write(&key_path, &cert.key_pem).await?;
159
160        debug!("Saved certificate to file: {}", cert.domain);
161        Ok(())
162    }
163
164    /// Read every certificate from the database, ignoring both caches.
165    ///
166    /// [`get`](Self::get) answers from memory once a domain has been read
167    /// once, which is right for a handshake and wrong for a refresh: a
168    /// certificate renewed by *another* instance would never be noticed, and
169    /// this proxy would go on serving the old one until it was restarted. The
170    /// database is the authority, so a refresh asks it.
171    ///
172    /// The in-memory cache is replaced with what was read, so the two do not
173    /// then disagree.
174    pub async fn load_all(&self) -> ProxyResult<Vec<Certificate>> {
175        let rows: Vec<(
176            String,
177            Vec<u8>,
178            Vec<u8>,
179            Option<chrono::DateTime<chrono::Utc>>,
180        )> = sqlx::query_as("SELECT domain, cert_pem, key_pem, expires_at FROM proxy_certificates")
181            .fetch_all(&self.pool)
182            .await?;
183
184        let certificates: Vec<Certificate> = rows
185            .into_iter()
186            .map(|(domain, cert_pem, key_pem, expires_at)| Certificate {
187                domain,
188                cert_pem,
189                key_pem,
190                expires_at,
191            })
192            .collect();
193
194        let mut cache = self.cache.write().await;
195        *cache = certificates
196            .iter()
197            .map(|c| (c.domain.clone(), c.clone()))
198            .collect();
199
200        debug!(
201            "Loaded {} certificate(s) from the database",
202            certificates.len()
203        );
204        Ok(certificates)
205    }
206
207    async fn load_from_database(&self, domain: &str) -> ProxyResult<Option<Certificate>> {
208        let row: Option<(String, Vec<u8>, Vec<u8>, Option<chrono::DateTime<chrono::Utc>>)> =
209            sqlx::query_as(
210                "SELECT domain, cert_pem, key_pem, expires_at FROM proxy_certificates WHERE domain = $1",
211            )
212            .bind(domain)
213            .fetch_optional(&self.pool)
214            .await?;
215
216        Ok(
217            row.map(|(domain, cert_pem, key_pem, expires_at)| Certificate {
218                domain,
219                cert_pem,
220                key_pem,
221                expires_at,
222            }),
223        )
224    }
225
226    async fn save_to_database(&self, cert: &Certificate) -> ProxyResult<()> {
227        sqlx::query(
228            r#"
229            INSERT INTO proxy_certificates (domain, cert_pem, key_pem, expires_at, updated_at)
230            VALUES ($1, $2, $3, $4, NOW())
231            ON CONFLICT (domain) DO UPDATE SET
232                cert_pem = EXCLUDED.cert_pem,
233                key_pem = EXCLUDED.key_pem,
234                expires_at = EXCLUDED.expires_at,
235                updated_at = NOW()
236            "#,
237        )
238        .bind(&cert.domain)
239        .bind(&cert.cert_pem)
240        .bind(&cert.key_pem)
241        .bind(cert.expires_at)
242        .execute(&self.pool)
243        .await?;
244
245        info!("Saved certificate to database: {}", cert.domain);
246        Ok(())
247    }
248}