Skip to main content

grapsus_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(
198        &self,
199        domain: &str,
200    ) -> Result<Option<StoredCertificate>, StorageError> {
201        let domain_path = self.domain_path(domain);
202        let cert_path = domain_path.join("cert.pem");
203        let key_path = domain_path.join("key.pem");
204        let meta_path = domain_path.join("meta.json");
205
206        if !cert_path.exists() {
207            trace!(domain = %domain, "No stored certificate found");
208            return Ok(None);
209        }
210
211        let cert_pem = fs::read_to_string(&cert_path)?;
212        let key_pem = fs::read_to_string(&key_path)?;
213        let meta_content = fs::read_to_string(&meta_path)?;
214        let meta: CertificateMeta = serde_json::from_str(&meta_content)?;
215
216        debug!(
217            domain = %domain,
218            expires = %meta.expires,
219            "Loaded stored certificate"
220        );
221
222        Ok(Some(StoredCertificate {
223            cert_pem,
224            key_pem,
225            meta,
226        }))
227    }
228
229    /// Save a certificate for a domain
230    pub fn save_certificate(
231        &self,
232        domain: &str,
233        cert_pem: &str,
234        key_pem: &str,
235        expires: DateTime<Utc>,
236        all_domains: &[String],
237    ) -> Result<(), StorageError> {
238        let domain_path = self.domain_path(domain);
239        fs::create_dir_all(&domain_path)?;
240
241        let cert_path = domain_path.join("cert.pem");
242        let key_path = domain_path.join("key.pem");
243        let meta_path = domain_path.join("meta.json");
244
245        // Write certificate
246        fs::write(&cert_path, cert_pem)?;
247
248        // Write private key with restrictive permissions
249        fs::write(&key_path, key_pem)?;
250        #[cfg(unix)]
251        {
252            use std::os::unix::fs::PermissionsExt;
253            fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
254        }
255
256        // Write metadata
257        let meta = CertificateMeta {
258            expires,
259            issued: Utc::now(),
260            domains: all_domains.to_vec(),
261            issuer: Some("Let's Encrypt".to_string()),
262        };
263        let meta_content = serde_json::to_string_pretty(&meta)?;
264        fs::write(&meta_path, meta_content)?;
265
266        info!(
267            domain = %domain,
268            expires = %expires,
269            "Saved certificate to storage"
270        );
271
272        Ok(())
273    }
274
275    /// Check if a certificate needs renewal
276    ///
277    /// Returns `true` if:
278    /// - No certificate exists for the domain
279    /// - Certificate expires within `renew_before_days` days
280    pub fn needs_renewal(
281        &self,
282        domain: &str,
283        renew_before_days: u32,
284    ) -> Result<bool, StorageError> {
285        let Some(cert) = self.load_certificate(domain)? else {
286            debug!(domain = %domain, "No certificate exists, needs issuance");
287            return Ok(true);
288        };
289
290        let renew_threshold = Utc::now() + chrono::Duration::days(i64::from(renew_before_days));
291        let needs_renewal = cert.meta.expires <= renew_threshold;
292
293        if needs_renewal {
294            debug!(
295                domain = %domain,
296                expires = %cert.meta.expires,
297                threshold = %renew_threshold,
298                "Certificate needs renewal"
299            );
300        } else {
301            trace!(
302                domain = %domain,
303                expires = %cert.meta.expires,
304                "Certificate is still valid"
305            );
306        }
307
308        Ok(needs_renewal)
309    }
310
311    /// Get certificate paths for a domain
312    ///
313    /// Returns the paths to cert.pem and key.pem if they exist.
314    pub fn certificate_paths(&self, domain: &str) -> Option<(PathBuf, PathBuf)> {
315        let domain_path = self.domain_path(domain);
316        let cert_path = domain_path.join("cert.pem");
317        let key_path = domain_path.join("key.pem");
318
319        if cert_path.exists() && key_path.exists() {
320            Some((cert_path, key_path))
321        } else {
322            None
323        }
324    }
325
326    /// List all stored domains
327    pub fn list_domains(&self) -> Result<Vec<String>, StorageError> {
328        let domains_path = self.base_path.join("domains");
329
330        if !domains_path.exists() {
331            return Ok(Vec::new());
332        }
333
334        let mut domains = Vec::new();
335        for entry in fs::read_dir(&domains_path)? {
336            let entry = entry?;
337            if entry.file_type()?.is_dir() {
338                if let Some(name) = entry.file_name().to_str() {
339                    domains.push(name.to_string());
340                }
341            }
342        }
343
344        Ok(domains)
345    }
346
347    /// Delete stored certificate for a domain
348    pub fn delete_certificate(&self, domain: &str) -> Result<(), StorageError> {
349        let domain_path = self.domain_path(domain);
350
351        if domain_path.exists() {
352            fs::remove_dir_all(&domain_path)?;
353            info!(domain = %domain, "Deleted stored certificate");
354        } else {
355            warn!(domain = %domain, "Certificate to delete not found");
356        }
357
358        Ok(())
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use tempfile::TempDir;
366
367    fn setup_storage() -> (TempDir, CertificateStorage) {
368        let temp_dir = TempDir::new().unwrap();
369        let storage = CertificateStorage::new(temp_dir.path()).unwrap();
370        (temp_dir, storage)
371    }
372
373    #[test]
374    fn test_storage_creation() {
375        let (_temp_dir, storage) = setup_storage();
376        assert!(storage.base_path().exists());
377        assert!(storage.base_path().join("domains").exists());
378    }
379
380    #[test]
381    fn test_credentials_json_save_load() {
382        let (_temp_dir, storage) = setup_storage();
383
384        let test_json = r#"{"test": "credentials"}"#;
385        storage.save_credentials_json(test_json).unwrap();
386
387        let loaded = storage.load_credentials_json().unwrap();
388        assert!(loaded.is_some());
389        assert_eq!(loaded.unwrap(), test_json);
390    }
391
392    #[test]
393    fn test_certificate_save_load() {
394        let (_temp_dir, storage) = setup_storage();
395
396        let cert_pem = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----";
397        let key_pem = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
398        let expires = Utc::now() + chrono::Duration::days(90);
399
400        storage
401            .save_certificate(
402                "example.com",
403                cert_pem,
404                key_pem,
405                expires,
406                &["example.com".to_string()],
407            )
408            .unwrap();
409
410        let loaded = storage.load_certificate("example.com").unwrap();
411        assert!(loaded.is_some());
412        let loaded = loaded.unwrap();
413        assert_eq!(loaded.cert_pem, cert_pem);
414        assert_eq!(loaded.key_pem, key_pem);
415    }
416
417    #[test]
418    fn test_needs_renewal_no_cert() {
419        let (_temp_dir, storage) = setup_storage();
420        assert!(storage.needs_renewal("nonexistent.com", 30).unwrap());
421    }
422
423    #[test]
424    fn test_needs_renewal_expiring_soon() {
425        let (_temp_dir, storage) = setup_storage();
426
427        // Save a certificate expiring in 15 days
428        let expires = Utc::now() + chrono::Duration::days(15);
429        storage
430            .save_certificate(
431                "expiring.com",
432                "cert",
433                "key",
434                expires,
435                &["expiring.com".to_string()],
436            )
437            .unwrap();
438
439        // Should need renewal if we renew 30 days before expiry
440        assert!(storage.needs_renewal("expiring.com", 30).unwrap());
441    }
442
443    #[test]
444    fn test_needs_renewal_still_valid() {
445        let (_temp_dir, storage) = setup_storage();
446
447        // Save a certificate expiring in 60 days
448        let expires = Utc::now() + chrono::Duration::days(60);
449        storage
450            .save_certificate(
451                "valid.com",
452                "cert",
453                "key",
454                expires,
455                &["valid.com".to_string()],
456            )
457            .unwrap();
458
459        // Should NOT need renewal if we renew 30 days before expiry
460        assert!(!storage.needs_renewal("valid.com", 30).unwrap());
461    }
462
463    #[test]
464    fn test_list_domains() {
465        let (_temp_dir, storage) = setup_storage();
466
467        storage
468            .save_certificate(
469                "a.com",
470                "cert",
471                "key",
472                Utc::now() + chrono::Duration::days(90),
473                &["a.com".to_string()],
474            )
475            .unwrap();
476        storage
477            .save_certificate(
478                "b.com",
479                "cert",
480                "key",
481                Utc::now() + chrono::Duration::days(90),
482                &["b.com".to_string()],
483            )
484            .unwrap();
485
486        let domains = storage.list_domains().unwrap();
487        assert_eq!(domains.len(), 2);
488        assert!(domains.contains(&"a.com".to_string()));
489        assert!(domains.contains(&"b.com".to_string()));
490    }
491
492    #[test]
493    fn test_delete_certificate() {
494        let (_temp_dir, storage) = setup_storage();
495
496        storage
497            .save_certificate(
498                "delete.com",
499                "cert",
500                "key",
501                Utc::now() + chrono::Duration::days(90),
502                &["delete.com".to_string()],
503            )
504            .unwrap();
505
506        assert!(storage.load_certificate("delete.com").unwrap().is_some());
507
508        storage.delete_certificate("delete.com").unwrap();
509
510        assert!(storage.load_certificate("delete.com").unwrap().is_none());
511    }
512}