Skip to main content

sentinel_proxy/acme/
storage.rs

1//! Certificate and account storage for ACME
2//!
3//! Provides persistent storage for ACME account credentials and issued certificates.
4//!
5//! # Directory Structure
6//!
7//! ```text
8//! storage/
9//! ├── account.json          # ACME account credentials (opaque, serialized)
10//! └── domains/
11//!     └── example.com/
12//!         ├── cert.pem      # Certificate chain
13//!         ├── key.pem       # Private key
14//!         └── meta.json     # Certificate metadata (expiry, issued date)
15//! ```
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use tracing::{debug, info, trace, warn};
23
24use super::error::StorageError;
25
26/// Certificate metadata stored alongside the certificate
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CertificateMeta {
29    /// When the certificate expires
30    pub expires: DateTime<Utc>,
31    /// When the certificate was issued
32    pub issued: DateTime<Utc>,
33    /// Domains covered by this certificate
34    pub domains: Vec<String>,
35    /// Issuer (e.g., "Let's Encrypt")
36    #[serde(default)]
37    pub issuer: Option<String>,
38}
39
40/// A stored certificate with its metadata
41#[derive(Debug, Clone)]
42pub struct StoredCertificate {
43    /// PEM-encoded certificate chain
44    pub cert_pem: String,
45    /// PEM-encoded private key
46    pub key_pem: String,
47    /// Certificate metadata
48    pub meta: CertificateMeta,
49}
50
51/// ACME account metadata for storage
52///
53/// Stores metadata about the ACME account alongside the credentials JSON.
54/// The actual `instant_acme::AccountCredentials` is stored separately as JSON.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StoredAccountCredentials {
57    /// Contact email (for reference)
58    #[serde(default)]
59    pub contact_email: Option<String>,
60    /// When the account was created
61    pub created: DateTime<Utc>,
62}
63
64/// Certificate storage manager
65///
66/// Handles persistent storage of ACME account credentials and certificates.
67/// Uses a simple filesystem-based storage with restrictive permissions.
68#[derive(Debug)]
69pub struct CertificateStorage {
70    /// Base storage directory
71    base_path: PathBuf,
72}
73
74impl CertificateStorage {
75    /// Create a new certificate storage at the given path
76    ///
77    /// Creates the directory structure if it doesn't exist and sets
78    /// restrictive permissions (0700 on Unix).
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the directory cannot be created or permissions
83    /// cannot be set.
84    pub fn new(base_path: &Path) -> Result<Self, StorageError> {
85        // Create base directory
86        fs::create_dir_all(base_path)?;
87
88        // Create domains subdirectory
89        let domains_path = base_path.join("domains");
90        fs::create_dir_all(&domains_path)?;
91
92        // Set restrictive permissions on Unix
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::PermissionsExt;
96            let perms = fs::Permissions::from_mode(0o700);
97            fs::set_permissions(base_path, perms.clone())?;
98            fs::set_permissions(&domains_path, perms)?;
99        }
100
101        info!(
102            storage_path = %base_path.display(),
103            "Initialized ACME certificate storage"
104        );
105
106        Ok(Self {
107            base_path: base_path.to_path_buf(),
108        })
109    }
110
111    /// Get the storage base path
112    pub fn base_path(&self) -> &Path {
113        &self.base_path
114    }
115
116    // =========================================================================
117    // Account Operations
118    // =========================================================================
119
120    /// Load stored account credentials
121    pub fn load_account(&self) -> Result<Option<StoredAccountCredentials>, StorageError> {
122        let account_path = self.base_path.join("account.json");
123
124        if !account_path.exists() {
125            trace!("No stored ACME account found");
126            return Ok(None);
127        }
128
129        let content = fs::read_to_string(&account_path)?;
130        let creds: StoredAccountCredentials = serde_json::from_str(&content)?;
131
132        debug!(
133            contact = ?creds.contact_email,
134            created = %creds.created,
135            "Loaded ACME account credentials"
136        );
137        Ok(Some(creds))
138    }
139
140    /// Save account credentials
141    pub fn save_account(&self, creds: &StoredAccountCredentials) -> Result<(), StorageError> {
142        let account_path = self.base_path.join("account.json");
143        let content = serde_json::to_string_pretty(creds)?;
144        fs::write(&account_path, content)?;
145
146        // Set restrictive permissions on the account file
147        #[cfg(unix)]
148        {
149            use std::os::unix::fs::PermissionsExt;
150            fs::set_permissions(&account_path, fs::Permissions::from_mode(0o600))?;
151        }
152
153        info!(contact = ?creds.contact_email, "Saved ACME account credentials");
154        Ok(())
155    }
156
157    /// Load raw credentials JSON (for instant_acme::AccountCredentials)
158    pub fn load_credentials_json(&self) -> Result<Option<String>, StorageError> {
159        let creds_path = self.base_path.join("credentials.json");
160
161        if !creds_path.exists() {
162            trace!("No stored ACME credentials found");
163            return Ok(None);
164        }
165
166        let content = fs::read_to_string(&creds_path)?;
167        debug!("Loaded ACME credentials JSON");
168        Ok(Some(content))
169    }
170
171    /// Save raw credentials JSON (for instant_acme::AccountCredentials)
172    pub fn save_credentials_json(&self, json: &str) -> Result<(), StorageError> {
173        let creds_path = self.base_path.join("credentials.json");
174        fs::write(&creds_path, json)?;
175
176        // Set restrictive permissions on the credentials file
177        #[cfg(unix)]
178        {
179            use std::os::unix::fs::PermissionsExt;
180            fs::set_permissions(&creds_path, fs::Permissions::from_mode(0o600))?;
181        }
182
183        info!("Saved ACME credentials JSON");
184        Ok(())
185    }
186
187    // =========================================================================
188    // Certificate Operations
189    // =========================================================================
190
191    /// Get the path to a domain's certificate directory
192    fn domain_path(&self, domain: &str) -> PathBuf {
193        self.base_path.join("domains").join(domain)
194    }
195
196    /// Load a stored certificate for a domain
197    pub fn load_certificate(&self, domain: &str) -> Result<Option<StoredCertificate>, StorageError> {
198        let domain_path = self.domain_path(domain);
199        let cert_path = domain_path.join("cert.pem");
200        let key_path = domain_path.join("key.pem");
201        let meta_path = domain_path.join("meta.json");
202
203        if !cert_path.exists() {
204            trace!(domain = %domain, "No stored certificate found");
205            return Ok(None);
206        }
207
208        let cert_pem = fs::read_to_string(&cert_path)?;
209        let key_pem = fs::read_to_string(&key_path)?;
210        let meta_content = fs::read_to_string(&meta_path)?;
211        let meta: CertificateMeta = serde_json::from_str(&meta_content)?;
212
213        debug!(
214            domain = %domain,
215            expires = %meta.expires,
216            "Loaded stored certificate"
217        );
218
219        Ok(Some(StoredCertificate {
220            cert_pem,
221            key_pem,
222            meta,
223        }))
224    }
225
226    /// Save a certificate for a domain
227    pub fn save_certificate(
228        &self,
229        domain: &str,
230        cert_pem: &str,
231        key_pem: &str,
232        expires: DateTime<Utc>,
233        all_domains: &[String],
234    ) -> Result<(), StorageError> {
235        let domain_path = self.domain_path(domain);
236        fs::create_dir_all(&domain_path)?;
237
238        let cert_path = domain_path.join("cert.pem");
239        let key_path = domain_path.join("key.pem");
240        let meta_path = domain_path.join("meta.json");
241
242        // Write certificate
243        fs::write(&cert_path, cert_pem)?;
244
245        // Write private key with restrictive permissions
246        fs::write(&key_path, key_pem)?;
247        #[cfg(unix)]
248        {
249            use std::os::unix::fs::PermissionsExt;
250            fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
251        }
252
253        // Write metadata
254        let meta = CertificateMeta {
255            expires,
256            issued: Utc::now(),
257            domains: all_domains.to_vec(),
258            issuer: Some("Let's Encrypt".to_string()),
259        };
260        let meta_content = serde_json::to_string_pretty(&meta)?;
261        fs::write(&meta_path, meta_content)?;
262
263        info!(
264            domain = %domain,
265            expires = %expires,
266            "Saved certificate to storage"
267        );
268
269        Ok(())
270    }
271
272    /// Check if a certificate needs renewal
273    ///
274    /// Returns `true` if:
275    /// - No certificate exists for the domain
276    /// - Certificate expires within `renew_before_days` days
277    pub fn needs_renewal(&self, domain: &str, renew_before_days: u32) -> Result<bool, StorageError> {
278        let Some(cert) = self.load_certificate(domain)? else {
279            debug!(domain = %domain, "No certificate exists, needs issuance");
280            return Ok(true);
281        };
282
283        let renew_threshold = Utc::now() + chrono::Duration::days(i64::from(renew_before_days));
284        let needs_renewal = cert.meta.expires <= renew_threshold;
285
286        if needs_renewal {
287            debug!(
288                domain = %domain,
289                expires = %cert.meta.expires,
290                threshold = %renew_threshold,
291                "Certificate needs renewal"
292            );
293        } else {
294            trace!(
295                domain = %domain,
296                expires = %cert.meta.expires,
297                "Certificate is still valid"
298            );
299        }
300
301        Ok(needs_renewal)
302    }
303
304    /// Get certificate paths for a domain
305    ///
306    /// Returns the paths to cert.pem and key.pem if they exist.
307    pub fn certificate_paths(&self, domain: &str) -> Option<(PathBuf, PathBuf)> {
308        let domain_path = self.domain_path(domain);
309        let cert_path = domain_path.join("cert.pem");
310        let key_path = domain_path.join("key.pem");
311
312        if cert_path.exists() && key_path.exists() {
313            Some((cert_path, key_path))
314        } else {
315            None
316        }
317    }
318
319    /// List all stored domains
320    pub fn list_domains(&self) -> Result<Vec<String>, StorageError> {
321        let domains_path = self.base_path.join("domains");
322
323        if !domains_path.exists() {
324            return Ok(Vec::new());
325        }
326
327        let mut domains = Vec::new();
328        for entry in fs::read_dir(&domains_path)? {
329            let entry = entry?;
330            if entry.file_type()?.is_dir() {
331                if let Some(name) = entry.file_name().to_str() {
332                    domains.push(name.to_string());
333                }
334            }
335        }
336
337        Ok(domains)
338    }
339
340    /// Delete stored certificate for a domain
341    pub fn delete_certificate(&self, domain: &str) -> Result<(), StorageError> {
342        let domain_path = self.domain_path(domain);
343
344        if domain_path.exists() {
345            fs::remove_dir_all(&domain_path)?;
346            info!(domain = %domain, "Deleted stored certificate");
347        } else {
348            warn!(domain = %domain, "Certificate to delete not found");
349        }
350
351        Ok(())
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use tempfile::TempDir;
359
360    fn setup_storage() -> (TempDir, CertificateStorage) {
361        let temp_dir = TempDir::new().unwrap();
362        let storage = CertificateStorage::new(temp_dir.path()).unwrap();
363        (temp_dir, storage)
364    }
365
366    #[test]
367    fn test_storage_creation() {
368        let (_temp_dir, storage) = setup_storage();
369        assert!(storage.base_path().exists());
370        assert!(storage.base_path().join("domains").exists());
371    }
372
373    #[test]
374    fn test_credentials_json_save_load() {
375        let (_temp_dir, storage) = setup_storage();
376
377        let test_json = r#"{"test": "credentials"}"#;
378        storage.save_credentials_json(test_json).unwrap();
379
380        let loaded = storage.load_credentials_json().unwrap();
381        assert!(loaded.is_some());
382        assert_eq!(loaded.unwrap(), test_json);
383    }
384
385    #[test]
386    fn test_certificate_save_load() {
387        let (_temp_dir, storage) = setup_storage();
388
389        let cert_pem = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----";
390        let key_pem = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
391        let expires = Utc::now() + chrono::Duration::days(90);
392
393        storage
394            .save_certificate(
395                "example.com",
396                cert_pem,
397                key_pem,
398                expires,
399                &["example.com".to_string()],
400            )
401            .unwrap();
402
403        let loaded = storage.load_certificate("example.com").unwrap();
404        assert!(loaded.is_some());
405        let loaded = loaded.unwrap();
406        assert_eq!(loaded.cert_pem, cert_pem);
407        assert_eq!(loaded.key_pem, key_pem);
408    }
409
410    #[test]
411    fn test_needs_renewal_no_cert() {
412        let (_temp_dir, storage) = setup_storage();
413        assert!(storage.needs_renewal("nonexistent.com", 30).unwrap());
414    }
415
416    #[test]
417    fn test_needs_renewal_expiring_soon() {
418        let (_temp_dir, storage) = setup_storage();
419
420        // Save a certificate expiring in 15 days
421        let expires = Utc::now() + chrono::Duration::days(15);
422        storage
423            .save_certificate(
424                "expiring.com",
425                "cert",
426                "key",
427                expires,
428                &["expiring.com".to_string()],
429            )
430            .unwrap();
431
432        // Should need renewal if we renew 30 days before expiry
433        assert!(storage.needs_renewal("expiring.com", 30).unwrap());
434    }
435
436    #[test]
437    fn test_needs_renewal_still_valid() {
438        let (_temp_dir, storage) = setup_storage();
439
440        // Save a certificate expiring in 60 days
441        let expires = Utc::now() + chrono::Duration::days(60);
442        storage
443            .save_certificate(
444                "valid.com",
445                "cert",
446                "key",
447                expires,
448                &["valid.com".to_string()],
449            )
450            .unwrap();
451
452        // Should NOT need renewal if we renew 30 days before expiry
453        assert!(!storage.needs_renewal("valid.com", 30).unwrap());
454    }
455
456    #[test]
457    fn test_list_domains() {
458        let (_temp_dir, storage) = setup_storage();
459
460        storage
461            .save_certificate(
462                "a.com",
463                "cert",
464                "key",
465                Utc::now() + chrono::Duration::days(90),
466                &["a.com".to_string()],
467            )
468            .unwrap();
469        storage
470            .save_certificate(
471                "b.com",
472                "cert",
473                "key",
474                Utc::now() + chrono::Duration::days(90),
475                &["b.com".to_string()],
476            )
477            .unwrap();
478
479        let domains = storage.list_domains().unwrap();
480        assert_eq!(domains.len(), 2);
481        assert!(domains.contains(&"a.com".to_string()));
482        assert!(domains.contains(&"b.com".to_string()));
483    }
484
485    #[test]
486    fn test_delete_certificate() {
487        let (_temp_dir, storage) = setup_storage();
488
489        storage
490            .save_certificate(
491                "delete.com",
492                "cert",
493                "key",
494                Utc::now() + chrono::Duration::days(90),
495                &["delete.com".to_string()],
496            )
497            .unwrap();
498
499        assert!(storage.load_certificate("delete.com").unwrap().is_some());
500
501        storage.delete_certificate("delete.com").unwrap();
502
503        assert!(storage.load_certificate("delete.com").unwrap().is_none());
504    }
505}