Compare commits

...

12 Commits

Author SHA1 Message Date
07e464fdac v24.0.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 16:32:02 +00:00
0e058594c9 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 2026-02-13 16:32:02 +00:00
e0af82c1ef v23.1.6
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 13:08:30 +00:00
efe3d80713 fix(smart-proxy): disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow 2026-02-13 13:08:30 +00:00
6b04bc612b v23.1.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-13 12:02:47 +00:00
e774ec87ca fix(smart-proxy): provision certificates for wildcard domains instead of skipping them 2026-02-13 12:02:47 +00:00
cbde778f09 v23.1.4
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 22:35:25 +00:00
bc2bc874a5 fix(tests): make tests more robust and bump small dependencies 2026-02-12 22:35:25 +00:00
fdabf807b0 v23.1.3
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 20:17:32 +00:00
81e0e6b4d8 fix(rustproxy): install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches 2026-02-12 20:17:32 +00:00
28fa69bf59 v23.1.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 13:48:30 +00:00
5019658032 fix(core): use node: scoped builtin imports and add route unit tests 2026-02-11 13:48:30 +00:00
35 changed files with 8132 additions and 523 deletions

View File

@@ -1,5 +1,58 @@
# Changelog # 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
- Pass an optional ACME override into buildRustConfig so Rust ACME can be disabled per-run
- Disable Rust ACME when certProvisionFunction is configured to avoid provisioning race conditions
- Normalize routing glob patterns into concrete domain identifiers for certificate provisioning (expand leading-star globs and warn on unsupported patterns)
- Deduplicate domains during provisioning to avoid repeated attempts
- When the callback returns 'http01', explicitly trigger Rust ACME for the route via bridge.provisionCertificate and log success/failure
## 2026-02-13 - 23.1.5 - fix(smart-proxy)
provision certificates for wildcard domains instead of skipping them
- Removed early continue that skipped domains containing '*' in the domain loop
- Now calls provisionFn for wildcard domains so certificate provisioning can proceed for wildcard hosts
- Fixes cases where wildcard domains never had certificates requested
## 2026-02-12 - 23.1.4 - fix(tests)
make tests more robust and bump small dependencies
- Bump dependencies: @push.rocks/smartrust ^1.2.1 and minimatch ^10.2.0
- Replace hardcoded ports with named constants (ECHO_PORT, PROXY_PORT, PROXY_PORT_1/2) to avoid collisions between tests
- Add server 'error' handlers and reject listen promises on server errors to prevent silent hangs
- Reduce test timeouts and intervals (shorter test durations, more frequent pings) to speed up test runs
- Ensure proxy is stopped between tests and remove forced process.exit; export tap.start() consistently
- Adjust assertions to match the new shorter ping/response counts
## 2026-02-12 - 23.1.3 - fix(rustproxy)
install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches
- Install ring-based rustls crypto provider at startup to prevent panics from instant-acme/hyper-rustls calling ClientConfig::builder() before TLS listeners are initialized
- Add a non-blocking 10ms peek to detect HTTP traffic in the TCP passthrough fast-path to avoid misrouting HTTP and ensure HTTP proxy handles CORS, errors, and request-level routing
- Skip the fast-path and fall back to the HTTP proxy when HTTP is detected (with a debug log)
- When no route matches for detected HTTP connections, send an HTTP 502 Bad Gateway response and close the connection instead of silently dropping it
## 2026-02-11 - 23.1.2 - fix(core)
use node: scoped builtin imports and add route unit tests
- Replaced bare Node built-in imports (events, fs, http, https, net, path, tls, url, http2, buffer, crypto) with 'node:' specifiers for ESM/bundler compatibility (files updated include ts/plugins.ts, ts/core/models/socket-types.ts, ts/core/utils/enhanced-connection-pool.ts, ts/core/utils/socket-tracker.ts, ts/protocols/common/fragment-handler.ts, ts/protocols/tls/sni/client-hello-parser.ts, ts/protocols/tls/sni/sni-extraction.ts, ts/protocols/websocket/utils.ts, ts/tls/sni/sni-handler.ts).
- Added new unit tests (test/test.bun.ts and test/test.deno.ts) covering route helpers, validators, matching, merging and cloning to improve test coverage.
## 2026-02-11 - 23.1.1 - fix(rust-proxy) ## 2026-02-11 - 23.1.1 - fix(rust-proxy)
increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies

7324
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "23.1.1", "version": "24.0.0",
"private": false, "private": false,
"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.", "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.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -34,14 +34,14 @@
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrust": "^1.2.0", "@push.rocks/smartrust": "^1.2.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.1.0", "@push.rocks/smartstring": "^4.1.0",
"@push.rocks/taskbuffer": "^4.2.0", "@push.rocks/taskbuffer": "^4.2.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",
"@types/minimatch": "^6.0.0", "@types/minimatch": "^6.0.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"minimatch": "^10.1.2", "minimatch": "^10.2.0",
"pretty-ms": "^9.3.0", "pretty-ms": "^9.3.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },

