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:
13
changelog.md
13
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<HashMap<...>> 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
|
||||
|
||||
|
||||
92
readme.md
92
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
|
||||
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<ICert | 'http01'>;
|
||||
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<Array<{
|
||||
domain: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
ca?: string;
|
||||
}>>;
|
||||
|
||||
/** Called after each successful cert provision */
|
||||
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
|
||||
|
||||
/** Optional: remove a cert from storage */
|
||||
remove?: (domain: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
40
rust/Cargo.lock
generated
40
rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -29,9 +29,6 @@ pub struct AcmeOptions {
|
||||
/// Enable automatic renewal (default: true)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_renew: Option<bool>,
|
||||
/// Directory to store certificates (default: './certs')
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub certificate_store: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skip_configured_certs: Option<bool>,
|
||||
/// 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,
|
||||
}),
|
||||
|
||||
@@ -88,8 +88,8 @@ pub struct TcpListenerManager {
|
||||
route_manager: Arc<ArcSwap<RouteManager>>,
|
||||
/// Shared metrics collector
|
||||
metrics: Arc<MetricsCollector>,
|
||||
/// TLS acceptors indexed by domain
|
||||
tls_configs: Arc<HashMap<String, TlsCertConfig>>,
|
||||
/// TLS acceptors indexed by domain (ArcSwap for hot-reload visibility in accept loops)
|
||||
tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
|
||||
/// HTTP proxy service for HTTP-level forwarding
|
||||
http_proxy: Arc<HttpProxyService>,
|
||||
/// 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<String, TlsCertConfig>) {
|
||||
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<String, TlsCertConfig>) {
|
||||
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<ArcSwap<RouteManager>>,
|
||||
metrics: Arc<MetricsCollector>,
|
||||
tls_configs: Arc<HashMap<String, TlsCertConfig>>,
|
||||
tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
|
||||
http_proxy: Arc<HttpProxyService>,
|
||||
conn_config: Arc<ConnectionConfig>,
|
||||
conn_tracker: Arc<ConnectionTracker>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)?;
|
||||
/// Remove a certificate from the cache.
|
||||
pub fn remove(&mut self, domain: &str) -> bool {
|
||||
self.cache.remove(domain).is_some()
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }>>;
|
||||
/** Save a cert after successful provisioning */
|
||||
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
|
||||
/** Remove a cert (optional) */
|
||||
remove?: (domain: string) => Promise<void>;
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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<string>();
|
||||
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<void> {
|
||||
private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
|
||||
const provisionFn = this.settings.certProvisionFunction;
|
||||
if (!provisionFn) return;
|
||||
|
||||
const provisionedDomains = new Set<string>();
|
||||
const provisionedDomains = new Set<string>(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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user