From 0e058594c9489262dbdfdafa0d4d83ff5b6f6e75 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 13 Feb 2026 16:32:02 +0000 Subject: [PATCH] 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 --- changelog.md | 13 + readme.md | 96 ++++++- rust/Cargo.lock | 40 --- .../rustproxy-config/src/proxy_options.rs | 4 - .../rustproxy-passthrough/src/tcp_listener.rs | 18 +- rust/crates/rustproxy-tls/Cargo.toml | 2 - rust/crates/rustproxy-tls/src/acme.rs | 95 +------ rust/crates/rustproxy-tls/src/cert_manager.rs | 25 +- rust/crates/rustproxy-tls/src/cert_store.rs | 242 ++++-------------- rust/crates/rustproxy/src/lib.rs | 25 +- ts/00_commitinfo_data.ts | 2 +- ts/core/models/common-types.ts | 1 - ts/proxies/smart-proxy/models/index.ts | 2 +- ts/proxies/smart-proxy/models/interfaces.ts | 28 +- ts/proxies/smart-proxy/smart-proxy.ts | 61 ++++- .../utils/default-cert-generator.ts | 36 +++ ts/proxies/smart-proxy/utils/index.ts | 3 + 17 files changed, 296 insertions(+), 397 deletions(-) create mode 100644 ts/proxies/smart-proxy/utils/default-cert-generator.ts diff --git a/changelog.md b/changelog.md index 2f8df47..b04d5cb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2026-02-13 - 24.0.0 - 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 + +- Cert persistence removed from Rust side: CertStore is now an in-memory cache (no filesystem reads/writes). Rust no longer persists or loads certs from disk. +- ACME account credentials are no longer persisted by the library; AcmeClient uses ephemeral accounts only and account persistence APIs were removed. +- TypeScript API changes: removed certificateStore option and added ISmartProxyCertStore + certStore option for consumer-provided persistence (loadAll, save, optional remove). +- Default self-signed fallback certificate added (generateDefaultCertificate) and loaded as '*' unless disableDefaultCert is set. +- SmartProxy now pre-loads certificates from consumer certStore on startup and persists certificates by calling certStore.save() after provisioning. +- provisionCertificatesViaCallback signature changed to accept preloaded domains (prevents re-provisioning), and ACME fallback behavior adjusted with clearer logging. +- Rust cert manager methods made infallible for cache-only operations (load_static/store no longer return errors for cache insertions); removed store-backed load_all/remove/base_dir APIs. +- TCP listener tls_configs concurrency improved: switched to ArcSwap> so accept loops see hot-reloads immediately. +- Removed dependencies related to filesystem cert persistence from the tls crate (serde_json, tempfile) and corresponding Cargo.lock changes and test updates. + ## 2026-02-13 - 23.1.6 - fix(smart-proxy) disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow diff --git a/readme.md b/readme.md index b4a870c..d16190e 100644 --- a/readme.md +++ b/readme.md @@ -36,6 +36,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a | 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data | | 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts | | 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information | +| 💾 **Consumer Cert Storage** | Bring your own persistence — SmartProxy never writes certs to disk | ## 🚀 Quick Start @@ -456,6 +457,51 @@ const proxy = new SmartProxy({ }); ``` +### 💾 Consumer-Managed Certificate Storage + +SmartProxy **never writes certificates to disk**. Instead, you own all persistence through the `certStore` interface. This gives you full control — store certs in a database, cloud KMS, encrypted vault, or wherever makes sense for your infrastructure: + +```typescript +const proxy = new SmartProxy({ + routes: [...], + + certProvisionFunction: async (domain) => myAcme.provision(domain), + + // Your persistence layer — SmartProxy calls these hooks + certStore: { + // Called once on startup to pre-load persisted certs + loadAll: async () => { + const certs = await myDb.getAllCerts(); + return certs.map(c => ({ + domain: c.domain, + publicKey: c.certPem, + privateKey: c.keyPem, + ca: c.caPem, // optional + })); + }, + + // Called after each successful cert provision + save: async (domain, publicKey, privateKey, ca) => { + await myDb.upsertCert({ domain, certPem: publicKey, keyPem: privateKey, caPem: ca }); + }, + + // Optional: called when a cert should be removed + remove: async (domain) => { + await myDb.deleteCert(domain); + }, + }, +}); +``` + +**Startup flow:** +1. Rust engine starts +2. Default self-signed `*` fallback cert is loaded (unless `disableDefaultCert: true`) +3. `certStore.loadAll()` is called → all returned certs are loaded into the Rust TLS stack +4. `certProvisionFunction` runs for any remaining `certificate: 'auto'` routes (skipping domains already loaded from the store) +5. After each successful provision, `certStore.save()` is called + +This means your second startup is instant — no re-provisioning needed for domains that already have valid certs in your store. + ## 🏛️ Architecture SmartProxy uses a hybrid **Rust + TypeScript** architecture: @@ -488,7 +534,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture: - **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics - **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks -- **IPC** — The TypeScript wrapper uses [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) for type-safe JSON commands/events over stdin/stdout +- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary - **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions) ## 🎯 Route Configuration Reference @@ -497,7 +543,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture: ```typescript interface IRouteMatch { - ports: number | number[] | Array<{ from: number; to: number }>; // Port(s) to listen on + ports: number | number[] | Array<{ from: number; to: number }>; // Required — port(s) to listen on domains?: string | string[]; // 'example.com', '*.example.com' path?: string; // '/api/*', '/users/:id' clientIp?: string[]; // ['10.0.0.0/8', '192.168.*'] @@ -517,11 +563,16 @@ interface IRouteMatch { ```typescript interface IRouteTarget { - host: string | string[] | ((context: IRouteContext) => string); + host: string | string[] | ((context: IRouteContext) => string | string[]); port: number | 'preserve' | ((context: IRouteContext) => number); - tls?: { ... }; // Per-target TLS override - priority?: number; // Target priority - match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method) + tls?: IRouteTls; // Per-target TLS override + priority?: number; // Target priority + match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method) + websocket?: IRouteWebSocket; + loadBalancing?: IRouteLoadBalancing; + sendProxyProtocol?: boolean; + headers?: IRouteHeaders; + advanced?: IRouteAdvanced; } ``` @@ -613,6 +664,7 @@ import { createPortMappingRoute, // Port mapping with context createOffsetPortMappingRoute, // Simple port offset createDynamicRoute, // Dynamic host/port via functions + createPortOffset, // Port offset factory // Security Modifiers addRateLimiting, // Add rate limiting to any route @@ -680,7 +732,6 @@ interface ISmartProxyOptions { port?: number; // HTTP-01 challenge port (default: 80) renewThresholdDays?: number; // Days before expiry to renew (default: 30) autoRenew?: boolean; // Enable auto-renewal (default: true) - certificateStore?: string; // Directory to store certs (default: './certs') renewCheckIntervalHours?: number; // Renewal check interval (default: 24) }; @@ -688,6 +739,12 @@ interface ISmartProxyOptions { certProvisionFunction?: (domain: string) => Promise; certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true) + // Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage") + certStore?: ISmartProxyCertStore; + + // Self-signed fallback + disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false) + // Global defaults defaults?: { target?: { host: string; port: number }; @@ -729,6 +786,26 @@ interface ISmartProxyOptions { } ``` +### ISmartProxyCertStore Interface + +```typescript +interface ISmartProxyCertStore { + /** Called once on startup to pre-load persisted certs */ + loadAll: () => Promise>; + + /** Called after each successful cert provision */ + save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise; + + /** Optional: remove a cert from storage */ + remove?: (domain: string) => Promise; +} +``` + ### IMetrics Interface The `getMetrics()` method returns a cached metrics adapter that polls the Rust engine: @@ -758,6 +835,10 @@ metrics.requests.total(); // Total requests metrics.totals.bytesIn(); // Total bytes received metrics.totals.bytesOut(); // Total bytes sent metrics.totals.connections(); // Total connections + +// Percentiles +metrics.percentiles.connectionDuration(); // { p50, p95, p99 } +metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } } ``` ## 🐛 Troubleshooting @@ -802,6 +883,7 @@ SmartProxy searches for the Rust binary in this order: 7. **✅ Validate Routes** — Use `RouteValidator.validateRoutes()` to catch config errors before deployment 8. **🔀 Atomic Updates** — Use `updateRoutes()` for hot-reloading routes (mutex-locked, no downtime) 9. **🎮 Use Socket Handlers** — For protocols beyond HTTP, implement custom socket handlers instead of fighting the proxy model +10. **💾 Use `certStore`** — Persist certs in your own storage to avoid re-provisioning on every restart ## License and Legal Information diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6479928..3914aa8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -285,12 +285,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -618,12 +612,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "lock_api" version = "0.4.14" @@ -866,19 +854,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.36" @@ -1084,8 +1059,6 @@ dependencies = [ "rustls", "rustproxy-config", "serde", - "serde_json", - "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -1260,19 +1233,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/rust/crates/rustproxy-config/src/proxy_options.rs b/rust/crates/rustproxy-config/src/proxy_options.rs index 0847a7f..bbb0dc3 100644 --- a/rust/crates/rustproxy-config/src/proxy_options.rs +++ b/rust/crates/rustproxy-config/src/proxy_options.rs @@ -29,9 +29,6 @@ pub struct AcmeOptions { /// Enable automatic renewal (default: true) #[serde(skip_serializing_if = "Option::is_none")] pub auto_renew: Option, - /// Directory to store certificates (default: './certs') - #[serde(skip_serializing_if = "Option::is_none")] - pub certificate_store: Option, #[serde(skip_serializing_if = "Option::is_none")] pub skip_configured_certs: Option, /// How often to check for renewals (default: 24) @@ -361,7 +358,6 @@ mod tests { use_production: None, renew_threshold_days: None, auto_renew: None, - certificate_store: None, skip_configured_certs: None, renew_check_interval_hours: None, }), diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index 43ef4c4..bd5cda1 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -88,8 +88,8 @@ pub struct TcpListenerManager { route_manager: Arc>, /// Shared metrics collector metrics: Arc, - /// TLS acceptors indexed by domain - tls_configs: Arc>, + /// TLS acceptors indexed by domain (ArcSwap for hot-reload visibility in accept loops) + tls_configs: Arc>>, /// HTTP proxy service for HTTP-level forwarding http_proxy: Arc, /// Connection configuration @@ -118,7 +118,7 @@ impl TcpListenerManager { listeners: HashMap::new(), route_manager: Arc::new(ArcSwap::from(route_manager)), metrics, - tls_configs: Arc::new(HashMap::new()), + tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))), http_proxy, conn_config: Arc::new(conn_config), conn_tracker, @@ -142,7 +142,7 @@ impl TcpListenerManager { listeners: HashMap::new(), route_manager: Arc::new(ArcSwap::from(route_manager)), metrics, - tls_configs: Arc::new(HashMap::new()), + tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))), http_proxy, conn_config: Arc::new(conn_config), conn_tracker, @@ -161,8 +161,9 @@ impl TcpListenerManager { } /// Set TLS certificate configurations. - pub fn set_tls_configs(&mut self, configs: HashMap) { - self.tls_configs = Arc::new(configs); + /// Uses ArcSwap so running accept loops immediately see the new certs. + pub fn set_tls_configs(&self, configs: HashMap) { + self.tls_configs.store(Arc::new(configs)); } /// Set the shared socket-handler relay path. @@ -284,7 +285,7 @@ impl TcpListenerManager { port: u16, route_manager_swap: Arc>, metrics: Arc, - tls_configs: Arc>, + tls_configs: Arc>>, http_proxy: Arc, conn_config: Arc, conn_tracker: Arc, @@ -314,7 +315,8 @@ impl TcpListenerManager { // Load the latest route manager from ArcSwap on each connection let rm = route_manager_swap.load_full(); let m = Arc::clone(&metrics); - let tc = Arc::clone(&tls_configs); + // Load the latest TLS configs from ArcSwap on each connection + let tc = tls_configs.load_full(); let hp = Arc::clone(&http_proxy); let cc = Arc::clone(&conn_config); let ct = Arc::clone(&conn_tracker); diff --git a/rust/crates/rustproxy-tls/Cargo.toml b/rust/crates/rustproxy-tls/Cargo.toml index cb90a8e..dfa9e9d 100644 --- a/rust/crates/rustproxy-tls/Cargo.toml +++ b/rust/crates/rustproxy-tls/Cargo.toml @@ -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 } diff --git a/rust/crates/rustproxy-tls/src/acme.rs b/rust/crates/rustproxy-tls/src/acme.rs index 973f072..dbb6541 100644 --- a/rust/crates/rustproxy-tls/src/acme.rs +++ b/rust/crates/rustproxy-tls/src/acme.rs @@ -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, } 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) -> 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 { 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::(&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()); - } } diff --git a/rust/crates/rustproxy-tls/src/cert_manager.rs b/rust/crates/rustproxy-tls/src/cert_manager.rs index b9af9b7..5ae6b5c 100644 --- a/rust/crates/rustproxy-tls/src/cert_manager.rs +++ b/rust/crates/rustproxy-tls/src/cert_manager.rs @@ -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 { 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 { - 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() diff --git a/rust/crates/rustproxy-tls/src/cert_store.rs b/rust/crates/rustproxy-tls/src/cert_store.rs index 0391ed6..c7115e0 100644 --- a/rust/crates/rustproxy-tls/src/cert_store.rs +++ b/rust/crates/rustproxy-tls/src/cert_store.rs @@ -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, } impl CertStore { - /// Create a new cert store at the given directory. - pub fn new(base_dir: impl AsRef) -> 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 { - 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 { - 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"); } } diff --git a/rust/crates/rustproxy/src/lib.rs b/rust/crates/rustproxy/src/lib.rs index 9e8afce..78e15c8 100644 --- a/rust/crates/rustproxy/src/lib.rs +++ b/rust/crates/rustproxy/src/lib.rs @@ -184,15 +184,12 @@ impl RustProxy { return None; } - let store_path = acme.certificate_store - .as_deref() - .unwrap_or("./certs"); let email = acme.email.clone() .or_else(|| acme.account_email.clone()); let use_production = acme.use_production.unwrap_or(false); let renew_before_days = acme.renew_threshold_days.unwrap_or(30); - let store = CertStore::new(store_path); + let store = CertStore::new(); Some(CertManager::new(store, email, use_production, renew_before_days)) } @@ -222,19 +219,6 @@ impl RustProxy { info!("Starting RustProxy..."); - // Load persisted certificates - if let Some(ref cm) = self.cert_manager { - let mut cm = cm.lock().await; - match cm.load_all() { - Ok(count) => { - if count > 0 { - info!("Loaded {} persisted certificates", count); - } - } - Err(e) => warn!("Failed to load persisted certificates: {}", e), - } - } - // Auto-provision certificates for routes with certificate: 'auto' self.auto_provision_certificates().await; @@ -396,9 +380,7 @@ impl RustProxy { }; let mut cm = cm_arc.lock().await; - if let Err(e) = cm.load_static(domain.clone(), bundle) { - error!("Failed to store certificate for {}: {}", domain, e); - } + cm.load_static(domain.clone(), bundle); info!("Certificate provisioned for {}", domain); } @@ -775,8 +757,7 @@ impl RustProxy { }; let mut cm = cm_arc.lock().await; - cm.load_static(domain.to_string(), bundle) - .map_err(|e| anyhow::anyhow!("Failed to store certificate: {}", e))?; + cm.load_static(domain.to_string(), bundle); } // Hot-swap TLS config on the listener diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 32eeedf..4665216 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '23.1.6', + version: '24.0.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/core/models/common-types.ts b/ts/core/models/common-types.ts index 1aeb997..b6ec367 100644 --- a/ts/core/models/common-types.ts +++ b/ts/core/models/common-types.ts @@ -85,7 +85,6 @@ export interface IAcmeOptions { renewThresholdDays?: number; // Days before expiry to renew certificates renewCheckIntervalHours?: number; // How often to check for renewals (in hours) autoRenew?: boolean; // Whether to automatically renew certificates - certificateStore?: string; // Directory to store certificates skipConfiguredCerts?: boolean; // Skip domains with existing certificates domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs } \ No newline at end of file diff --git a/ts/proxies/smart-proxy/models/index.ts b/ts/proxies/smart-proxy/models/index.ts index b62fcf1..541b2be 100644 --- a/ts/proxies/smart-proxy/models/index.ts +++ b/ts/proxies/smart-proxy/models/index.ts @@ -2,6 +2,6 @@ * SmartProxy models */ // Export everything except IAcmeOptions from interfaces -export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js'; +export type { ISmartProxyOptions, ISmartProxyCertStore, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js'; export * from './route-types.js'; export * from './metrics-types.js'; diff --git a/ts/proxies/smart-proxy/models/interfaces.ts b/ts/proxies/smart-proxy/models/interfaces.ts index 0ae7335..6cab361 100644 --- a/ts/proxies/smart-proxy/models/interfaces.ts +++ b/ts/proxies/smart-proxy/models/interfaces.ts @@ -10,11 +10,23 @@ export interface IAcmeOptions { useProduction?: boolean; // Use Let's Encrypt production (default: false) renewThresholdDays?: number; // Days before expiry to renew (default: 30) autoRenew?: boolean; // Enable automatic renewal (default: true) - certificateStore?: string; // Directory to store certificates (default: './certs') skipConfiguredCerts?: boolean; renewCheckIntervalHours?: number; // How often to check for renewals (default: 24) routeForwards?: any[]; } + +/** + * Consumer-provided certificate storage. + * SmartProxy never writes certs to disk — the consumer owns all persistence. + */ +export interface ISmartProxyCertStore { + /** Load all stored certs on startup (called once before cert provisioning) */ + loadAll: () => Promise>; + /** Save a cert after successful provisioning */ + save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise; + /** Remove a cert (optional) */ + remove?: (domain: string) => Promise; +} import type { IRouteConfig } from './route-types.js'; /** @@ -136,6 +148,20 @@ export interface ISmartProxyOptions { */ certProvisionFallbackToAcme?: boolean; + /** + * Disable the default self-signed fallback certificate. + * When false (default), a self-signed cert is generated at startup and loaded + * as '*' so TLS handshakes never fail due to missing certs. + */ + disableDefaultCert?: boolean; + + /** + * Consumer-provided cert storage. SmartProxy never writes certs to disk. + * On startup, loadAll() is called to pre-load persisted certs. + * After each successful cert provision, save() is called. + */ + certStore?: ISmartProxyCertStore; + /** * Path to the RustProxy binary. If not set, the binary is located * automatically via env var, platform package, local build, or PATH. diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index 0500a7c..da64832 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -10,6 +10,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js'; // Route management import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { RouteValidator } from './utils/route-validator.js'; +import { generateDefaultCertificate } from './utils/default-cert-generator.js'; import { Mutex } from './utils/mutex.js'; // Types @@ -68,7 +69,6 @@ export class SmartProxy extends plugins.EventEmitter { useProduction: this.settings.acme.useProduction || false, renewThresholdDays: this.settings.acme.renewThresholdDays || 30, autoRenew: this.settings.acme.autoRenew !== false, - certificateStore: this.settings.acme.certificateStore || './certs', skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, routeForwards: this.settings.acme.routeForwards || [], @@ -165,8 +165,34 @@ export class SmartProxy extends plugins.EventEmitter { await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath()); } + // Load default self-signed fallback certificate (domain: '*') + if (!this.settings.disableDefaultCert) { + try { + const defaultCert = generateDefaultCertificate(); + await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key); + logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' }); + } catch (err: any) { + logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' }); + } + } + + // Load consumer-stored certificates + const preloadedDomains = new Set(); + if (this.settings.certStore) { + try { + const stored = await this.settings.certStore.loadAll(); + for (const entry of stored) { + await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca); + preloadedDomains.add(entry.domain); + } + logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' }); + } catch (err: any) { + logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' }); + } + } + // Handle certProvisionFunction - await this.provisionCertificatesViaCallback(); + await this.provisionCertificatesViaCallback(preloadedDomains); // Start metrics polling this.metricsAdapter.startPolling(); @@ -355,7 +381,6 @@ export class SmartProxy extends plugins.EventEmitter { port: acme.port, renewThresholdDays: acme.renewThresholdDays, autoRenew: acme.autoRenew, - certificateStore: acme.certificateStore, renewCheckIntervalHours: acme.renewCheckIntervalHours, } : undefined, @@ -379,11 +404,11 @@ export class SmartProxy extends plugins.EventEmitter { * If the callback returns a cert object, load it into Rust. * If it returns 'http01', let Rust handle ACME. */ - private async provisionCertificatesViaCallback(): Promise { + private async provisionCertificatesViaCallback(skipDomains: Set = new Set()): Promise { const provisionFn = this.settings.certProvisionFunction; if (!provisionFn) return; - const provisionedDomains = new Set(); + const provisionedDomains = new Set(skipDomains); for (const route of this.settings.routes) { if (route.action.tls?.certificate !== 'auto') continue; @@ -405,7 +430,8 @@ export class SmartProxy extends plugins.EventEmitter { await this.bridge.provisionCertificate(route.name); logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' }); } catch (provisionErr: any) { - logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}`, { component: 'smart-proxy' }); + logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` + + 'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' }); } } continue; @@ -420,13 +446,30 @@ export class SmartProxy extends plugins.EventEmitter { certObj.privateKey, ); logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' }); + + // Persist to consumer store + if (this.settings.certStore?.save) { + try { + await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey); + } catch (storeErr: any) { + logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' }); + } + } } } catch (err: any) { logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' }); - // Fallback to ACME if enabled - if (this.settings.certProvisionFallbackToAcme !== false) { - logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' }); + // Fallback to ACME if enabled and route has a name + if (this.settings.certProvisionFallbackToAcme !== false && route.name) { + try { + await this.bridge.provisionCertificate(route.name); + logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' }); + } catch (acmeErr: any) { + logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` + + (this.settings.disableDefaultCert + ? ' — TLS will fail for this domain (disableDefaultCert is true)' + : ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' }); + } } } } diff --git a/ts/proxies/smart-proxy/utils/default-cert-generator.ts b/ts/proxies/smart-proxy/utils/default-cert-generator.ts new file mode 100644 index 0000000..09bd1fd --- /dev/null +++ b/ts/proxies/smart-proxy/utils/default-cert-generator.ts @@ -0,0 +1,36 @@ +import * as plugins from '../../../plugins.js'; + +/** + * Generate a self-signed fallback certificate (CN=SmartProxy Default Certificate, SAN=*). + * Used as the '*' wildcard fallback so TLS handshakes never reset due to missing certs. + */ +export function generateDefaultCertificate(): { cert: string; key: string } { + const forge = plugins.smartcrypto.nodeForge; + + // Generate 2048-bit RSA keypair + const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 }); + + // Create self-signed X.509 certificate + const cert = forge.pki.createCertificate(); + cert.publicKey = keypair.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); + + const attrs = [{ name: 'commonName', value: 'SmartProxy Default Certificate' }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + + // Add wildcard SAN + cert.setExtensions([ + { name: 'subjectAltName', altNames: [{ type: 2 /* DNS */, value: '*' }] }, + ]); + + cert.sign(keypair.privateKey, forge.md.sha256.create()); + + return { + cert: forge.pki.certificateToPem(cert), + key: forge.pki.privateKeyToPem(keypair.privateKey), + }; +} diff --git a/ts/proxies/smart-proxy/utils/index.ts b/ts/proxies/smart-proxy/utils/index.ts index 9f7c894..d5fb631 100644 --- a/ts/proxies/smart-proxy/utils/index.ts +++ b/ts/proxies/smart-proxy/utils/index.ts @@ -14,6 +14,9 @@ export * from './route-validator.js'; // Export route utilities for route operations export * from './route-utils.js'; +// Export default certificate generator +export { generateDefaultCertificate } from './default-cert-generator.js'; + // Export additional functions from route-helpers that weren't already exported export { createApiGatewayRoute,