62
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1 version: 5.0.1
'@push.rocks/smartrust': '@push.rocks/smartrust':
specifier: ^1.2.0 specifier: ^1.2.1
version: 1.2.0 version: 1.2.1
'@push.rocks/smartrx': '@push.rocks/smartrx':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
@@ -57,8 +57,8 @@ importers:
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
minimatch: minimatch:
specifier: ^10.1.2 specifier: ^10.2.0
version: 10.1.2 version: 10.2.0
pretty-ms: pretty-ms:
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
@@ -570,14 +570,6 @@ packages:
resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.1':
resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==}
engines: {node: 20 || >=22}
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -883,8 +875,8 @@ packages:
'@push.rocks/smartrouter@1.3.3': '@push.rocks/smartrouter@1.3.3':
resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==} resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==}
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
resolution: {integrity: sha512-JlaALselIHoP6C3ceQbrvz424G21cND/QsH/KI3E/JrO4XphJiGZwM6f4yJWrijdPYR/YYMoaIiYN7ybZp0C4w==} resolution: {integrity: sha512-ANwXXibUwoHNWF1hhXhXVVrfzYlhgHYRa2205Jkd/s/wXzcWHftYZthilJj+52B7nkzSB76umfxKfK5eBYY2Ug==}
'@push.rocks/smartrx@3.0.10': '@push.rocks/smartrx@3.0.10':
resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==} resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==}
@@ -1649,6 +1641,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.2:
resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==}
engines: {node: 20 || >=22}
bare-events@2.8.2: bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies: peerDependencies:
@@ -1714,6 +1710,10 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.2:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==} resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==}
@@ -2750,8 +2750,8 @@ packages:
minimalistic-crypto-utils@1.0.1: minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.1.2: minimatch@10.2.0:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
minimatch@3.1.2: minimatch@3.1.2:
@@ -4654,12 +4654,6 @@ snapshots:
dependencies: dependencies:
mute-stream: 1.0.0 mute-stream: 1.0.0
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.1':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
@@ -5034,7 +5028,7 @@ snapshots:
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
@@ -5127,7 +5121,7 @@ snapshots:
acme-client: 5.4.0 acme-client: 5.4.0
dns-packet: 5.6.1 dns-packet: 5.6.1
elliptic: 6.6.1 elliptic: 6.6.1
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5143,7 +5137,7 @@ snapshots:
acme-client: 5.4.0 acme-client: 5.4.0
dns-packet: 5.6.1 dns-packet: 5.6.1
elliptic: 6.6.1 elliptic: 6.6.1
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5497,7 +5491,7 @@ snapshots:
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
path-to-regexp: 8.3.0 path-to-regexp: 8.3.0
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
dependencies: dependencies:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -6323,7 +6317,7 @@ snapshots:
'@types/minimatch@6.0.0': '@types/minimatch@6.0.0':
dependencies: dependencies:
minimatch: 10.1.2 minimatch: 10.2.0
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
@@ -6494,6 +6488,10 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.2:
dependencies:
jackspeak: 4.2.3
bare-events@2.8.2: {} bare-events@2.8.2: {}
bare-fs@4.5.3: bare-fs@4.5.3:
@@ -6564,6 +6562,10 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.2:
dependencies:
balanced-match: 4.0.2
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@@ -7157,7 +7159,7 @@ snapshots:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
jackspeak: 4.2.3 jackspeak: 4.2.3
minimatch: 10.1.2 minimatch: 10.2.0
minipass: 7.1.2 minipass: 7.1.2
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.1 path-scurry: 2.0.1
@@ -7862,9 +7864,9 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {} minimalistic-crypto-utils@1.0.1: {}
minimatch@10.1.2: minimatch@10.2.0:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.1 brace-expansion: 5.0.2
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:

View File

