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:
2026-02-13 16:32:02 +00:00
parent e0af82c1ef
commit 0e058594c9
17 changed files with 296 additions and 397 deletions

View File

@@ -15,8 +15,6 @@ tracing = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rcgen = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -1,16 +1,15 @@
//! ACME (Let's Encrypt) integration using instant-acme.
//!
//! This module handles HTTP-01 challenge creation and certificate provisioning.
//! Supports persisting ACME account credentials to disk for reuse across restarts.
//! Account credentials are ephemeral — the consumer owns all persistence.
use std::path::{Path, PathBuf};
use instant_acme::{
Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus,
AccountCredentials,
};
use rcgen::{CertificateParams, KeyPair};
use thiserror::Error;
use tracing::{debug, info, warn};
use tracing::{debug, info};
#[derive(Debug, Error)]
pub enum AcmeError {
@@ -26,8 +25,6 @@ pub enum AcmeError {
NoHttp01Challenge,
#[error("Timeout waiting for order: {0}")]
Timeout(String),
#[error("Account persistence error: {0}")]
Persistence(String),
}
/// Pending HTTP-01 challenge that needs to be served.
@@ -41,8 +38,6 @@ pub struct PendingChallenge {
pub struct AcmeClient {
use_production: bool,
email: String,
/// Optional directory where account.json is persisted.
account_dir: Option<PathBuf>,
}
impl AcmeClient {
@@ -50,56 +45,15 @@ impl AcmeClient {
Self {
use_production,
email,
account_dir: None,
}
}
/// Create a new client with account persistence at the given directory.
pub fn with_persistence(email: String, use_production: bool, account_dir: impl AsRef<Path>) -> Self {
Self {
use_production,
email,
account_dir: Some(account_dir.as_ref().to_path_buf()),
}
}
/// Get or create an ACME account, persisting credentials if account_dir is set.
/// Create a new ACME account (ephemeral — not persisted).
async fn get_or_create_account(&self) -> Result<Account, AcmeError> {
let directory_url = self.directory_url();
// Try to restore from persisted credentials
if let Some(ref dir) = self.account_dir {
let account_file = dir.join("account.json");
if account_file.exists() {
match std::fs::read_to_string(&account_file) {
Ok(json) => {
match serde_json::from_str::<AccountCredentials>(&json) {
Ok(credentials) => {
match Account::from_credentials(credentials).await {
Ok(account) => {
debug!("Restored ACME account from {}", account_file.display());
return Ok(account);
}
Err(e) => {
warn!("Failed to restore ACME account, creating new: {}", e);
}
}
}
Err(e) => {
warn!("Invalid account.json, creating new account: {}", e);
}
}
}
Err(e) => {
warn!("Could not read account.json: {}", e);
}
}
}
}
// Create a new account
let contact = format!("mailto:{}", self.email);
let (account, credentials) = Account::create(
let (account, _credentials) = Account::create(
&NewAccount {
contact: &[&contact],
terms_of_service_agreed: true,
@@ -113,27 +67,6 @@ impl AcmeClient {
debug!("ACME account created");
// Persist credentials if we have a directory
if let Some(ref dir) = self.account_dir {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create account directory {}: {}", dir.display(), e);
} else {
let account_file = dir.join("account.json");
match serde_json::to_string_pretty(&credentials) {
Ok(json) => {
if let Err(e) = std::fs::write(&account_file, &json) {
warn!("Failed to persist ACME account to {}: {}", account_file.display(), e);
} else {
info!("ACME account credentials persisted to {}", account_file.display());
}
}
Err(e) => {
warn!("Failed to serialize account credentials: {}", e);
}
}
}
}
Ok(account)
}
@@ -158,7 +91,7 @@ impl AcmeClient {
{
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url());
// 1. Get or create ACME account (with persistence)
// 1. Get or create ACME account
let account = self.get_or_create_account().await?;
// 2. Create order
@@ -339,22 +272,4 @@ mod tests {
assert!(!client.directory_url().contains("staging"));
assert!(client.is_production());
}
#[test]
fn test_with_persistence_sets_account_dir() {
let tmp = tempfile::tempdir().unwrap();
let client = AcmeClient::with_persistence(
"test@example.com".to_string(),
false,
tmp.path(),
);
assert!(client.account_dir.is_some());
assert_eq!(client.account_dir.unwrap(), tmp.path());
}
#[test]
fn test_without_persistence_no_account_dir() {
let client = AcmeClient::new("test@example.com".to_string(), false);
assert!(client.account_dir.is_none());
}
}

View File

@@ -9,8 +9,6 @@ use crate::acme::AcmeClient;
pub enum CertManagerError {
#[error("ACME provisioning failed for {domain}: {message}")]
AcmeFailure { domain: String, message: String },
#[error("Certificate store error: {0}")]
Store(#[from] crate::cert_store::CertStoreError),
#[error("No ACME email configured")]
NoEmail,
}
@@ -46,25 +44,19 @@ impl CertManager {
/// Create an ACME client using this manager's configuration.
/// Returns None if no ACME email is configured.
/// Account credentials are persisted in the cert store base directory.
pub fn acme_client(&self) -> Option<AcmeClient> {
self.acme_email.as_ref().map(|email| {
AcmeClient::with_persistence(
email.clone(),
self.use_production,
self.store.base_dir(),
)
AcmeClient::new(email.clone(), self.use_production)
})
}
/// Load a static certificate into the store.
/// Load a static certificate into the store (infallible — pure cache insert).
pub fn load_static(
&mut self,
domain: String,
bundle: CertBundle,
) -> Result<(), CertManagerError> {
self.store.store(domain, bundle)?;
Ok(())
) {
self.store.store(domain, bundle);
}
/// Check and return domains that need certificate renewal.
@@ -153,19 +145,12 @@ impl CertManager {
},
};
self.store.store(domain.to_string(), bundle.clone())?;
self.store.store(domain.to_string(), bundle.clone());
info!("Certificate renewed and stored for {}", domain);
Ok(bundle)
}
/// Load all certificates from disk.
pub fn load_all(&mut self) -> Result<usize, CertManagerError> {
let loaded = self.store.load_all()?;
info!("Loaded {} certificates from store", loaded);
Ok(loaded)
}
/// Whether this manager has an ACME email configured.
pub fn has_acme(&self) -> bool {
self.acme_email.is_some()

View File

@@ -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");
}
}