feat(rustproxy): introduce a Rust-powered proxy engine and workspace with core crates for proxy functionality, ACME/TLS support, passthrough and HTTP proxies, metrics, nftables integration, routing/security, management IPC, tests, and README updates
This commit is contained in:
360
rust/crates/rustproxy-tls/src/acme.rs
Normal file
360
rust/crates/rustproxy-tls/src/acme.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
//! 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.
|
||||
|
||||
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};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AcmeError {
|
||||
#[error("ACME account creation failed: {0}")]
|
||||
AccountCreation(String),
|
||||
#[error("ACME order failed: {0}")]
|
||||
OrderFailed(String),
|
||||
#[error("Challenge failed: {0}")]
|
||||
ChallengeFailed(String),
|
||||
#[error("Certificate finalization failed: {0}")]
|
||||
FinalizationFailed(String),
|
||||
#[error("No HTTP-01 challenge found")]
|
||||
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.
|
||||
pub struct PendingChallenge {
|
||||
pub token: String,
|
||||
pub key_authorization: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
/// ACME client wrapper around instant-acme.
|
||||
pub struct AcmeClient {
|
||||
use_production: bool,
|
||||
email: String,
|
||||
/// Optional directory where account.json is persisted.
|
||||
account_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AcmeClient {
|
||||
pub fn new(email: String, use_production: bool) -> Self {
|
||||
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.
|
||||
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(
|
||||
&NewAccount {
|
||||
contact: &[&contact],
|
||||
terms_of_service_agreed: true,
|
||||
only_return_existing: false,
|
||||
},
|
||||
directory_url,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Request a certificate for a domain using the HTTP-01 challenge.
|
||||
///
|
||||
/// Returns (cert_chain_pem, private_key_pem) on success.
|
||||
///
|
||||
/// The caller must serve the HTTP-01 challenge at:
|
||||
/// `http://<domain>/.well-known/acme-challenge/<token>`
|
||||
///
|
||||
/// The `challenge_handler` closure is called with a `PendingChallenge`
|
||||
/// and must arrange for the challenge response to be served. It should
|
||||
/// return once the challenge is ready to be validated.
|
||||
pub async fn provision<F, Fut>(
|
||||
&self,
|
||||
domain: &str,
|
||||
challenge_handler: F,
|
||||
) -> Result<(String, String), AcmeError>
|
||||
where
|
||||
F: FnOnce(PendingChallenge) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<(), AcmeError>>,
|
||||
{
|
||||
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url());
|
||||
|
||||
// 1. Get or create ACME account (with persistence)
|
||||
let account = self.get_or_create_account().await?;
|
||||
|
||||
// 2. Create order
|
||||
let identifier = Identifier::Dns(domain.to_string());
|
||||
let mut order = account
|
||||
.new_order(&NewOrder {
|
||||
identifiers: &[identifier],
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
||||
|
||||
debug!("ACME order created");
|
||||
|
||||
// 3. Get authorizations and find HTTP-01 challenge
|
||||
let authorizations = order
|
||||
.authorizations()
|
||||
.await
|
||||
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
||||
|
||||
// Find the HTTP-01 challenge
|
||||
let (challenge_token, challenge_url) = authorizations
|
||||
.iter()
|
||||
.flat_map(|auth| auth.challenges.iter())
|
||||
.find(|c| c.r#type == ChallengeType::Http01)
|
||||
.map(|c| {
|
||||
let key_auth = order.key_authorization(c);
|
||||
(
|
||||
PendingChallenge {
|
||||
token: c.token.clone(),
|
||||
key_authorization: key_auth.as_str().to_string(),
|
||||
domain: domain.to_string(),
|
||||
},
|
||||
c.url.clone(),
|
||||
)
|
||||
})
|
||||
.ok_or(AcmeError::NoHttp01Challenge)?;
|
||||
|
||||
// Call the handler to set up challenge serving
|
||||
challenge_handler(challenge_token).await?;
|
||||
|
||||
// 4. Notify ACME server that challenge is ready
|
||||
order
|
||||
.set_challenge_ready(&challenge_url)
|
||||
.await
|
||||
.map_err(|e| AcmeError::ChallengeFailed(e.to_string()))?;
|
||||
|
||||
debug!("Challenge marked as ready, waiting for validation...");
|
||||
|
||||
// 5. Poll for order to become ready
|
||||
let mut attempts = 0;
|
||||
let state = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let state = order
|
||||
.refresh()
|
||||
.await
|
||||
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
||||
|
||||
match state.status {
|
||||
OrderStatus::Ready | OrderStatus::Valid => break state.status,
|
||||
OrderStatus::Invalid => {
|
||||
return Err(AcmeError::ChallengeFailed(
|
||||
"Order became invalid (challenge failed)".to_string(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
attempts += 1;
|
||||
if attempts > 30 {
|
||||
return Err(AcmeError::Timeout(
|
||||
"Order did not become ready within 60 seconds".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Order ready, finalizing...");
|
||||
|
||||
// 6. Generate CSR and finalize
|
||||
let key_pair = KeyPair::generate().map_err(|e| {
|
||||
AcmeError::FinalizationFailed(format!("Key generation failed: {}", e))
|
||||
})?;
|
||||
|
||||
let mut params = CertificateParams::new(vec![domain.to_string()]).map_err(|e| {
|
||||
AcmeError::FinalizationFailed(format!("CSR params failed: {}", e))
|
||||
})?;
|
||||
params.distinguished_name.push(rcgen::DnType::CommonName, domain);
|
||||
|
||||
let csr = params.serialize_request(&key_pair).map_err(|e| {
|
||||
AcmeError::FinalizationFailed(format!("CSR serialization failed: {}", e))
|
||||
})?;
|
||||
|
||||
if state == OrderStatus::Ready {
|
||||
order
|
||||
.finalize(csr.der())
|
||||
.await
|
||||
.map_err(|e| AcmeError::FinalizationFailed(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 7. Wait for certificate to be issued
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let state = order
|
||||
.refresh()
|
||||
.await
|
||||
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
||||
if state.status == OrderStatus::Valid {
|
||||
break;
|
||||
}
|
||||
if state.status == OrderStatus::Invalid {
|
||||
return Err(AcmeError::FinalizationFailed(
|
||||
"Order became invalid during finalization".to_string(),
|
||||
));
|
||||
}
|
||||
attempts += 1;
|
||||
if attempts > 15 {
|
||||
return Err(AcmeError::Timeout(
|
||||
"Certificate not issued within 30 seconds".to_string(),
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// 8. Download certificate
|
||||
let cert_chain_pem = order
|
||||
.certificate()
|
||||
.await
|
||||
.map_err(|e| AcmeError::FinalizationFailed(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
AcmeError::FinalizationFailed("No certificate returned".to_string())
|
||||
})?;
|
||||
|
||||
let private_key_pem = key_pair.serialize_pem();
|
||||
|
||||
info!("Certificate provisioned successfully for {}", domain);
|
||||
|
||||
Ok((cert_chain_pem, private_key_pem))
|
||||
}
|
||||
|
||||
/// Restore an ACME account from stored credentials.
|
||||
pub async fn restore_account(
|
||||
&self,
|
||||
credentials: AccountCredentials,
|
||||
) -> Result<Account, AcmeError> {
|
||||
Account::from_credentials(credentials)
|
||||
.await
|
||||
.map_err(|e| AcmeError::AccountCreation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get the ACME directory URL based on production/staging.
|
||||
pub fn directory_url(&self) -> &str {
|
||||
if self.use_production {
|
||||
"https://acme-v02.api.letsencrypt.org/directory"
|
||||
} else {
|
||||
"https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this client is configured for production.
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.use_production
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_directory_url_staging() {
|
||||
let client = AcmeClient::new("test@example.com".to_string(), false);
|
||||
assert!(client.directory_url().contains("staging"));
|
||||
assert!(!client.is_production());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directory_url_production() {
|
||||
let client = AcmeClient::new("test@example.com".to_string(), true);
|
||||
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());
|
||||
}
|
||||
}
|
||||
183
rust/crates/rustproxy-tls/src/cert_manager.rs
Normal file
183
rust/crates/rustproxy-tls/src/cert_manager.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cert_store::{CertStore, CertBundle, CertMetadata, CertSource};
|
||||
use crate::acme::AcmeClient;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
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,
|
||||
}
|
||||
|
||||
/// Certificate lifecycle manager.
|
||||
/// Handles ACME provisioning, static cert loading, and renewal.
|
||||
pub struct CertManager {
|
||||
store: CertStore,
|
||||
acme_email: Option<String>,
|
||||
use_production: bool,
|
||||
renew_before_days: u32,
|
||||
}
|
||||
|
||||
impl CertManager {
|
||||
pub fn new(
|
||||
store: CertStore,
|
||||
acme_email: Option<String>,
|
||||
use_production: bool,
|
||||
renew_before_days: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
acme_email,
|
||||
use_production,
|
||||
renew_before_days,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a certificate for a domain (from cache).
|
||||
pub fn get_cert(&self, domain: &str) -> Option<&CertBundle> {
|
||||
self.store.get(domain)
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a static certificate into the store.
|
||||
pub fn load_static(
|
||||
&mut self,
|
||||
domain: String,
|
||||
bundle: CertBundle,
|
||||
) -> Result<(), CertManagerError> {
|
||||
self.store.store(domain, bundle)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check and return domains that need certificate renewal.
|
||||
///
|
||||
/// A certificate needs renewal if it expires within `renew_before_days`.
|
||||
/// Returns a list of domain names needing renewal.
|
||||
pub fn check_renewals(&self) -> Vec<String> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let renewal_threshold = self.renew_before_days as u64 * 86400;
|
||||
let mut needs_renewal = Vec::new();
|
||||
|
||||
for (domain, bundle) in self.store.iter() {
|
||||
// Only auto-renew ACME certs
|
||||
if bundle.metadata.source != CertSource::Acme {
|
||||
continue;
|
||||
}
|
||||
|
||||
let time_until_expiry = bundle.metadata.expires_at.saturating_sub(now);
|
||||
if time_until_expiry < renewal_threshold {
|
||||
info!(
|
||||
"Certificate for {} needs renewal (expires in {} days)",
|
||||
domain,
|
||||
time_until_expiry / 86400
|
||||
);
|
||||
needs_renewal.push(domain.clone());
|
||||
}
|
||||
}
|
||||
|
||||
needs_renewal
|
||||
}
|
||||
|
||||
/// Renew a certificate for a domain.
|
||||
///
|
||||
/// Performs the full ACME provision+store flow. The `challenge_setup` closure
|
||||
/// is called to arrange for the HTTP-01 challenge to be served. It receives
|
||||
/// (token, key_authorization) and must make the challenge response available.
|
||||
///
|
||||
/// Returns the new CertBundle on success.
|
||||
pub async fn renew_domain<F, Fut>(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
challenge_setup: F,
|
||||
) -> Result<CertBundle, CertManagerError>
|
||||
where
|
||||
F: FnOnce(String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
let acme_client = self.acme_client()
|
||||
.ok_or(CertManagerError::NoEmail)?;
|
||||
|
||||
info!("Renewing certificate for {}", domain);
|
||||
|
||||
let domain_owned = domain.to_string();
|
||||
let result = acme_client.provision(&domain_owned, |pending| {
|
||||
let token = pending.token.clone();
|
||||
let key_auth = pending.key_authorization.clone();
|
||||
async move {
|
||||
challenge_setup(token, key_auth).await;
|
||||
Ok(())
|
||||
}
|
||||
}).await.map_err(|e| CertManagerError::AcmeFailure {
|
||||
domain: domain.to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let (cert_pem, key_pem) = result;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let bundle = CertBundle {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
ca_pem: None,
|
||||
metadata: CertMetadata {
|
||||
domain: domain.to_string(),
|
||||
source: CertSource::Acme,
|
||||
issued_at: now,
|
||||
expires_at: now + 90 * 86400,
|
||||
renewed_at: Some(now),
|
||||
},
|
||||
};
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Get reference to the underlying store.
|
||||
pub fn store(&self) -> &CertStore {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Get mutable reference to the underlying store.
|
||||
pub fn store_mut(&mut self) -> &mut CertStore {
|
||||
&mut self.store
|
||||
}
|
||||
}
|
||||
314
rust/crates/rustproxy-tls/src/cert_store.rs
Normal file
314
rust/crates/rustproxy-tls/src/cert_store.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CertMetadata {
|
||||
pub domain: String,
|
||||
pub source: CertSource,
|
||||
pub issued_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub renewed_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// How a certificate was obtained.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CertSource {
|
||||
Acme,
|
||||
Static,
|
||||
Custom,
|
||||
SelfSigned,
|
||||
}
|
||||
|
||||
/// An in-memory certificate bundle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertBundle {
|
||||
pub key_pem: String,
|
||||
pub cert_pem: String,
|
||||
pub ca_pem: Option<String>,
|
||||
pub metadata: CertMetadata,
|
||||
}
|
||||
|
||||
/// Filesystem-backed certificate store.
|
||||
///
|
||||
/// File layout per domain:
|
||||
/// ```text
|
||||
/// {base_dir}/{domain}/
|
||||
/// key.pem
|
||||
/// cert.pem
|
||||
/// ca.pem (optional)
|
||||
/// metadata.json
|
||||
/// ```
|
||||
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 {
|
||||
Self {
|
||||
base_dir: base_dir.as_ref().to_path_buf(),
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a certificate by domain.
|
||||
pub fn get(&self, domain: &str) -> Option<&CertBundle> {
|
||||
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
|
||||
self.cache.insert(domain, bundle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a certificate exists for a domain.
|
||||
pub fn has(&self, domain: &str) -> bool {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Iterate over all cached certificates.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &CertBundle)> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_bundle(domain: &str) -> CertBundle {
|
||||
CertBundle {
|
||||
key_pem: "-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----\n".to_string(),
|
||||
cert_pem: "-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----\n".to_string(),
|
||||
ca_pem: None,
|
||||
metadata: CertMetadata {
|
||||
domain: domain.to_string(),
|
||||
source: CertSource::Static,
|
||||
issued_at: 1700000000,
|
||||
expires_at: 1700000000 + 90 * 86400,
|
||||
renewed_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
|
||||
let bundle = make_test_bundle("example.com");
|
||||
store.store("example.com".to_string(), bundle.clone()).unwrap();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_with_ca_cert() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
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());
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_cert() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut store = CertStore::new(tmp.path());
|
||||
|
||||
store.store("remove-me.com".to_string(), make_test_bundle("remove-me.com")).unwrap();
|
||||
assert!(store.has("remove-me.com"));
|
||||
|
||||
let removed = store.remove("remove-me.com").unwrap();
|
||||
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());
|
||||
|
||||
store.store("*.example.com".to_string(), make_test_bundle("*.example.com")).unwrap();
|
||||
|
||||
// Directory should use sanitized name
|
||||
assert!(tmp.path().join("_wildcard_.example.com").exists());
|
||||
|
||||
let mut store2 = CertStore::new(tmp.path());
|
||||
store2.load_all().unwrap();
|
||||
assert!(store2.has("*.example.com"));
|
||||
}
|
||||
}
|
||||
13
rust/crates/rustproxy-tls/src/lib.rs
Normal file
13
rust/crates/rustproxy-tls/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! # rustproxy-tls
|
||||
//!
|
||||
//! TLS certificate management for RustProxy.
|
||||
//! Handles ACME (Let's Encrypt), static certificates, and dynamic SNI resolution.
|
||||
|
||||
pub mod cert_store;
|
||||
pub mod cert_manager;
|
||||
pub mod acme;
|
||||
pub mod sni_resolver;
|
||||
|
||||
pub use cert_store::*;
|
||||
pub use cert_manager::*;
|
||||
pub use sni_resolver::*;
|
||||
139
rust/crates/rustproxy-tls/src/sni_resolver.rs
Normal file
139
rust/crates/rustproxy-tls/src/sni_resolver.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::cert_store::CertBundle;
|
||||
|
||||
/// Dynamic SNI-based certificate resolver.
|
||||
/// Used by the TLS stack to select the right certificate based on client SNI.
|
||||
pub struct SniResolver {
|
||||
/// Domain -> certificate bundle mapping
|
||||
certs: RwLock<HashMap<String, Arc<CertBundle>>>,
|
||||
/// Fallback certificate (used when no SNI or no match)
|
||||
fallback: RwLock<Option<Arc<CertBundle>>>,
|
||||
}
|
||||
|
||||
impl SniResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
certs: RwLock::new(HashMap::new()),
|
||||
fallback: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a certificate for a domain.
|
||||
pub fn add_cert(&self, domain: String, bundle: CertBundle) {
|
||||
let mut certs = self.certs.write().unwrap();
|
||||
certs.insert(domain, Arc::new(bundle));
|
||||
}
|
||||
|
||||
/// Set the fallback certificate.
|
||||
pub fn set_fallback(&self, bundle: CertBundle) {
|
||||
let mut fallback = self.fallback.write().unwrap();
|
||||
*fallback = Some(Arc::new(bundle));
|
||||
}
|
||||
|
||||
/// Resolve a certificate for the given SNI domain.
|
||||
pub fn resolve(&self, domain: &str) -> Option<Arc<CertBundle>> {
|
||||
let certs = self.certs.read().unwrap();
|
||||
|
||||
// Try exact match
|
||||
if let Some(bundle) = certs.get(domain) {
|
||||
return Some(Arc::clone(bundle));
|
||||
}
|
||||
|
||||
// Try wildcard match (e.g., *.example.com)
|
||||
if let Some(dot_pos) = domain.find('.') {
|
||||
let wildcard = format!("*.{}", &domain[dot_pos + 1..]);
|
||||
if let Some(bundle) = certs.get(&wildcard) {
|
||||
return Some(Arc::clone(bundle));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
let fallback = self.fallback.read().unwrap();
|
||||
fallback.clone()
|
||||
}
|
||||
|
||||
/// Remove a certificate for a domain.
|
||||
pub fn remove_cert(&self, domain: &str) {
|
||||
let mut certs = self.certs.write().unwrap();
|
||||
certs.remove(domain);
|
||||
}
|
||||
|
||||
/// Get the number of registered certificates.
|
||||
pub fn cert_count(&self) -> usize {
|
||||
self.certs.read().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SniResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cert_store::{CertBundle, CertMetadata, CertSource};
|
||||
|
||||
fn make_bundle(domain: &str) -> CertBundle {
|
||||
CertBundle {
|
||||
key_pem: format!("KEY-{}", domain),
|
||||
cert_pem: format!("CERT-{}", domain),
|
||||
ca_pem: None,
|
||||
metadata: CertMetadata {
|
||||
domain: domain.to_string(),
|
||||
source: CertSource::Static,
|
||||
issued_at: 0,
|
||||
expires_at: 0,
|
||||
renewed_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_domain_resolve() {
|
||||
let resolver = SniResolver::new();
|
||||
resolver.add_cert("example.com".to_string(), make_bundle("example.com"));
|
||||
let result = resolver.resolve("example.com");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().cert_pem, "CERT-example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_resolve() {
|
||||
let resolver = SniResolver::new();
|
||||
resolver.add_cert("*.example.com".to_string(), make_bundle("*.example.com"));
|
||||
let result = resolver.resolve("sub.example.com");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().cert_pem, "CERT-*.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback() {
|
||||
let resolver = SniResolver::new();
|
||||
resolver.set_fallback(make_bundle("fallback"));
|
||||
let result = resolver.resolve("unknown.com");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().cert_pem, "CERT-fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match_no_fallback() {
|
||||
let resolver = SniResolver::new();
|
||||
resolver.add_cert("example.com".to_string(), make_bundle("example.com"));
|
||||
let result = resolver.resolve("other.com");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_cert() {
|
||||
let resolver = SniResolver::new();
|
||||
resolver.add_cert("example.com".to_string(), make_bundle("example.com"));
|
||||
assert_eq!(resolver.cert_count(), 1);
|
||||
resolver.remove_cert("example.com");
|
||||
assert_eq!(resolver.cert_count(), 0);
|
||||
assert!(resolver.resolve("example.com").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user