@@ -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 | | 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data |
| 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts | | 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts |
| 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information | | 🔄 **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 ## 🚀 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 ## 🏛️ Architecture
SmartProxy uses a hybrid **Rust + TypeScript** 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 - **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 - **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) - **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
## 🎯 Route Configuration Reference ## 🎯 Route Configuration Reference
@@ -497,7 +543,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
```typescript ```typescript
interface IRouteMatch { 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' domains?: string | string[]; // 'example.com', '*.example.com'
path?: string; // '/api/*', '/users/:id' path?: string; // '/api/*', '/users/:id'
clientIp?: string[]; // ['10.0.0.0/8', '192.168.*'] clientIp?: string[]; // ['10.0.0.0/8', '192.168.*']
@@ -517,11 +563,16 @@ interface IRouteMatch {
```typescript ```typescript
interface IRouteTarget { interface IRouteTarget {
host: string | string[] | ((context: IRouteContext) => string); host: string | string[] | ((context: IRouteContext) => string | string[]);
port: number | 'preserve' | ((context: IRouteContext) => number); port: number | 'preserve' | ((context: IRouteContext) => number);
tls?: { ... }; // Per-target TLS override tls?: IRouteTls; // Per-target TLS override
priority?: number; // Target priority priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method) 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 createPortMappingRoute, // Port mapping with context
createOffsetPortMappingRoute, // Simple port offset createOffsetPortMappingRoute, // Simple port offset
createDynamicRoute, // Dynamic host/port via functions createDynamicRoute, // Dynamic host/port via functions
createPortOffset, // Port offset factory
// Security Modifiers // Security Modifiers
addRateLimiting, // Add rate limiting to any route addRateLimiting, // Add rate limiting to any route
@@ -680,7 +732,6 @@ interface ISmartProxyOptions {
port?: number; // HTTP-01 challenge port (default: 80) port?: number; // HTTP-01 challenge port (default: 80)
renewThresholdDays?: number; // Days before expiry to renew (default: 30) renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable auto-renewal (default: true) autoRenew?: boolean; // Enable auto-renewal (default: true)
certificateStore?: string; // Directory to store certs (default: './certs')
renewCheckIntervalHours?: number; // Renewal check interval (default: 24) renewCheckIntervalHours?: number; // Renewal check interval (default: 24)
}; };
@@ -688,6 +739,12 @@ interface ISmartProxyOptions {
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>; certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true) 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 // Global defaults
defaults?: { defaults?: {
target?: { host: string; port: number }; 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 ### IMetrics Interface
The `getMetrics()` method returns a cached metrics adapter that polls the Rust engine: 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.bytesIn(); // Total bytes received
metrics.totals.bytesOut(); // Total bytes sent metrics.totals.bytesOut(); // Total bytes sent
metrics.totals.connections(); // Total connections metrics.totals.connections(); // Total connections
// Percentiles
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
``` ```
## 🐛 Troubleshooting ## 🐛 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 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) 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 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 ## License and Legal Information

40
rust/Cargo.lock generated
View File

@@ -285,12 +285,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -618,12 +612,6 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -866,19 +854,6 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.36"
@@ -1084,8 +1059,6 @@ dependencies = [
"rustls", "rustls",
"rustproxy-config", "rustproxy-config",
"serde", "serde",
"serde_json",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -1260,19 +1233,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View File

@@ -29,9 +29,6 @@ pub struct AcmeOptions {
/// Enable automatic renewal (default: true) /// Enable automatic renewal (default: true)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub auto_renew: Option<bool>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub skip_configured_certs: Option<bool>, pub skip_configured_certs: Option<bool>,
/// How often to check for renewals (default: 24) /// How often to check for renewals (default: 24)
@@ -361,7 +358,6 @@ mod tests {
use_production: None, use_production: None,
renew_threshold_days: None, renew_threshold_days: None,
auto_renew: None, auto_renew: None,
certificate_store: None,
skip_configured_certs: None, skip_configured_certs: None,
renew_check_interval_hours: None, renew_check_interval_hours: None,
}), }),

View File

@@ -88,8 +88,8 @@ pub struct TcpListenerManager {
route_manager: Arc<ArcSwap<RouteManager>>, route_manager: Arc<ArcSwap<RouteManager>>,
/// Shared metrics collector /// Shared metrics collector
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
/// TLS acceptors indexed by domain /// TLS acceptors indexed by domain (ArcSwap for hot-reload visibility in accept loops)
tls_configs: Arc<HashMap<String, TlsCertConfig>>, tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
/// HTTP proxy service for HTTP-level forwarding /// HTTP proxy service for HTTP-level forwarding
http_proxy: Arc<HttpProxyService>, http_proxy: Arc<HttpProxyService>,
/// Connection configuration /// Connection configuration
@@ -118,7 +118,7 @@ impl TcpListenerManager {
listeners: HashMap::new(), listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)), route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics, metrics,
tls_configs: Arc::new(HashMap::new()), tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
http_proxy, http_proxy,
conn_config: Arc::new(conn_config), conn_config: Arc::new(conn_config),
conn_tracker, conn_tracker,
@@ -142,7 +142,7 @@ impl TcpListenerManager {
listeners: HashMap::new(), listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)), route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics, metrics,
tls_configs: Arc::new(HashMap::new()), tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
http_proxy, http_proxy,
conn_config: Arc::new(conn_config), conn_config: Arc::new(conn_config),
conn_tracker, conn_tracker,
@@ -161,8 +161,9 @@ impl TcpListenerManager {
} }
/// Set TLS certificate configurations. /// Set TLS certificate configurations.
pub fn set_tls_configs(&mut self, configs: HashMap<String, TlsCertConfig>) { /// Uses ArcSwap so running accept loops immediately see the new certs.
self.tls_configs = Arc::new(configs); pub fn set_tls_configs(&self, configs: HashMap<String, TlsCertConfig>) {
self.tls_configs.store(Arc::new(configs));
} }
/// Set the shared socket-handler relay path. /// Set the shared socket-handler relay path.
@@ -284,7 +285,7 @@ impl TcpListenerManager {
port: u16, port: u16,
route_manager_swap: Arc<ArcSwap<RouteManager>>, route_manager_swap: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
tls_configs: Arc<HashMap<String, TlsCertConfig>>, tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
http_proxy: Arc<HttpProxyService>, http_proxy: Arc<HttpProxyService>,
conn_config: Arc<ConnectionConfig>, conn_config: Arc<ConnectionConfig>,
conn_tracker: Arc<ConnectionTracker>, conn_tracker: Arc<ConnectionTracker>,
@@ -314,7 +315,8 @@ impl TcpListenerManager {
// Load the latest route manager from ArcSwap on each connection // Load the latest route manager from ArcSwap on each connection
let rm = route_manager_swap.load_full(); let rm = route_manager_swap.load_full();
let m = Arc::clone(&metrics); 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 hp = Arc::clone(&http_proxy);
let cc = Arc::clone(&conn_config); let cc = Arc::clone(&conn_config);
let ct = Arc::clone(&conn_tracker); let ct = Arc::clone(&conn_tracker);
@@ -364,6 +366,10 @@ impl TcpListenerManager {
// doesn't send initial data (e.g., SMTP, greeting-based protocols). // doesn't send initial data (e.g., SMTP, greeting-based protocols).
// If a route matches by port alone and doesn't need domain/path/TLS info, // If a route matches by port alone and doesn't need domain/path/TLS info,
// we can forward immediately without waiting for client data. // we can forward immediately without waiting for client data.
//
// IMPORTANT: HTTP connections must NOT use this path — they need the HTTP
// proxy for proper error responses, CORS handling, and request-level routing.
// We detect HTTP via a non-blocking peek before committing to raw forwarding.
{ {
let quick_ctx = rustproxy_routing::MatchContext { let quick_ctx = rustproxy_routing::MatchContext {
port, port,
@@ -384,7 +390,28 @@ impl TcpListenerManager {
// Only use fast path for simple port-only forward routes with no TLS // Only use fast path for simple port-only forward routes with no TLS
if has_no_domain && has_no_path && is_forward && has_no_tls { if has_no_domain && has_no_path && is_forward && has_no_tls {
if let Some(target) = quick_match.target { // Non-blocking peek: if client has already sent data that looks
// like HTTP, skip fast path and let the normal path handle it
// through the HTTP proxy (for CORS, error responses, path routing).
let is_likely_http = {
let mut probe = [0u8; 16];
// Brief peek: HTTP clients send data immediately after connect.
// Server-speaks-first protocols (SMTP etc.) send nothing initially.
// 10ms is ample for any HTTP client while negligible for
// server-speaks-first protocols (which wait seconds for greeting).
match tokio::time::timeout(
std::time::Duration::from_millis(10),
stream.peek(&mut probe),
).await {
Ok(Ok(n)) if n > 0 => sni_parser::is_http(&probe[..n]),
_ => false,
}
};
if is_likely_http {
debug!("Fast-path skipped: HTTP detected from {}, using HTTP proxy", peer_addr);
// Fall through to normal path for HTTP proxy handling
} else if let Some(target) = quick_match.target {
let target_host = target.host.first().to_string(); let target_host = target.host.first().to_string();
let target_port = target.port.resolve(port); let target_port = target.port.resolve(port);
let route_id = quick_match.route.id.as_deref(); let route_id = quick_match.route.id.as_deref();
@@ -562,6 +589,17 @@ impl TcpListenerManager {
Some(rm) => rm, Some(rm) => rm,
None => { None => {
debug!("No route matched for port {} domain {:?}", port, domain); debug!("No route matched for port {} domain {:?}", port, domain);
if is_http {
// Send a proper HTTP error instead of dropping the connection
use tokio::io::AsyncWriteExt;
let body = "No route matched";
let resp = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
}
return Ok(()); return Ok(());
} }
}; };

View File

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

View File

@@ -1,16 +1,15 @@
//! ACME (Let's Encrypt) integration using instant-acme. //! ACME (Let's Encrypt) integration using instant-acme.
//! //!
//! This module handles HTTP-01 challenge creation and certificate provisioning. //! 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::{ use instant_acme::{
Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus, Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus,
AccountCredentials, AccountCredentials,
}; };
use rcgen::{CertificateParams, KeyPair}; use rcgen::{CertificateParams, KeyPair};
use thiserror::Error; use thiserror::Error;
use tracing::{debug, info, warn}; use tracing::{debug, info};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AcmeError { pub enum AcmeError {
@@ -26,8 +25,6 @@ pub enum AcmeError {
NoHttp01Challenge, NoHttp01Challenge,
#[error("Timeout waiting for order: {0}")] #[error("Timeout waiting for order: {0}")]
Timeout(String), Timeout(String),
#[error("Account persistence error: {0}")]
Persistence(String),
} }
/// Pending HTTP-01 challenge that needs to be served. /// Pending HTTP-01 challenge that needs to be served.
@@ -41,8 +38,6 @@ pub struct PendingChallenge {
pub struct AcmeClient { pub struct AcmeClient {
use_production: bool, use_production: bool,
email: String, email: String,
/// Optional directory where account.json is persisted.
account_dir: Option<PathBuf>,
} }
impl AcmeClient { impl AcmeClient {
@@ -50,56 +45,15 @@ impl AcmeClient {
Self { Self {
use_production, use_production,
email, email,
account_dir: None,
} }
} }
/// Create a new client with account persistence at the given directory. /// Create a new ACME account (ephemeral — not persisted).
pub fn with_persistence(email: String, use_production: bool, account_dir: impl AsRef<Path>) -> Self {
Self {
use_production,
email,
account_dir: Some(account_dir.as_ref().to_path_buf()),
}
}
/// Get or create an ACME account, persisting credentials if account_dir is set.
async fn get_or_create_account(&self) -> Result<Account, AcmeError> { async fn get_or_create_account(&self) -> Result<Account, AcmeError> {
let directory_url = self.directory_url(); 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 contact = format!("mailto:{}", self.email);
let (account, credentials) = Account::create( let (account, _credentials) = Account::create(
&NewAccount { &NewAccount {
contact: &[&contact], contact: &[&contact],
terms_of_service_agreed: true, terms_of_service_agreed: true,
@@ -113,27 +67,6 @@ impl AcmeClient {
debug!("ACME account created"); 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) Ok(account)
} }
@@ -158,7 +91,7 @@ impl AcmeClient {
{ {
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url()); 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?; let account = self.get_or_create_account().await?;
// 2. Create order // 2. Create order
@@ -339,22 +272,4 @@ mod tests {
assert!(!client.directory_url().contains("staging")); assert!(!client.directory_url().contains("staging"));
assert!(client.is_production()); assert!(client.is_production());
} }
#[test]
fn test_with_persistence_sets_account_dir() {
let tmp = tempfile::tempdir().unwrap();
let client = AcmeClient::with_persistence(
"test@example.com".to_string(),
false,
tmp.path(),
);
assert!(client.account_dir.is_some());
assert_eq!(client.account_dir.unwrap(), tmp.path());
}
#[test]
fn test_without_persistence_no_account_dir() {
let client = AcmeClient::new("test@example.com".to_string(), false);
assert!(client.account_dir.is_none());
}
} }

View File

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

View File

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

View File

@@ -184,15 +184,12 @@ impl RustProxy {
return None; return None;
} }
let store_path = acme.certificate_store
.as_deref()
.unwrap_or("./certs");
let email = acme.email.clone() let email = acme.email.clone()
.or_else(|| acme.account_email.clone()); .or_else(|| acme.account_email.clone());
let use_production = acme.use_production.unwrap_or(false); let use_production = acme.use_production.unwrap_or(false);
let renew_before_days = acme.renew_threshold_days.unwrap_or(30); 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)) Some(CertManager::new(store, email, use_production, renew_before_days))
} }
@@ -222,19 +219,6 @@ impl RustProxy {
info!("Starting 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' // Auto-provision certificates for routes with certificate: 'auto'
self.auto_provision_certificates().await; self.auto_provision_certificates().await;
@@ -396,9 +380,7 @@ impl RustProxy {
}; };
let mut cm = cm_arc.lock().await; let mut cm = cm_arc.lock().await;
if let Err(e) = cm.load_static(domain.clone(), bundle) { cm.load_static(domain.clone(), bundle);
error!("Failed to store certificate for {}: {}", domain, e);
}
info!("Certificate provisioned for {}", domain); info!("Certificate provisioned for {}", domain);
} }
@@ -775,8 +757,7 @@ impl RustProxy {
}; };
let mut cm = cm_arc.lock().await; let mut cm = cm_arc.lock().await;
cm.load_static(domain.to_string(), bundle) cm.load_static(domain.to_string(), bundle);
.map_err(|e| anyhow::anyhow!("Failed to store certificate: {}", e))?;
} }
// Hot-swap TLS config on the listener // Hot-swap TLS config on the listener

View File

@@ -29,6 +29,11 @@ struct Cli {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Install the default CryptoProvider early, before any TLS or ACME code runs.
// This prevents panics from instant-acme/hyper-rustls calling ClientConfig::builder()
// before TLS listeners have started. Idempotent — later calls harmlessly return Err.
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize tracing - write to stderr so stdout is reserved for management IPC // Initialize tracing - write to stderr so stdout is reserved for management IPC

123
test/test.bun.ts Normal file
View File

@@ -0,0 +1,123 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpsTerminateRoute,
createCompleteHttpsServer,
createHttpRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
mergeRouteConfigs,
cloneRoute,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRoutes,
validateRouteConfig,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.action.type).toEqual('forward');
expect(route.action.tls).toBeDefined();
expect(route.action.tls!.mode).toEqual('terminate');
expect(route.match.domains).toEqual('secure.example.com');
});
tap.test('route creation - createCompleteHttpsServer returns redirect and main route', async () => {
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
expect(routes).toBeArray();
expect(routes.length).toBeGreaterThanOrEqual(2);
// Should have an HTTP→HTTPS redirect and an HTTPS route
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
expect(hasRedirect || hasHttps).toBeTrue();
});
tap.test('route validation - validateRoutes on a set of routes', async () => {
const routes: IRouteConfig[] = [
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
];
const result = validateRoutes(routes);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
const routes: any[] = [
createHttpRoute('valid.com', { host: '127.0.0.1', port: 3000 }),
{ match: { ports: 80 } }, // missing action
];
const result = validateRoutes(routes);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('path matching - routeMatchesPath with exact path', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
route.match.path = '/api';
expect(routeMatchesPath(route, '/api')).toBeTrue();
expect(routeMatchesPath(route, '/other')).toBeFalse();
});
tap.test('path matching - route without path matches everything', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// No path set, should match any path
expect(routeMatchesPath(route, '/anything')).toBeTrue();
expect(routeMatchesPath(route, '/')).toBeTrue();
});
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.priority = 10;
base.name = 'base-route';
const merged = mergeRouteConfigs(base, {
priority: 50,
name: 'merged-route',
});
expect(merged.priority).toEqual(50);
expect(merged.name).toEqual('merged-route');
// Original route fields should be preserved
expect(merged.match.domains).toEqual('example.com');
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
});
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.name = 'original';
const merged = mergeRouteConfigs(base, { name: 'changed' });
expect(base.name).toEqual('original');
expect(merged.name).toEqual('changed');
});
tap.test('route cloning - cloneRoute produces independent copy', async () => {
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
original.priority = 42;
original.name = 'original-route';
const cloned = cloneRoute(original);
// Should be equal in value
expect(cloned.match.domains).toEqual('example.com');
expect(cloned.priority).toEqual(42);
expect(cloned.name).toEqual('original-route');
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
expect(cloned.action.targets![0].port).toEqual(3000);
// Should be independent - modifying clone shouldn't affect original
cloned.name = 'cloned-route';
cloned.priority = 99;
expect(original.name).toEqual('original-route');
expect(original.priority).toEqual(42);
});
export default tap.start();

111
test/test.deno.ts Normal file
View File

@@ -0,0 +1,111 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpRoute,
createHttpsTerminateRoute,
createLoadBalancerRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
findMatchingRoutes,
findBestMatchingRoute,
routeMatchesDomain,
routeMatchesPort,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRouteConfig,
isValidDomain,
isValidPort,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpRoute produces correct structure', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.match.domains).toEqual('example.com');
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeArray();
expect(route.action.targets![0].host).toEqual('127.0.0.1');
expect(route.action.targets![0].port).toEqual(3000);
});
tap.test('route creation - createHttpRoute with array of domains', async () => {
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
expect(route.match.domains).toEqual(['a.com', 'b.com']);
});
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
const route = createHttpRoute('valid.example.com', { host: '10.0.0.1', port: 8080 });
const result = validateRouteConfig(route);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRouteConfig rejects missing action', async () => {
const badRoute = { match: { ports: 80 } } as any;
const result = validateRouteConfig(badRoute);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('route validation - isValidDomain checks correctly', async () => {
expect(isValidDomain('example.com')).toBeTrue();
expect(isValidDomain('*.example.com')).toBeTrue();
expect(isValidDomain('')).toBeFalse();
});
tap.test('route validation - isValidPort checks correctly', async () => {
expect(isValidPort(80)).toBeTrue();
expect(isValidPort(443)).toBeTrue();
expect(isValidPort(0)).toBeFalse();
expect(isValidPort(70000)).toBeFalse();
expect(isValidPort(-1)).toBeFalse();
});
tap.test('domain matching - exact domain', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
});
tap.test('domain matching - wildcard domain', async () => {
const route = createHttpRoute('*.example.com', { host: '127.0.0.1', port: 3000 });
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
});
tap.test('port matching - single port', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// createHttpRoute defaults to port 80
expect(routeMatchesPort(route, 80)).toBeTrue();
expect(routeMatchesPort(route, 443)).toBeFalse();
});
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
lowPriority.priority = 10;
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
highPriority.priority = 100;
const routes: IRouteConfig[] = [lowPriority, highPriority];
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(best).toBeDefined();
expect(best!.priority).toEqual(100);
expect(best!.action.targets![0].port).toEqual(4000);
});
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
expect(matches).toHaveLength(2);
});
export default tap.start();

