BREAKING CHANGE(smart-proxy): move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling
This commit is contained in:
@@ -1,21 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CertStoreError {
|
||||
#[error("Certificate not found for domain: {0}")]
|
||||
NotFound(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Invalid certificate: {0}")]
|
||||
Invalid(String),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Certificate metadata stored alongside certs on disk.
|
||||
/// Certificate metadata stored alongside certs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CertMetadata {
|
||||
@@ -45,27 +31,18 @@ pub struct CertBundle {
|
||||
pub metadata: CertMetadata,
|
||||
}
|
||||
|
||||
/// Filesystem-backed certificate store.
|
||||
/// In-memory certificate store.
|
||||
///
|
||||
/// File layout per domain:
|
||||
/// ```text
|
||||
/// {base_dir}/{domain}/
|
||||
/// key.pem
|
||||
/// cert.pem
|
||||
/// ca.pem (optional)
|
||||
/// metadata.json
|
||||
/// ```
|
||||
/// All persistence is owned by the consumer (TypeScript side).
|
||||
/// This struct is a thin HashMap wrapper used as a runtime cache.
|
||||
pub struct CertStore {
|
||||
base_dir: PathBuf,
|
||||
/// In-memory cache of loaded certs
|
||||
cache: HashMap<String, CertBundle>,
|
||||
}
|
||||
|
||||
impl CertStore {
|
||||
/// Create a new cert store at the given directory.
|
||||
pub fn new(base_dir: impl AsRef<Path>) -> Self {
|
||||
/// Create a new empty cert store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dir: base_dir.as_ref().to_path_buf(),
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -75,33 +52,9 @@ impl CertStore {
|
||||
self.cache.get(domain)
|
||||
}
|
||||
|
||||
/// Store a certificate to both cache and filesystem.
|
||||
pub fn store(&mut self, domain: String, bundle: CertBundle) -> Result<(), CertStoreError> {
|
||||
// Sanitize domain for directory name (replace wildcards)
|
||||
let dir_name = domain.replace('*', "_wildcard_");
|
||||
let cert_dir = self.base_dir.join(&dir_name);
|
||||
|
||||
// Create directory
|
||||
std::fs::create_dir_all(&cert_dir)?;
|
||||
|
||||
// Write key
|
||||
std::fs::write(cert_dir.join("key.pem"), &bundle.key_pem)?;
|
||||
|
||||
// Write cert
|
||||
std::fs::write(cert_dir.join("cert.pem"), &bundle.cert_pem)?;
|
||||
|
||||
// Write CA cert if present
|
||||
if let Some(ref ca) = bundle.ca_pem {
|
||||
std::fs::write(cert_dir.join("ca.pem"), ca)?;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
let metadata_json = serde_json::to_string_pretty(&bundle.metadata)?;
|
||||
std::fs::write(cert_dir.join("metadata.json"), metadata_json)?;
|
||||
|
||||
// Update cache
|
||||
/// Store a certificate in the cache.
|
||||
pub fn store(&mut self, domain: String, bundle: CertBundle) {
|
||||
self.cache.insert(domain, bundle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a certificate exists for a domain.
|
||||
@@ -109,68 +62,6 @@ impl CertStore {
|
||||
self.cache.contains_key(domain)
|
||||
}
|
||||
|
||||
/// Load all certificates from the base directory.
|
||||
pub fn load_all(&mut self) -> Result<usize, CertStoreError> {
|
||||
if !self.base_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&self.base_dir)?;
|
||||
let mut loaded = 0;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata_path = path.join("metadata.json");
|
||||
let key_path = path.join("key.pem");
|
||||
let cert_path = path.join("cert.pem");
|
||||
|
||||
// All three files must exist
|
||||
if !metadata_path.exists() || !key_path.exists() || !cert_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load metadata
|
||||
let metadata_str = std::fs::read_to_string(&metadata_path)?;
|
||||
let metadata: CertMetadata = serde_json::from_str(&metadata_str)?;
|
||||
|
||||
// Load key and cert
|
||||
let key_pem = std::fs::read_to_string(&key_path)?;
|
||||
let cert_pem = std::fs::read_to_string(&cert_path)?;
|
||||
|
||||
// Load CA cert if present
|
||||
let ca_path = path.join("ca.pem");
|
||||
let ca_pem = if ca_path.exists() {
|
||||
Some(std::fs::read_to_string(&ca_path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let domain = metadata.domain.clone();
|
||||
let bundle = CertBundle {
|
||||
key_pem,
|
||||
cert_pem,
|
||||
ca_pem,
|
||||
metadata,
|
||||
};
|
||||
|
||||
self.cache.insert(domain, bundle);
|
||||
loaded += 1;
|
||||
}
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
/// Get the base directory.
|
||||
pub fn base_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
|
||||
/// Get the number of cached certificates.
|
||||
pub fn count(&self) -> usize {
|
||||
self.cache.len()
|
||||
@@ -181,17 +72,15 @@ impl CertStore {
|
||||
self.cache.iter()
|
||||
}
|
||||
|
||||
/// Remove a certificate from cache and filesystem.
|
||||
pub fn remove(&mut self, domain: &str) -> Result<bool, CertStoreError> {
|
||||
let removed = self.cache.remove(domain).is_some();
|
||||
if removed {
|
||||
let dir_name = domain.replace('*', "_wildcard_");
|
||||
let cert_dir = self.base_dir.join(&dir_name);
|
||||
if cert_dir.exists() {
|
||||
std::fs::remove_dir_all(&cert_dir)?;
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
/// Remove a certificate from the cache.
|
||||
pub fn remove(&mut self, domain: &str) -> bool {
|
||||
self.cache.remove(domain).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CertStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,100 +104,71 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
fn test_store_and_get() {
|
||||
let mut store = CertStore::new();
|
||||
|
||||
let bundle = make_test_bundle("example.com");
|
||||
store.store("example.com".to_string(), bundle.clone()).unwrap();
|
||||
store.store("example.com".to_string(), bundle.clone());
|
||||
|
||||
// Verify files exist
|
||||
let cert_dir = tmp.path().join("example.com");
|
||||
assert!(cert_dir.join("key.pem").exists());
|
||||
assert!(cert_dir.join("cert.pem").exists());
|
||||
assert!(cert_dir.join("metadata.json").exists());
|
||||
assert!(!cert_dir.join("ca.pem").exists()); // No CA cert
|
||||
|
||||
// Load into a fresh store
|
||||
let mut store2 = CertStore::new(tmp.path());
|
||||
let loaded = store2.load_all().unwrap();
|
||||
assert_eq!(loaded, 1);
|
||||
|
||||
let loaded_bundle = store2.get("example.com").unwrap();
|
||||
assert_eq!(loaded_bundle.key_pem, bundle.key_pem);
|
||||
assert_eq!(loaded_bundle.cert_pem, bundle.cert_pem);
|
||||
assert_eq!(loaded_bundle.metadata.domain, "example.com");
|
||||
assert_eq!(loaded_bundle.metadata.source, CertSource::Static);
|
||||
let loaded = store.get("example.com").unwrap();
|
||||
assert_eq!(loaded.key_pem, bundle.key_pem);
|
||||
assert_eq!(loaded.cert_pem, bundle.cert_pem);
|
||||
assert_eq!(loaded.metadata.domain, "example.com");
|
||||
assert_eq!(loaded.metadata.source, CertSource::Static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_with_ca_cert() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
let mut store = CertStore::new();
|
||||
|
||||
let mut bundle = make_test_bundle("secure.com");
|
||||
bundle.ca_pem = Some("-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----\n".to_string());
|
||||
store.store("secure.com".to_string(), bundle).unwrap();
|
||||
store.store("secure.com".to_string(), bundle);
|
||||
|
||||
let cert_dir = tmp.path().join("secure.com");
|
||||
assert!(cert_dir.join("ca.pem").exists());
|
||||
|
||||
let mut store2 = CertStore::new(tmp.path());
|
||||
store2.load_all().unwrap();
|
||||
let loaded = store2.get("secure.com").unwrap();
|
||||
let loaded = store.get("secure.com").unwrap();
|
||||
assert!(loaded.ca_pem.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_multiple_certs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
fn test_multiple_certs() {
|
||||
let mut store = CertStore::new();
|
||||
|
||||
store.store("a.com".to_string(), make_test_bundle("a.com")).unwrap();
|
||||
store.store("b.com".to_string(), make_test_bundle("b.com")).unwrap();
|
||||
store.store("c.com".to_string(), make_test_bundle("c.com")).unwrap();
|
||||
store.store("a.com".to_string(), make_test_bundle("a.com"));
|
||||
store.store("b.com".to_string(), make_test_bundle("b.com"));
|
||||
store.store("c.com".to_string(), make_test_bundle("c.com"));
|
||||
|
||||
let mut store2 = CertStore::new(tmp.path());
|
||||
let loaded = store2.load_all().unwrap();
|
||||
assert_eq!(loaded, 3);
|
||||
assert!(store2.has("a.com"));
|
||||
assert!(store2.has("b.com"));
|
||||
assert!(store2.has("c.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_missing_directory() {
|
||||
let mut store = CertStore::new("/nonexistent/path/to/certs");
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded, 0);
|
||||
assert_eq!(store.count(), 3);
|
||||
assert!(store.has("a.com"));
|
||||
assert!(store.has("b.com"));
|
||||
assert!(store.has("c.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_cert() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
let mut store = CertStore::new();
|
||||
|
||||
store.store("remove-me.com".to_string(), make_test_bundle("remove-me.com")).unwrap();
|
||||
store.store("remove-me.com".to_string(), make_test_bundle("remove-me.com"));
|
||||
assert!(store.has("remove-me.com"));
|
||||
|
||||
let removed = store.remove("remove-me.com").unwrap();
|
||||
let removed = store.remove("remove-me.com");
|
||||
assert!(removed);
|
||||
assert!(!store.has("remove-me.com"));
|
||||
assert!(!tmp.path().join("remove-me.com").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_domain_storage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
fn test_remove_nonexistent() {
|
||||
let mut store = CertStore::new();
|
||||
assert!(!store.remove("nonexistent.com"));
|
||||
}
|
||||
|
||||
store.store("*.example.com".to_string(), make_test_bundle("*.example.com")).unwrap();
|
||||
#[test]
|
||||
fn test_wildcard_domain() {
|
||||
let mut store = CertStore::new();
|
||||
|
||||
// Directory should use sanitized name
|
||||
assert!(tmp.path().join("_wildcard_.example.com").exists());
|
||||
store.store("*.example.com".to_string(), make_test_bundle("*.example.com"));
|
||||
assert!(store.has("*.example.com"));
|
||||
|
||||
let mut store2 = CertStore::new(tmp.path());
|
||||
store2.load_all().unwrap();
|
||||
assert!(store2.has("*.example.com"));
|
||||
let loaded = store.get("*.example.com").unwrap();
|
||||
assert_eq!(loaded.metadata.domain, "*.example.com");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user