View File

@@ -6,42 +6,49 @@ import { SmartProxy } from '../ts/index.js';
let testProxy: SmartProxy; let testProxy: SmartProxy;
let targetServer: net.Server; let targetServer: net.Server;
const ECHO_PORT = 47200;
const PROXY_PORT = 47201;
// Create a simple echo server as target // Create a simple echo server as target
tap.test('setup test environment', async () => { tap.test('setup test environment', async () => {
// Create target server that echoes data back // Create target server that echoes data back
targetServer = net.createServer((socket) => { targetServer = net.createServer((socket) => {
console.log('Target server: client connected'); console.log('Target server: client connected');
// Echo data back // Echo data back
socket.on('data', (data) => { socket.on('data', (data) => {
console.log(`Target server received: ${data.toString().trim()}`); console.log(`Target server received: ${data.toString().trim()}`);
socket.write(data); socket.write(data);
}); });
socket.on('close', () => { socket.on('close', () => {
console.log('Target server: client disconnected'); console.log('Target server: client disconnected');
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
targetServer.listen(9876, () => { targetServer.on('error', (err) => {
console.log('Target server listening on port 9876'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
targetServer.listen(ECHO_PORT, () => {
console.log(`Target server listening on port ${ECHO_PORT}`);
resolve(); resolve();
}); });
}); });
// Create proxy with simple TCP forwarding (no TLS) // Create proxy with simple TCP forwarding (no TLS)
testProxy = new SmartProxy({ testProxy = new SmartProxy({
routes: [{ routes: [{
name: 'tcp-forward-test', name: 'tcp-forward-test',
match: { match: {
ports: 8888 // Plain TCP port ports: PROXY_PORT // Plain TCP port
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
}] }]
// No TLS configuration - just plain TCP forwarding // No TLS configuration - just plain TCP forwarding
} }
@@ -49,7 +56,7 @@ tap.test('setup test environment', async () => {
defaults: { defaults: {
target: { target: {
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
} }
}, },
enableDetailedLogging: true, enableDetailedLogging: true,
@@ -59,72 +66,72 @@ tap.test('setup test environment', async () => {
keepAlive: true, keepAlive: true,
keepAliveInitialDelay: 1000 keepAliveInitialDelay: 1000
}); });
await testProxy.start(); await testProxy.start();
}); });
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => { tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
tools.timeout(60000); // 60 second test timeout tools.timeout(15000); // 15 second test timeout
const client = new net.Socket(); const client = new net.Socket();
let messagesReceived = 0; let messagesReceived = 0;
let connectionClosed = false; let connectionClosed = false;
// Connect to proxy // Connect to proxy
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(8888, 'localhost', () => { client.connect(PROXY_PORT, 'localhost', () => {
console.log('Client connected to proxy'); console.log('Client connected to proxy');
resolve(); resolve();
}); });
client.on('error', reject); client.on('error', reject);
}); });
// Set up data handler // Set up data handler
client.on('data', (data) => { client.on('data', (data) => {
console.log(`Client received: ${data.toString().trim()}`); console.log(`Client received: ${data.toString().trim()}`);
messagesReceived++; messagesReceived++;
}); });
client.on('close', () => { client.on('close', () => {
console.log('Client connection closed'); console.log('Client connection closed');
connectionClosed = true; connectionClosed = true;
}); });
// Send initial handshake-like data // Send initial handshake-like data
client.write('HELLO\n'); client.write('HELLO\n');
// Wait for response // Wait for response
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
expect(messagesReceived).toEqual(1); expect(messagesReceived).toEqual(1);
// Simulate WebSocket-like keep-alive pattern // Simulate WebSocket-like keep-alive pattern
// Send periodic messages over 60 seconds // Send periodic messages over 5 seconds
const startTime = Date.now(); const startTime = Date.now();
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
if (!connectionClosed && Date.now() - startTime < 60000) { if (!connectionClosed && Date.now() - startTime < 5000) {
console.log('Sending ping...'); console.log('Sending ping...');
client.write('PING\n'); client.write('PING\n');
} else { } else {
clearInterval(pingInterval); clearInterval(pingInterval);
} }
}, 10000); // Every 10 seconds }, 1000); // Every 1 second
// Wait for 55 seconds (must complete within 60s runner timeout) // Wait for 5 seconds — sufficient to verify the connection stays open
await new Promise(resolve => setTimeout(resolve, 55000)); await new Promise(resolve => setTimeout(resolve, 5000));
// Clean up interval // Clean up interval
clearInterval(pingInterval); clearInterval(pingInterval);
// Connection should still be open // Connection should still be open
expect(connectionClosed).toEqual(false); expect(connectionClosed).toEqual(false);
// Should have received responses (1 hello + 6 pings) // Should have received responses (1 hello + ~5 pings)
expect(messagesReceived).toBeGreaterThan(5); expect(messagesReceived).toBeGreaterThan(3);
// Close connection gracefully // Close connection gracefully
client.end(); client.end();
// Wait for close // Wait for close
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
expect(connectionClosed).toEqual(true); expect(connectionClosed).toEqual(true);
@@ -134,7 +141,7 @@ tap.test('should keep WebSocket-like connection open for extended period', async
tap.test('cleanup', async () => { tap.test('cleanup', async () => {
await testProxy.stop(); await testProxy.stop();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
targetServer.close(() => { targetServer.close(() => {
console.log('Target server closed'); console.log('Target server closed');

View File

@@ -5,8 +5,8 @@ import * as net from 'net';
let smartProxyInstance: SmartProxy; let smartProxyInstance: SmartProxy;
let echoServer: net.Server; let echoServer: net.Server;
const echoServerPort = 9876; const echoServerPort = 47300;
const proxyPort = 8080; const proxyPort = 47301;
// Create an echo server for testing // Create an echo server for testing
tap.test('should create echo server for testing', async () => { tap.test('should create echo server for testing', async () => {
@@ -16,7 +16,11 @@ tap.test('should create echo server for testing', async () => {
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
echoServer.on('error', (err) => {
console.error(`Echo server error: ${err.message}`);
reject(err);
});
echoServer.listen(echoServerPort, () => { echoServer.listen(echoServerPort, () => {
console.log(`Echo server listening on port ${echoServerPort}`); console.log(`Echo server listening on port ${echoServerPort}`);
resolve(); resolve();
@@ -265,4 +269,4 @@ tap.test('should clean up resources', async () => {
}); });
}); });
export default tap.start(); export default tap.start();

View File

@@ -5,19 +5,27 @@ import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
let echoServer: net.Server; let echoServer: net.Server;
let proxy: SmartProxy; let proxy: SmartProxy;
const ECHO_PORT = 47400;
const PROXY_PORT_1 = 47401;
const PROXY_PORT_2 = 47402;
tap.test('port forwarding should not immediately close connections', async (tools) => { tap.test('port forwarding should not immediately close connections', async (tools) => {
// Set a timeout for this test // Set a timeout for this test
tools.timeout(10000); // 10 seconds tools.timeout(10000); // 10 seconds
// Create an echo server // Create an echo server
echoServer = await new Promise<net.Server>((resolve) => { echoServer = await new Promise<net.Server>((resolve, reject) => {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
socket.write(`ECHO: ${data}`); socket.write(`ECHO: ${data}`);
}); });
}); });
server.listen(8888, () => { server.on('error', (err) => {
console.log('Echo server listening on port 8888'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
server.listen(ECHO_PORT, () => {
console.log(`Echo server listening on port ${ECHO_PORT}`);
resolve(server); resolve(server);
}); });
}); });
@@ -26,10 +34,10 @@ tap.test('port forwarding should not immediately close connections', async (tool
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'test-forward', name: 'test-forward',
match: { ports: 9999 }, match: { ports: PROXY_PORT_1 },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ host: 'localhost', port: 8888 }] targets: [{ host: 'localhost', port: ECHO_PORT }]
} }
}] }]
}); });
@@ -37,21 +45,24 @@ tap.test('port forwarding should not immediately close connections', async (tool
await proxy.start(); await proxy.start();
// Test connection through proxy // Test connection through proxy
const client = net.createConnection(9999, 'localhost'); const client = net.createConnection(PROXY_PORT_1, 'localhost');
const result = await new Promise<string>((resolve, reject) => { const result = await new Promise<string>((resolve, reject) => {
client.on('data', (data) => { client.on('data', (data) => {
const response = data.toString(); const response = data.toString();
client.end(); // Close the connection after receiving data client.end(); // Close the connection after receiving data
resolve(response); resolve(response);
}); });
client.on('error', reject); client.on('error', reject);
client.write('Hello'); client.write('Hello');
}); });
expect(result).toEqual('ECHO: Hello'); expect(result).toEqual('ECHO: Hello');
// Stop proxy from test 1 before test 2 reassigns the variable
await proxy.stop();
}); });
tap.test('TLS passthrough should work correctly', async () => { tap.test('TLS passthrough should work correctly', async () => {
@@ -59,7 +70,7 @@ tap.test('TLS passthrough should work correctly', async () => {
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'tls-test', name: 'tls-test',
match: { ports: 8443, domains: 'test.example.com' }, match: { ports: PROXY_PORT_2, domains: 'test.example.com' },
action: { action: {
type: 'forward', type: 'forward',
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
@@ -85,16 +96,6 @@ tap.test('cleanup', async () => {
}); });
}); });
} }
if (proxy) {
await proxy.stop();
console.log('Proxy stopped');
}
}); });
export default tap.start().then(() => { export default tap.start();
// Force exit after tests complete
setTimeout(() => {
console.log('Forcing process exit');
process.exit(0);
}, 1000);
});

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '23.1.1', 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.' 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.'
} }

View File

@@ -85,7 +85,6 @@ export interface IAcmeOptions {
renewThresholdDays?: number; // Days before expiry to renew certificates renewThresholdDays?: number; // Days before expiry to renew certificates
renewCheckIntervalHours?: number; // How often to check for renewals (in hours) renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
autoRenew?: boolean; // Whether to automatically renew certificates autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains with existing certificates skipConfiguredCerts?: boolean; // Skip domains with existing certificates
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
} }

View File

@@ -1,4 +1,4 @@
import * as net from 'net'; import * as net from 'node:net';
import { WrappedSocket } from './wrapped-socket.js'; import { WrappedSocket } from './wrapped-socket.js';
/** /**

View File

@@ -1,7 +1,7 @@
import { LifecycleComponent } from './lifecycle-component.js'; import { LifecycleComponent } from './lifecycle-component.js';
import { BinaryHeap } from './binary-heap.js'; import { BinaryHeap } from './binary-heap.js';
import { AsyncMutex } from './async-utils.js'; import { AsyncMutex } from './async-utils.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
/** /**
* Interface for pooled connection * Interface for pooled connection

View File

@@ -3,7 +3,7 @@
* Provides standardized socket cleanup with proper listener and timer management * Provides standardized socket cleanup with proper listener and timer management
*/ */
import type { Socket } from 'net'; import type { Socket } from 'node:net';
export type SocketTracked = { export type SocketTracked = {
cleanup: () => void; cleanup: () => void;

View File

@@ -1,13 +1,13 @@
// node native scope // node native scope
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
import * as fs from 'fs'; import * as fs from 'node:fs';
import * as http from 'http'; import * as http from 'node:http';
import * as https from 'https'; import * as https from 'node:https';
import * as net from 'net'; import * as net from 'node:net';
import * as path from 'path'; import * as path from 'node:path';
import * as tls from 'tls'; import * as tls from 'node:tls';
import * as url from 'url'; import * as url from 'node:url';
import * as http2 from 'http2'; import * as http2 from 'node:http2';
export { EventEmitter, fs, http, https, net, path, tls, url, http2 }; export { EventEmitter, fs, http, https, net, path, tls, url, http2 };

View File

@@ -5,7 +5,7 @@
* that may span multiple TCP packets. * that may span multiple TCP packets.
*/ */
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
/** /**
* Fragment tracking information * Fragment tracking information

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js';
import { import {
ClientHelloParser, ClientHelloParser,

View File

@@ -2,7 +2,7 @@
* WebSocket Protocol Utilities * WebSocket Protocol Utilities
*/ */
import * as crypto from 'crypto'; import * as crypto from 'node:crypto';
import { WEBSOCKET_MAGIC_STRING } from './constants.js'; import { WEBSOCKET_MAGIC_STRING } from './constants.js';
import type { RawData } from './types.js'; import type { RawData } from './types.js';

View File

@@ -2,6 +2,6 @@
* SmartProxy models * SmartProxy models
*/ */
// Export everything except IAcmeOptions from interfaces // 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 './route-types.js';
export * from './metrics-types.js'; export * from './metrics-types.js';

View File

@@ -10,11 +10,23 @@ export interface IAcmeOptions {
useProduction?: boolean; // Use Let's Encrypt production (default: false) useProduction?: boolean; // Use Let's Encrypt production (default: false)
renewThresholdDays?: number; // Days before expiry to renew (default: 30) renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable automatic renewal (default: true) autoRenew?: boolean; // Enable automatic renewal (default: true)
certificateStore?: string; // Directory to store certificates (default: './certs')
skipConfiguredCerts?: boolean; skipConfiguredCerts?: boolean;
renewCheckIntervalHours?: number; // How often to check for renewals (default: 24) renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
routeForwards?: any[]; 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'; import type { IRouteConfig } from './route-types.js';
/** /**
@@ -136,6 +148,20 @@ export interface ISmartProxyOptions {
*/ */
certProvisionFallbackToAcme?: boolean; 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 * Path to the RustProxy binary. If not set, the binary is located
* automatically via env var, platform package, local build, or PATH. * automatically via env var, platform package, local build, or PATH.

View File

@@ -10,10 +10,11 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management // Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js'; import { RouteValidator } from './utils/route-validator.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js'; import { Mutex } from './utils/mutex.js';
// Types // Types
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject } from './models/interfaces.js'; import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js'; import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js'; import type { IMetrics } from './models/metrics-types.js';
@@ -68,7 +69,6 @@ export class SmartProxy extends plugins.EventEmitter {
useProduction: this.settings.acme.useProduction || false, useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30, renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false, autoRenew: this.settings.acme.autoRenew !== false,
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [], routeForwards: this.settings.acme.routeForwards || [],
@@ -146,8 +146,16 @@ export class SmartProxy extends plugins.EventEmitter {
// Preprocess routes (strip JS functions, convert socket-handler routes) // Preprocess routes (strip JS functions, convert socket-handler routes)
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes); const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
// When certProvisionFunction handles cert provisioning,
// disable Rust's built-in ACME to prevent race condition.
let acmeForRust = this.settings.acme;
if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
acmeForRust = { ...acmeForRust, enabled: false };
logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
}
// Build Rust config // Build Rust config
const config = this.buildRustConfig(rustRoutes); const config = this.buildRustConfig(rustRoutes, acmeForRust);
// Start the Rust proxy // Start the Rust proxy
await this.bridge.startProxy(config); await this.bridge.startProxy(config);
@@ -157,8 +165,34 @@ export class SmartProxy extends plugins.EventEmitter {
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath()); 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 // Handle certProvisionFunction
await this.provisionCertificatesViaCallback(); await this.provisionCertificatesViaCallback(preloadedDomains);
// Start metrics polling // Start metrics polling
this.metricsAdapter.startPolling(); this.metricsAdapter.startPolling();
@@ -334,20 +368,20 @@ export class SmartProxy extends plugins.EventEmitter {
/** /**
* Build the Rust configuration object from TS settings. * Build the Rust configuration object from TS settings.
*/ */
private buildRustConfig(routes: IRouteConfig[]): any { private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
return { return {
routes, routes,
defaults: this.settings.defaults, defaults: this.settings.defaults,
acme: this.settings.acme acme: acme
? { ? {
enabled: this.settings.acme.enabled, enabled: acme.enabled,
email: this.settings.acme.email, email: acme.email,
useProduction: this.settings.acme.useProduction, useProduction: acme.useProduction,
port: this.settings.acme.port, port: acme.port,
renewThresholdDays: this.settings.acme.renewThresholdDays, renewThresholdDays: acme.renewThresholdDays,
autoRenew: this.settings.acme.autoRenew, autoRenew: acme.autoRenew,
certificateStore: this.settings.acme.certificateStore, renewCheckIntervalHours: acme.renewCheckIntervalHours,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours,
} }
: undefined, : undefined,
connectionTimeout: this.settings.connectionTimeout, connectionTimeout: this.settings.connectionTimeout,
@@ -370,24 +404,36 @@ export class SmartProxy extends plugins.EventEmitter {
* If the callback returns a cert object, load it into Rust. * If the callback returns a cert object, load it into Rust.
* If it returns 'http01', let Rust handle ACME. * 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; const provisionFn = this.settings.certProvisionFunction;
if (!provisionFn) return; if (!provisionFn) return;
const provisionedDomains = new Set<string>(skipDomains);
for (const route of this.settings.routes) { for (const route of this.settings.routes) {
if (route.action.tls?.certificate !== 'auto') continue; if (route.action.tls?.certificate !== 'auto') continue;
if (!route.match.domains) continue; if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);
for (const domain of domains) {
if (domain.includes('*')) continue;
for (const domain of certDomains) {
if (provisionedDomains.has(domain)) continue;
provisionedDomains.add(domain);
try { try {
const result: TSmartProxyCertProvisionObject = await provisionFn(domain); const result: TSmartProxyCertProvisionObject = await provisionFn(domain);
if (result === 'http01') { if (result === 'http01') {
// Rust handles ACME for this domain // Callback wants HTTP-01 for this domain — trigger Rust ACME explicitly
if (route.name) {
try {
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}. ` +
'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
}
}
continue; continue;
} }
@@ -400,19 +446,73 @@ export class SmartProxy extends plugins.EventEmitter {
certObj.privateKey, certObj.privateKey,
); );
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' }); 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) { } catch (err: any) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' }); logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
// Fallback to ACME if enabled // Fallback to ACME if enabled and route has a name
if (this.settings.certProvisionFallbackToAcme !== false) { if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' }); 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' });
}
} }
} }
} }
} }
} }
/**
* Normalize routing glob patterns into valid domain identifiers for cert provisioning.
* - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']`
* - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard)
* - `code.foss.global` → `['code.foss.global']` (plain domain)
* - `*mid*.example.com` → skipped with warning (unsupported glob)
*/
private normalizeDomainsForCertProvisioning(rawDomains: string[]): string[] {
const result: string[] = [];
for (const raw of rawDomains) {
// Plain domain — no glob characters
if (!raw.includes('*')) {
result.push(raw);
continue;
}
// Valid wildcard: *.example.com
if (raw.startsWith('*.') && !raw.slice(2).includes('*')) {
result.push(raw);
continue;
}
// Routing glob like *example.com (leading star, no dot after it)
// Convert to bare domain + wildcard pair
if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) {
const baseDomain = raw.slice(1); // Remove leading *
result.push(baseDomain);
result.push(`*.${baseDomain}`);
continue;
}
// Unsupported glob pattern (e.g. *mid*.example.com)
logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' });
}
return result;
}
private isValidDomain(domain: string): boolean { private isValidDomain(domain: string): boolean {
if (!domain || domain.length === 0) return false; if (!domain || domain.length === 0) return false;
if (domain.includes('*')) return false; if (domain.includes('*')) return false;

View 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),
};
}

View File

@@ -14,6 +14,9 @@ export * from './route-validator.js';
// Export route utilities for route operations // Export route utilities for route operations
export * from './route-utils.js'; 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 additional functions from route-helpers that weren't already exported
export { export {
createApiGatewayRoute, createApiGatewayRoute,

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,