Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37372353d7 | |||
| 7afa4c4c58 | |||
| 998662e137 | |||
| a8f8946a4d | |||
| 07e464fdac | |||
| 0e058594c9 | |||
| e0af82c1ef | |||
| efe3d80713 | |||
| 6b04bc612b | |||
| e774ec87ca | |||
| cbde778f09 | |||
| bc2bc874a5 | |||
| fdabf807b0 | |||
| 81e0e6b4d8 | |||
| 28fa69bf59 | |||
| 5019658032 | |||
| a9fe365c78 | |||
| 32e0410227 | |||
| fd56064495 | |||
| 3b7e6a6ed7 |
90
changelog.md
90
changelog.md
@@ -1,5 +1,95 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-13 - 25.0.0 - BREAKING CHANGE(certs)
|
||||||
|
accept a second eventComms argument in certProvisionFunction, add cert provisioning event types, and emit certificate lifecycle events
|
||||||
|
|
||||||
|
- Breaking API change: certProvisionFunction signature changed from (domain: string) => Promise<TSmartProxyCertProvisionObject> to (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>. Custom provisioners must accept (or safely ignore) the new second argument.
|
||||||
|
- New types added and exported: ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent.
|
||||||
|
- smart-proxy now constructs an eventComms channel that allows provisioners to log/warn/error and set expiry date and source for the issued event.
|
||||||
|
- Emits 'certificate-issued' (domain, expiryDate, source, isRenewal?) on successful provisioning and 'certificate-failed' (domain, error, source) on failures.
|
||||||
|
- Updated public exports to include the new types so they are available to consumers.
|
||||||
|
- Removed readme.byte-counting-audit.md (documentation file deleted).
|
||||||
|
|
||||||
|
## 2026-02-13 - 24.0.1 - fix(proxy)
|
||||||
|
improve proxy robustness: add connect timeouts, graceful shutdown, WebSocket watchdog, and metrics guard
|
||||||
|
|
||||||
|
- Add tokio-util CancellationToken to HTTP handlers to support graceful shutdown (stop accepting new requests while letting in-flight requests finish).
|
||||||
|
- Introduce configurable upstream connect timeout (DEFAULT_CONNECT_TIMEOUT) and return 504 Gateway Timeout on connect timeouts to avoid hanging connections.
|
||||||
|
- Add WebSocket watchdog with inactivity and max-lifetime checks, activity tracking via AtomicU64, and cancellation-driven tunnel aborts.
|
||||||
|
- Add ConnectionGuard RAII in passthrough listener to ensure metrics.connection_closed() is called on all exit paths and disarm the guard when handing off to the HTTP proxy.
|
||||||
|
- Expose HttpProxyService::with_connect_timeout and wire connection timeout from ConnectionConfig into listeners.
|
||||||
|
- Add tokio-util workspace dependency (CancellationToken) and related code changes across rustproxy-http and rustproxy-passthrough.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies
|
||||||
|
|
||||||
|
- Set maxPayloadSize to 100 * 1024 * 1024 (100 MB) in ts/proxies/smart-proxy/rust-proxy-bridge.ts to support large route configs
|
||||||
|
- Bump devDependency @types/node from ^25.2.2 to ^25.2.3
|
||||||
|
- Bump dependency @push.rocks/smartrust from ^1.1.1 to ^1.2.0
|
||||||
|
|
||||||
|
## 2026-02-10 - 23.1.0 - feat(rust-bridge)
|
||||||
|
integrate tsrust to build and locate cross-compiled Rust binaries; refactor rust-proxy bridge to use typed IPC and streamline process handling; add @push.rocks/smartrust and update build/dev dependencies
|
||||||
|
|
||||||
|
- Add tsrust to the build script and include dist_rust candidates when locating the Rust binary (enables cross-compiled artifacts produced by tsrust).
|
||||||
|
- Remove the old rust-binary-locator and refactor rust-proxy-bridge to use explicit, typed IPC command definitions and improved process spawn/cleanup logic.
|
||||||
|
- Introduce @push.rocks/smartrust for type-safe JSON IPC and export it via plugins; update README with expanded metrics documentation and change initialDataTimeout default from 60s to 120s.
|
||||||
|
- Add rust/.cargo/config.toml with aarch64 linker configuration to support cross-compilation for arm64.
|
||||||
|
- Bump several devDependencies and runtime dependencies (e.g. @git.zone/tsbuild, @git.zone/tstest, @push.rocks/smartserve, @push.rocks/taskbuffer, ws, minimatch, etc.).
|
||||||
|
- Update runtime message guiding local builds to use 'pnpm build' (tsrust) instead of direct cargo invocation.
|
||||||
|
|
||||||
## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy)
|
## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy)
|
||||||
remove nftables-proxy implementation, models, and utilities from the repository
|
remove nftables-proxy implementation, models, and utilities from the repository
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
},
|
},
|
||||||
"@ship.zone/szci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": []
|
"npmGlobalTools": []
|
||||||
|
},
|
||||||
|
"@git.zone/tsrust": {
|
||||||
|
"targets": ["linux_amd64", "linux_arm64"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "23.0.0",
|
"version": "25.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",
|
||||||
@@ -10,16 +10,17 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
||||||
"format": "(gitzone format)",
|
"format": "(gitzone format)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tstest": "^3.1.3",
|
"@git.zone/tsrust": "^1.3.0",
|
||||||
"@push.rocks/smartserve": "^1.4.0",
|
"@git.zone/tstest": "^3.1.8",
|
||||||
"@types/node": "^24.10.2",
|
"@push.rocks/smartserve": "^2.0.1",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"why-is-node-running": "^3.2.2"
|
"why-is-node-running": "^3.2.2"
|
||||||
},
|
},
|
||||||
@@ -28,20 +29,21 @@
|
|||||||
"@push.rocks/smartacme": "^8.0.0",
|
"@push.rocks/smartacme": "^8.0.0",
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartfile": "^13.1.0",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@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.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": "^3.5.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.1",
|
"minimatch": "^10.2.0",
|
||||||
"pretty-ms": "^9.3.0",
|
"pretty-ms": "^9.3.0",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
|||||||
2716
pnpm-lock.yaml
generated
2716
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
|||||||
# SmartProxy Byte Counting Audit Report
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
After a comprehensive audit of the SmartProxy codebase, I can confirm that **byte counting is implemented correctly** with no instances of double counting. Each byte transferred through the proxy is counted exactly once in each direction.
|
|
||||||
|
|
||||||
## Byte Counting Implementation
|
|
||||||
|
|
||||||
### 1. Core Tracking Mechanisms
|
|
||||||
|
|
||||||
SmartProxy uses two complementary tracking systems:
|
|
||||||
|
|
||||||
1. **Connection Records** (`IConnectionRecord`):
|
|
||||||
- `bytesReceived`: Total bytes received from client
|
|
||||||
- `bytesSent`: Total bytes sent to client
|
|
||||||
|
|
||||||
2. **MetricsCollector**:
|
|
||||||
- Global throughput tracking via `ThroughputTracker`
|
|
||||||
- Per-connection byte tracking for route/IP metrics
|
|
||||||
- Called via `recordBytes(connectionId, bytesIn, bytesOut)`
|
|
||||||
|
|
||||||
### 2. Where Bytes Are Counted
|
|
||||||
|
|
||||||
Bytes are counted in only two files:
|
|
||||||
|
|
||||||
#### a) `route-connection-handler.ts`
|
|
||||||
- **Line 351**: TLS alert bytes when no SNI is provided
|
|
||||||
- **Lines 1286-1301**: Data forwarding callbacks in `setupBidirectionalForwarding()`
|
|
||||||
|
|
||||||
#### b) `http-proxy-bridge.ts`
|
|
||||||
- **Line 127**: Initial TLS chunk for HttpProxy connections
|
|
||||||
- **Lines 142-154**: Data forwarding callbacks in `setupBidirectionalForwarding()`
|
|
||||||
|
|
||||||
## Connection Flow Analysis
|
|
||||||
|
|
||||||
### 1. Direct TCP Connection (No TLS)
|
|
||||||
|
|
||||||
```
|
|
||||||
Client → SmartProxy → Target Server
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Connection arrives at `RouteConnectionHandler.handleConnection()`
|
|
||||||
2. For non-TLS ports, immediately routes via `routeConnection()`
|
|
||||||
3. `setupDirectConnection()` creates target connection
|
|
||||||
4. `setupBidirectionalForwarding()` handles all data transfer:
|
|
||||||
- `onClientData`: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
|
|
||||||
- `onServerData`: `bytesSent += chunk.length` + `recordBytes(0, chunk.length)`
|
|
||||||
|
|
||||||
**Result**: ✅ Each byte counted exactly once
|
|
||||||
|
|
||||||
### 2. TLS Passthrough Connection
|
|
||||||
|
|
||||||
```
|
|
||||||
Client (TLS) → SmartProxy → Target Server (TLS)
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Connection waits for initial data to detect TLS
|
|
||||||
2. TLS handshake detected, SNI extracted
|
|
||||||
3. Route matched, `setupDirectConnection()` called
|
|
||||||
4. Initial chunk stored in `pendingData` (NOT counted yet)
|
|
||||||
5. On target connect, `pendingData` written to target (still not counted)
|
|
||||||
6. `setupBidirectionalForwarding()` counts ALL bytes including initial chunk
|
|
||||||
|
|
||||||
**Result**: ✅ Each byte counted exactly once
|
|
||||||
|
|
||||||
### 3. TLS Termination via HttpProxy
|
|
||||||
|
|
||||||
```
|
|
||||||
Client (TLS) → SmartProxy → HttpProxy (localhost) → Target Server
|
|
||||||
```
|
|
||||||
|
|
||||||
1. TLS connection detected with `tls.mode = "terminate"`
|
|
||||||
2. `forwardToHttpProxy()` called:
|
|
||||||
- Initial chunk: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
|
|
||||||
3. Proxy connection created to HttpProxy on localhost
|
|
||||||
4. `setupBidirectionalForwarding()` handles subsequent data
|
|
||||||
|
|
||||||
**Result**: ✅ Each byte counted exactly once
|
|
||||||
|
|
||||||
### 4. HTTP Connection via HttpProxy
|
|
||||||
|
|
||||||
```
|
|
||||||
Client (HTTP) → SmartProxy → HttpProxy (localhost) → Target Server
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Connection on configured HTTP port (`useHttpProxy` ports)
|
|
||||||
2. Same flow as TLS termination
|
|
||||||
3. All byte counting identical to TLS termination
|
|
||||||
|
|
||||||
**Result**: ✅ Each byte counted exactly once
|
|
||||||
|
|
||||||
### 5. NFTables Forwarding
|
|
||||||
|
|
||||||
```
|
|
||||||
Client → [Kernel NFTables] → Target Server
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Connection detected, route matched with `forwardingEngine: 'nftables'`
|
|
||||||
2. Connection marked as `usingNetworkProxy = true`
|
|
||||||
3. NO application-level forwarding (kernel handles packet routing)
|
|
||||||
4. NO byte counting in application layer
|
|
||||||
|
|
||||||
**Result**: ✅ No counting (correct - kernel handles everything)
|
|
||||||
|
|
||||||
## Special Cases
|
|
||||||
|
|
||||||
### PROXY Protocol
|
|
||||||
- PROXY protocol headers sent to backend servers are NOT counted in client metrics
|
|
||||||
- Only actual client data is counted
|
|
||||||
- **Correct behavior**: Protocol overhead is not client data
|
|
||||||
|
|
||||||
### TLS Alerts
|
|
||||||
- TLS alerts (e.g., for missing SNI) are counted as sent bytes
|
|
||||||
- **Correct behavior**: Alerts are actual data sent to the client
|
|
||||||
|
|
||||||
### Initial Chunks
|
|
||||||
- **Direct connections**: Stored in `pendingData`, counted when forwarded
|
|
||||||
- **HttpProxy connections**: Counted immediately upon receipt
|
|
||||||
- **Both approaches**: Count each byte exactly once
|
|
||||||
|
|
||||||
## Verification Methodology
|
|
||||||
|
|
||||||
1. **Code Analysis**: Searched for all instances of:
|
|
||||||
- `bytesReceived +=` and `bytesSent +=`
|
|
||||||
- `recordBytes()` calls
|
|
||||||
- Data forwarding implementations
|
|
||||||
|
|
||||||
2. **Flow Tracing**: Followed data path for each connection type from entry to exit
|
|
||||||
|
|
||||||
3. **Handler Review**: Examined all forwarding handlers to ensure no additional counting
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### ✅ No Double Counting Detected
|
|
||||||
|
|
||||||
- Each byte is counted exactly once in the direction it flows
|
|
||||||
- Connection records and metrics are updated consistently
|
|
||||||
- No overlapping or duplicate counting logic found
|
|
||||||
|
|
||||||
### Areas of Excellence
|
|
||||||
|
|
||||||
1. **Centralized Counting**: All byte counting happens in just two files
|
|
||||||
2. **Consistent Pattern**: Uses `setupBidirectionalForwarding()` with callbacks
|
|
||||||
3. **Clear Separation**: Forwarding handlers don't interfere with proxy metrics
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
1. **Debug Logging**: Add optional debug logging to verify byte counts in production:
|
|
||||||
```typescript
|
|
||||||
if (settings.debugByteCount) {
|
|
||||||
logger.log('debug', `Bytes counted: ${connectionId} +${bytes} (total: ${record.bytesReceived})`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Unit Tests**: Create specific tests to ensure byte counting accuracy:
|
|
||||||
- Test initial chunk handling
|
|
||||||
- Test PROXY protocol overhead exclusion
|
|
||||||
- Test HttpProxy forwarding accuracy
|
|
||||||
|
|
||||||
3. **Protocol Overhead Tracking**: Consider separately tracking:
|
|
||||||
- PROXY protocol headers
|
|
||||||
- TLS handshake bytes
|
|
||||||
- HTTP headers vs body
|
|
||||||
|
|
||||||
4. **NFTables Documentation**: Clearly document that NFTables-forwarded connections are not included in application metrics
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
SmartProxy's byte counting implementation is **robust and accurate**. The design ensures that each byte is counted exactly once, with clear separation between connection tracking and metrics collection. No remediation is required.
|
|
||||||
146
readme.md
146
readme.md
@@ -36,6 +36,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a
|
|||||||
| 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data |
|
| 📊 **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
|
||||||
|
|
||||||
@@ -378,7 +379,9 @@ await proxy.updateRoutes([...newRoutes]);
|
|||||||
// Get real-time metrics
|
// Get real-time metrics
|
||||||
const metrics = proxy.getMetrics();
|
const metrics = proxy.getMetrics();
|
||||||
console.log(`Active connections: ${metrics.connections.active()}`);
|
console.log(`Active connections: ${metrics.connections.active()}`);
|
||||||
console.log(`Requests/sec: ${metrics.throughput.requestsPerSecond()}`);
|
console.log(`Bytes in: ${metrics.totals.bytesIn()}`);
|
||||||
|
console.log(`Requests/sec: ${metrics.requests.perSecond()}`);
|
||||||
|
console.log(`Throughput in: ${metrics.throughput.instant().in} bytes/sec`);
|
||||||
|
|
||||||
// Get detailed statistics from the Rust engine
|
// Get detailed statistics from the Rust engine
|
||||||
const stats = await proxy.getStatistics();
|
const stats = await proxy.getStatistics();
|
||||||
@@ -454,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:
|
||||||
@@ -486,8 +534,8 @@ 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** — JSON commands/events over stdin/stdout for seamless cross-language communication
|
- **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
|
||||||
|
|
||||||
@@ -495,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.*']
|
||||||
@@ -515,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;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -611,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
|
||||||
@@ -678,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)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -686,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 };
|
||||||
@@ -699,7 +758,7 @@ interface ISmartProxyOptions {
|
|||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
||||||
initialDataTimeout?: number; // Initial data/SNI timeout (default: 60s)
|
initialDataTimeout?: number; // Initial data/SNI timeout (default: 120s)
|
||||||
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
||||||
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
||||||
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
||||||
@@ -724,12 +783,64 @@ interface ISmartProxyOptions {
|
|||||||
// Behavior
|
// Behavior
|
||||||
enableDetailedLogging?: boolean; // Verbose connection logging
|
enableDetailedLogging?: boolean; // Verbose connection logging
|
||||||
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
|
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
|
||||||
|
|
||||||
// Rust binary
|
|
||||||
rustBinaryPath?: string; // Custom path to the Rust binary
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ISmartProxyCertStore Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ISmartProxyCertStore {
|
||||||
|
/** Called once on startup to pre-load persisted certs */
|
||||||
|
loadAll: () => Promise<Array<{
|
||||||
|
domain: string;
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
ca?: string;
|
||||||
|
}>>;
|
||||||
|
|
||||||
|
/** Called after each successful cert provision */
|
||||||
|
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** Optional: remove a cert from storage */
|
||||||
|
remove?: (domain: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMetrics Interface
|
||||||
|
|
||||||
|
The `getMetrics()` method returns a cached metrics adapter that polls the Rust engine:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const metrics = proxy.getMetrics();
|
||||||
|
|
||||||
|
// Connection metrics
|
||||||
|
metrics.connections.active(); // Current active connections
|
||||||
|
metrics.connections.total(); // Total connections since start
|
||||||
|
metrics.connections.byRoute(); // Map<routeName, activeCount>
|
||||||
|
metrics.connections.byIP(); // Map<ip, activeCount>
|
||||||
|
metrics.connections.topIPs(10); // Top N IPs by connection count
|
||||||
|
|
||||||
|
// Throughput (bytes/sec)
|
||||||
|
metrics.throughput.instant(); // { in: number, out: number }
|
||||||
|
metrics.throughput.recent(); // Recent average
|
||||||
|
metrics.throughput.average(); // Overall average
|
||||||
|
metrics.throughput.byRoute(); // Map<routeName, { in, out }>
|
||||||
|
|
||||||
|
// Request rates
|
||||||
|
metrics.requests.perSecond(); // Requests per second
|
||||||
|
metrics.requests.perMinute(); // Requests per minute
|
||||||
|
metrics.requests.total(); // Total requests
|
||||||
|
|
||||||
|
// Cumulative totals
|
||||||
|
metrics.totals.bytesIn(); // Total bytes received
|
||||||
|
metrics.totals.bytesOut(); // Total bytes sent
|
||||||
|
metrics.totals.connections(); // Total connections
|
||||||
|
|
||||||
|
// Percentiles
|
||||||
|
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
|
||||||
|
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
|
||||||
|
```
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Certificate Issues
|
### Certificate Issues
|
||||||
@@ -746,13 +857,13 @@ interface ISmartProxyOptions {
|
|||||||
- ✅ Enable debug logging with `enableDetailedLogging: true`
|
- ✅ Enable debug logging with `enableDetailedLogging: true`
|
||||||
|
|
||||||
### Rust Binary Not Found
|
### Rust Binary Not Found
|
||||||
|
|
||||||
SmartProxy searches for the Rust binary in this order:
|
SmartProxy searches for the Rust binary in this order:
|
||||||
1. `SMARTPROXY_RUST_BINARY` environment variable
|
1. `SMARTPROXY_RUST_BINARY` environment variable
|
||||||
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
|
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
|
||||||
3. Local dev build (`./rust/target/release/rustproxy`)
|
3. `dist_rust/rustproxy` relative to the package root (built by `tsrust`)
|
||||||
4. System PATH (`rustproxy`)
|
4. Local dev build (`./rust/target/release/rustproxy`)
|
||||||
|
5. System PATH (`rustproxy`)
|
||||||
Set `rustBinaryPath` in options to override.
|
|
||||||
|
|
||||||
### Performance Tuning
|
### Performance Tuning
|
||||||
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
|
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
|
||||||
@@ -772,6 +883,7 @@ Set `rustBinaryPath` in options to override.
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
2
rust/.cargo/config.toml
Normal file
2
rust/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
41
rust/Cargo.lock
generated
41
rust/Cargo.lock
generated
@@ -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"
|
||||||
@@ -996,6 +971,7 @@ dependencies = [
|
|||||||
"rustproxy-security",
|
"rustproxy-security",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1084,8 +1060,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 +1234,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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ thiserror = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
arc-swap = { workspace = true }
|
arc-swap = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::{BodyExt, Full, combinators::BoxBody};
|
use http_body_util::{BodyExt, Full, combinators::BoxBody};
|
||||||
@@ -14,6 +15,7 @@ use hyper::{Request, Response, StatusCode};
|
|||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use rustproxy_routing::RouteManager;
|
use rustproxy_routing::RouteManager;
|
||||||
@@ -23,11 +25,22 @@ use crate::request_filter::RequestFilter;
|
|||||||
use crate::response_filter::ResponseFilter;
|
use crate::response_filter::ResponseFilter;
|
||||||
use crate::upstream_selector::UpstreamSelector;
|
use crate::upstream_selector::UpstreamSelector;
|
||||||
|
|
||||||
|
/// Default upstream connect timeout (30 seconds).
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Default WebSocket inactivity timeout (1 hour).
|
||||||
|
const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
|
/// Default WebSocket max lifetime (24 hours).
|
||||||
|
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
||||||
|
|
||||||
/// HTTP proxy service that processes HTTP traffic.
|
/// HTTP proxy service that processes HTTP traffic.
|
||||||
pub struct HttpProxyService {
|
pub struct HttpProxyService {
|
||||||
route_manager: Arc<RouteManager>,
|
route_manager: Arc<RouteManager>,
|
||||||
metrics: Arc<MetricsCollector>,
|
metrics: Arc<MetricsCollector>,
|
||||||
upstream_selector: UpstreamSelector,
|
upstream_selector: UpstreamSelector,
|
||||||
|
/// Timeout for connecting to upstream backends.
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpProxyService {
|
impl HttpProxyService {
|
||||||
@@ -36,6 +49,21 @@ impl HttpProxyService {
|
|||||||
route_manager,
|
route_manager,
|
||||||
metrics,
|
metrics,
|
||||||
upstream_selector: UpstreamSelector::new(),
|
upstream_selector: UpstreamSelector::new(),
|
||||||
|
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with a custom connect timeout.
|
||||||
|
pub fn with_connect_timeout(
|
||||||
|
route_manager: Arc<RouteManager>,
|
||||||
|
metrics: Arc<MetricsCollector>,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
route_manager,
|
||||||
|
metrics,
|
||||||
|
upstream_selector: UpstreamSelector::new(),
|
||||||
|
connect_timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,41 +73,59 @@ impl HttpProxyService {
|
|||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
peer_addr: std::net::SocketAddr,
|
peer_addr: std::net::SocketAddr,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
cancel: CancellationToken,
|
||||||
) {
|
) {
|
||||||
self.handle_io(stream, peer_addr, port).await;
|
self.handle_io(stream, peer_addr, port, cancel).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle an incoming HTTP connection on any IO type (plain TCP or TLS-terminated).
|
/// Handle an incoming HTTP connection on any IO type (plain TCP or TLS-terminated).
|
||||||
///
|
///
|
||||||
/// Uses HTTP/1.1 with upgrade support. For clients that negotiate HTTP/2,
|
/// Uses HTTP/1.1 with upgrade support. Responds to graceful shutdown via the
|
||||||
/// use `handle_io_auto` instead.
|
/// cancel token — in-flight requests complete, but no new requests are accepted.
|
||||||
pub async fn handle_io<I>(
|
pub async fn handle_io<I>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
stream: I,
|
stream: I,
|
||||||
peer_addr: std::net::SocketAddr,
|
peer_addr: std::net::SocketAddr,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
cancel: CancellationToken,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
let io = TokioIo::new(stream);
|
let io = TokioIo::new(stream);
|
||||||
|
|
||||||
|
let cancel_inner = cancel.clone();
|
||||||
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
|
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
|
||||||
let svc = Arc::clone(&self);
|
let svc = Arc::clone(&self);
|
||||||
let peer = peer_addr;
|
let peer = peer_addr;
|
||||||
|
let cn = cancel_inner.clone();
|
||||||
async move {
|
async move {
|
||||||
svc.handle_request(req, peer, port).await
|
svc.handle_request(req, peer, port, cn).await
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use http1::Builder with upgrades for WebSocket support
|
// Use http1::Builder with upgrades for WebSocket support
|
||||||
let conn = hyper::server::conn::http1::Builder::new()
|
let mut conn = hyper::server::conn::http1::Builder::new()
|
||||||
.keep_alive(true)
|
.keep_alive(true)
|
||||||
.serve_connection(io, service)
|
.serve_connection(io, service)
|
||||||
.with_upgrades();
|
.with_upgrades();
|
||||||
|
|
||||||
if let Err(e) = conn.await {
|
// Use select to support graceful shutdown via cancellation token
|
||||||
debug!("HTTP connection error from {}: {}", peer_addr, e);
|
let conn_pin = std::pin::Pin::new(&mut conn);
|
||||||
|
tokio::select! {
|
||||||
|
result = conn_pin => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
debug!("HTTP connection error from {}: {}", peer_addr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
// Graceful shutdown: let in-flight request finish, stop accepting new ones
|
||||||
|
let conn_pin = std::pin::Pin::new(&mut conn);
|
||||||
|
conn_pin.graceful_shutdown();
|
||||||
|
if let Err(e) = conn.await {
|
||||||
|
debug!("HTTP connection error during shutdown from {}: {}", peer_addr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +135,7 @@ impl HttpProxyService {
|
|||||||
req: Request<Incoming>,
|
req: Request<Incoming>,
|
||||||
peer_addr: std::net::SocketAddr,
|
peer_addr: std::net::SocketAddr,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
cancel: CancellationToken,
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||||
let host = req.headers()
|
let host = req.headers()
|
||||||
.get("host")
|
.get("host")
|
||||||
@@ -184,7 +231,7 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
if is_websocket {
|
if is_websocket {
|
||||||
let result = self.handle_websocket_upgrade(
|
let result = self.handle_websocket_upgrade(
|
||||||
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key,
|
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, cancel,
|
||||||
).await;
|
).await;
|
||||||
// Note: for WebSocket, connection_ended is called inside
|
// Note: for WebSocket, connection_ended is called inside
|
||||||
// the spawned tunnel task when the connection closes.
|
// the spawned tunnel task when the connection closes.
|
||||||
@@ -223,15 +270,24 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to upstream
|
// Connect to upstream with timeout
|
||||||
let upstream_stream = match TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)).await {
|
let upstream_stream = match tokio::time::timeout(
|
||||||
Ok(s) => s,
|
self.connect_timeout,
|
||||||
Err(e) => {
|
TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)),
|
||||||
|
).await {
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => {
|
||||||
error!("Failed to connect to upstream {}:{}: {}", upstream.host, upstream.port, e);
|
error!("Failed to connect to upstream {}:{}: {}", upstream.host, upstream.port, e);
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
self.metrics.connection_closed(route_id);
|
self.metrics.connection_closed(route_id);
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error!("Upstream connect timeout for {}:{}", upstream.host, upstream.port);
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
self.metrics.connection_closed(route_id);
|
||||||
|
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
upstream_stream.set_nodelay(true).ok();
|
upstream_stream.set_nodelay(true).ok();
|
||||||
|
|
||||||
@@ -394,6 +450,7 @@ impl HttpProxyService {
|
|||||||
route: &rustproxy_config::RouteConfig,
|
route: &rustproxy_config::RouteConfig,
|
||||||
route_id: Option<&str>,
|
route_id: Option<&str>,
|
||||||
upstream_key: &str,
|
upstream_key: &str,
|
||||||
|
cancel: CancellationToken,
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
@@ -417,16 +474,24 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
info!("WebSocket upgrade from {} -> {}:{}", peer_addr, upstream.host, upstream.port);
|
info!("WebSocket upgrade from {} -> {}:{}", peer_addr, upstream.host, upstream.port);
|
||||||
|
|
||||||
let mut upstream_stream = match TcpStream::connect(
|
// Connect to upstream with timeout
|
||||||
format!("{}:{}", upstream.host, upstream.port)
|
let mut upstream_stream = match tokio::time::timeout(
|
||||||
|
self.connect_timeout,
|
||||||
|
TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)),
|
||||||
).await {
|
).await {
|
||||||
Ok(s) => s,
|
Ok(Ok(s)) => s,
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
error!("WebSocket: failed to connect upstream {}:{}: {}", upstream.host, upstream.port, e);
|
error!("WebSocket: failed to connect upstream {}:{}: {}", upstream.host, upstream.port, e);
|
||||||
self.upstream_selector.connection_ended(upstream_key);
|
self.upstream_selector.connection_ended(upstream_key);
|
||||||
self.metrics.connection_closed(route_id);
|
self.metrics.connection_closed(route_id);
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error!("WebSocket: upstream connect timeout for {}:{}", upstream.host, upstream.port);
|
||||||
|
self.upstream_selector.connection_ended(upstream_key);
|
||||||
|
self.metrics.connection_closed(route_id);
|
||||||
|
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
upstream_stream.set_nodelay(true).ok();
|
upstream_stream.set_nodelay(true).ok();
|
||||||
|
|
||||||
@@ -591,6 +656,11 @@ impl HttpProxyService {
|
|||||||
let (mut cr, mut cw) = tokio::io::split(client_io);
|
let (mut cr, mut cw) = tokio::io::split(client_io);
|
||||||
let (mut ur, mut uw) = tokio::io::split(upstream_stream);
|
let (mut ur, mut uw) = tokio::io::split(upstream_stream);
|
||||||
|
|
||||||
|
// Shared activity tracker for the watchdog
|
||||||
|
let last_activity = Arc::new(AtomicU64::new(0));
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let la1 = Arc::clone(&last_activity);
|
||||||
let c2u = tokio::spawn(async move {
|
let c2u = tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
@@ -603,11 +673,13 @@ impl HttpProxyService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total += n as u64;
|
total += n as u64;
|
||||||
|
la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let _ = uw.shutdown().await;
|
let _ = uw.shutdown().await;
|
||||||
total
|
total
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let la2 = Arc::clone(&last_activity);
|
||||||
let u2c = tokio::spawn(async move {
|
let u2c = tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
@@ -620,13 +692,59 @@ impl HttpProxyService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total += n as u64;
|
total += n as u64;
|
||||||
|
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let _ = cw.shutdown().await;
|
let _ = cw.shutdown().await;
|
||||||
total
|
total
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watchdog: monitors inactivity, max lifetime, and cancellation
|
||||||
|
let la_watch = Arc::clone(&last_activity);
|
||||||
|
let c2u_handle = c2u.abort_handle();
|
||||||
|
let u2c_handle = u2c.abort_handle();
|
||||||
|
let inactivity_timeout = DEFAULT_WS_INACTIVITY_TIMEOUT;
|
||||||
|
let max_lifetime = DEFAULT_WS_MAX_LIFETIME;
|
||||||
|
|
||||||
|
let watchdog = tokio::spawn(async move {
|
||||||
|
let check_interval = std::time::Duration::from_secs(5);
|
||||||
|
let mut last_seen = 0u64;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(check_interval) => {}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
debug!("WebSocket tunnel cancelled by shutdown");
|
||||||
|
c2u_handle.abort();
|
||||||
|
u2c_handle.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max lifetime
|
||||||
|
if start.elapsed() >= max_lifetime {
|
||||||
|
debug!("WebSocket tunnel exceeded max lifetime, closing");
|
||||||
|
c2u_handle.abort();
|
||||||
|
u2c_handle.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check inactivity
|
||||||
|
let current = la_watch.load(Ordering::Relaxed);
|
||||||
|
if current == last_seen {
|
||||||
|
let elapsed_since_activity = start.elapsed().as_millis() as u64 - current;
|
||||||
|
if elapsed_since_activity >= inactivity_timeout.as_millis() as u64 {
|
||||||
|
debug!("WebSocket tunnel inactive for {}ms, closing", elapsed_since_activity);
|
||||||
|
c2u_handle.abort();
|
||||||
|
u2c_handle.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_seen = current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let bytes_in = c2u.await.unwrap_or(0);
|
let bytes_in = c2u.await.unwrap_or(0);
|
||||||
let bytes_out = u2c.await.unwrap_or(0);
|
let bytes_out = u2c.await.unwrap_or(0);
|
||||||
|
watchdog.abort();
|
||||||
|
|
||||||
debug!("WebSocket tunnel closed: {} bytes in, {} bytes out", bytes_in, bytes_out);
|
debug!("WebSocket tunnel closed: {} bytes in, {} bytes out", bytes_in, bytes_out);
|
||||||
|
|
||||||
@@ -812,6 +930,7 @@ impl Default for HttpProxyService {
|
|||||||
route_manager: Arc::new(RouteManager::new(vec![])),
|
route_manager: Arc::new(RouteManager::new(vec![])),
|
||||||
metrics: Arc::new(MetricsCollector::new()),
|
metrics: Arc::new(MetricsCollector::new()),
|
||||||
upstream_selector: UpstreamSelector::new(),
|
upstream_selector: UpstreamSelector::new(),
|
||||||
|
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,38 @@ use crate::forwarder;
|
|||||||
use crate::tls_handler;
|
use crate::tls_handler;
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
use crate::connection_tracker::ConnectionTracker;
|
||||||
|
|
||||||
|
/// RAII guard that decrements the active connection metric on drop.
|
||||||
|
/// Ensures connection_closed is called on ALL exit paths — normal, error, or panic.
|
||||||
|
struct ConnectionGuard {
|
||||||
|
metrics: Arc<MetricsCollector>,
|
||||||
|
route_id: Option<String>,
|
||||||
|
disarmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionGuard {
|
||||||
|
fn new(metrics: Arc<MetricsCollector>, route_id: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
metrics,
|
||||||
|
route_id: route_id.map(|s| s.to_string()),
|
||||||
|
disarmed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disarm the guard — prevents the Drop from running.
|
||||||
|
/// Use when handing off to a path that manages its own cleanup (e.g., HTTP proxy).
|
||||||
|
fn disarm(mut self) {
|
||||||
|
self.disarmed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ConnectionGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.disarmed {
|
||||||
|
self.metrics.connection_closed(self.route_id.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ListenerError {
|
pub enum ListenerError {
|
||||||
#[error("Failed to bind port {port}: {source}")]
|
#[error("Failed to bind port {port}: {source}")]
|
||||||
@@ -88,8 +120,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
|
||||||
@@ -105,11 +137,12 @@ pub struct TcpListenerManager {
|
|||||||
impl TcpListenerManager {
|
impl TcpListenerManager {
|
||||||
pub fn new(route_manager: Arc<RouteManager>) -> Self {
|
pub fn new(route_manager: Arc<RouteManager>) -> Self {
|
||||||
let metrics = Arc::new(MetricsCollector::new());
|
let metrics = Arc::new(MetricsCollector::new());
|
||||||
let http_proxy = Arc::new(HttpProxyService::new(
|
let conn_config = ConnectionConfig::default();
|
||||||
|
let http_proxy = Arc::new(HttpProxyService::with_connect_timeout(
|
||||||
Arc::clone(&route_manager),
|
Arc::clone(&route_manager),
|
||||||
Arc::clone(&metrics),
|
Arc::clone(&metrics),
|
||||||
|
std::time::Duration::from_millis(conn_config.connection_timeout_ms),
|
||||||
));
|
));
|
||||||
let conn_config = ConnectionConfig::default();
|
|
||||||
let conn_tracker = Arc::new(ConnectionTracker::new(
|
let conn_tracker = Arc::new(ConnectionTracker::new(
|
||||||
conn_config.max_connections_per_ip,
|
conn_config.max_connections_per_ip,
|
||||||
conn_config.connection_rate_limit_per_minute,
|
conn_config.connection_rate_limit_per_minute,
|
||||||
@@ -118,7 +151,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,
|
||||||
@@ -129,11 +162,12 @@ impl TcpListenerManager {
|
|||||||
|
|
||||||
/// Create with a metrics collector.
|
/// Create with a metrics collector.
|
||||||
pub fn with_metrics(route_manager: Arc<RouteManager>, metrics: Arc<MetricsCollector>) -> Self {
|
pub fn with_metrics(route_manager: Arc<RouteManager>, metrics: Arc<MetricsCollector>) -> Self {
|
||||||
let http_proxy = Arc::new(HttpProxyService::new(
|
let conn_config = ConnectionConfig::default();
|
||||||
|
let http_proxy = Arc::new(HttpProxyService::with_connect_timeout(
|
||||||
Arc::clone(&route_manager),
|
Arc::clone(&route_manager),
|
||||||
Arc::clone(&metrics),
|
Arc::clone(&metrics),
|
||||||
|
std::time::Duration::from_millis(conn_config.connection_timeout_ms),
|
||||||
));
|
));
|
||||||
let conn_config = ConnectionConfig::default();
|
|
||||||
let conn_tracker = Arc::new(ConnectionTracker::new(
|
let conn_tracker = Arc::new(ConnectionTracker::new(
|
||||||
conn_config.max_connections_per_ip,
|
conn_config.max_connections_per_ip,
|
||||||
conn_config.connection_rate_limit_per_minute,
|
conn_config.connection_rate_limit_per_minute,
|
||||||
@@ -142,7 +176,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 +195,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 +319,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 +349,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 +400,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 +424,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();
|
||||||
@@ -400,6 +461,7 @@ impl TcpListenerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metrics.connection_opened(route_id);
|
metrics.connection_opened(route_id);
|
||||||
|
let _fast_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id);
|
||||||
|
|
||||||
let connect_timeout = std::time::Duration::from_millis(conn_config.connection_timeout_ms);
|
let connect_timeout = std::time::Duration::from_millis(conn_config.connection_timeout_ms);
|
||||||
let inactivity_timeout = std::time::Duration::from_millis(conn_config.socket_timeout_ms);
|
let inactivity_timeout = std::time::Duration::from_millis(conn_config.socket_timeout_ms);
|
||||||
@@ -415,14 +477,8 @@ impl TcpListenerManager {
|
|||||||
tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)),
|
tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)),
|
||||||
).await {
|
).await {
|
||||||
Ok(Ok(s)) => s,
|
Ok(Ok(s)) => s,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => return Err(e.into()),
|
||||||
metrics.connection_closed(route_id);
|
Err(_) => return Err("Backend connection timeout".into()),
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
metrics.connection_closed(route_id);
|
|
||||||
return Err("Backend connection timeout".into());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
backend.set_nodelay(true)?;
|
backend.set_nodelay(true)?;
|
||||||
|
|
||||||
@@ -453,7 +509,6 @@ impl TcpListenerManager {
|
|||||||
metrics.record_bytes(bytes_in, bytes_out, route_id);
|
metrics.record_bytes(bytes_in, bytes_out, route_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics.connection_closed(route_id);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,6 +617,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(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -579,8 +645,9 @@ impl TcpListenerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track connection in metrics
|
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
||||||
metrics.connection_opened(route_id);
|
metrics.connection_opened(route_id);
|
||||||
|
let _conn_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id);
|
||||||
|
|
||||||
// Check if this is a socket-handler route that should be relayed to TypeScript
|
// Check if this is a socket-handler route that should be relayed to TypeScript
|
||||||
if route_match.route.action.action_type == RouteActionType::SocketHandler {
|
if route_match.route.action.action_type == RouteActionType::SocketHandler {
|
||||||
@@ -590,16 +657,13 @@ impl TcpListenerManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(relay_socket_path) = relay_path {
|
if let Some(relay_socket_path) = relay_path {
|
||||||
let result = Self::relay_to_socket_handler(
|
return Self::relay_to_socket_handler(
|
||||||
stream, n, port, peer_addr,
|
stream, n, port, peer_addr,
|
||||||
&route_match, domain.as_deref(), is_tls,
|
&route_match, domain.as_deref(), is_tls,
|
||||||
&relay_socket_path,
|
&relay_socket_path,
|
||||||
).await;
|
).await;
|
||||||
metrics.connection_closed(route_id);
|
|
||||||
return result;
|
|
||||||
} else {
|
} else {
|
||||||
debug!("Socket-handler route matched but no relay path configured");
|
debug!("Socket-handler route matched but no relay path configured");
|
||||||
metrics.connection_closed(route_id);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +672,6 @@ impl TcpListenerManager {
|
|||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
debug!("Route matched but no target available");
|
debug!("Route matched but no target available");
|
||||||
metrics.connection_closed(route_id);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -727,7 +790,9 @@ impl TcpListenerManager {
|
|||||||
"TLS Terminate + HTTP: {} -> {}:{} (domain: {:?})",
|
"TLS Terminate + HTTP: {} -> {}:{} (domain: {:?})",
|
||||||
peer_addr, target_host, target_port, domain
|
peer_addr, target_host, target_port, domain
|
||||||
);
|
);
|
||||||
http_proxy.handle_io(buf_stream, peer_addr, port).await;
|
// HTTP proxy manages its own per-request metrics — disarm TCP-level guard
|
||||||
|
_conn_guard.disarm();
|
||||||
|
http_proxy.handle_io(buf_stream, peer_addr, port, cancel.clone()).await;
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
|
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
|
||||||
@@ -767,7 +832,9 @@ impl TcpListenerManager {
|
|||||||
if is_http {
|
if is_http {
|
||||||
// Plain HTTP - use HTTP proxy for request-level routing
|
// Plain HTTP - use HTTP proxy for request-level routing
|
||||||
debug!("HTTP proxy: {} on port {}", peer_addr, port);
|
debug!("HTTP proxy: {} on port {}", peer_addr, port);
|
||||||
http_proxy.handle_connection(stream, peer_addr, port).await;
|
// HTTP proxy manages its own per-request metrics — disarm TCP-level guard
|
||||||
|
_conn_guard.disarm();
|
||||||
|
http_proxy.handle_connection(stream, peer_addr, port, cancel.clone()).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// Plain TCP forwarding (non-HTTP)
|
// Plain TCP forwarding (non-HTTP)
|
||||||
@@ -805,7 +872,7 @@ impl TcpListenerManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
metrics.connection_closed(route_id);
|
// ConnectionGuard handles metrics.connection_closed() on drop
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
123
test/test.bun.ts
Normal 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
111
test/test.deno.ts
Normal 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();
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '23.0.0',
|
version: '25.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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export { SharedRouteManager as RouteManager } from './core/routing/route-manager
|
|||||||
|
|
||||||
// Export smart-proxy models
|
// Export smart-proxy models
|
||||||
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
|
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
|
||||||
export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js';
|
export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js';
|
||||||
export * from './proxies/smart-proxy/utils/index.js';
|
export * from './proxies/smart-proxy/utils/index.js';
|
||||||
|
|
||||||
// Original: export * from './smartproxy/classes.pp.snihandler.js'
|
// Original: export * from './smartproxy/classes.pp.snihandler.js'
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ import * as smartlog from '@push.rocks/smartlog';
|
|||||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
lik,
|
lik,
|
||||||
@@ -47,6 +48,7 @@ export {
|
|||||||
smartlogDestinationLocal,
|
smartlogDestinationLocal,
|
||||||
taskbuffer,
|
taskbuffer,
|
||||||
smartrx,
|
smartrx,
|
||||||
|
smartrust,
|
||||||
};
|
};
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import {
|
import {
|
||||||
TlsRecordType,
|
TlsRecordType,
|
||||||
TlsHandshakeType,
|
TlsHandshakeType,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './interfaces.js';
|
||||||
export * from './route-types.js';
|
export * from './route-types.js';
|
||||||
export * from './metrics-types.js';
|
export * from './metrics-types.js';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +34,38 @@ import type { IRouteConfig } from './route-types.js';
|
|||||||
*/
|
*/
|
||||||
export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
|
export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Communication channel passed as second argument to certProvisionFunction.
|
||||||
|
* Allows the callback to report metadata back to SmartProxy for event emission.
|
||||||
|
*/
|
||||||
|
export interface ICertProvisionEventComms {
|
||||||
|
/** Informational log */
|
||||||
|
log: (message: string) => void;
|
||||||
|
/** Warning (non-fatal) */
|
||||||
|
warn: (message: string) => void;
|
||||||
|
/** Error */
|
||||||
|
error: (message: string) => void;
|
||||||
|
/** Set the certificate expiry date (for the issued event) */
|
||||||
|
setExpiryDate: (date: Date) => void;
|
||||||
|
/** Set the source/method used for provisioning (e.g. 'smartacme-dns-01') */
|
||||||
|
setSource: (source: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for 'certificate-issued' and 'certificate-renewed' events */
|
||||||
|
export interface ICertificateIssuedEvent {
|
||||||
|
domain: string;
|
||||||
|
expiryDate?: string; // ISO 8601
|
||||||
|
source: string; // e.g. 'certProvisionFunction', 'smartacme-dns-01'
|
||||||
|
isRenewal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for 'certificate-failed' event */
|
||||||
|
export interface ICertificateFailedEvent {
|
||||||
|
domain: string;
|
||||||
|
error: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy options and type checking functions have been removed
|
// Legacy options and type checking functions have been removed
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,7 +172,7 @@ export interface ISmartProxyOptions {
|
|||||||
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
|
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
|
||||||
* or a static certificate object for immediate provisioning.
|
* or a static certificate object for immediate provisioning.
|
||||||
*/
|
*/
|
||||||
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
|
certProvisionFunction?: (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to fallback to ACME if custom certificate provision fails.
|
* Whether to fallback to ACME if custom certificate provision fails.
|
||||||
@@ -136,6 +180,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.
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { logger } from '../../core/utils/logger.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locates the RustProxy binary using a priority-ordered search strategy:
|
|
||||||
* 1. SMARTPROXY_RUST_BINARY environment variable
|
|
||||||
* 2. Platform-specific optional npm package
|
|
||||||
* 3. Local development build at ./rust/target/release/rustproxy
|
|
||||||
* 4. System PATH
|
|
||||||
*/
|
|
||||||
export class RustBinaryLocator {
|
|
||||||
private cachedPath: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the RustProxy binary path.
|
|
||||||
* Returns null if no binary is available.
|
|
||||||
*/
|
|
||||||
public async findBinary(): Promise<string | null> {
|
|
||||||
if (this.cachedPath !== null) {
|
|
||||||
return this.cachedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = await this.searchBinary();
|
|
||||||
this.cachedPath = path;
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the cached binary path (e.g., after a failed launch).
|
|
||||||
*/
|
|
||||||
public clearCache(): void {
|
|
||||||
this.cachedPath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async searchBinary(): Promise<string | null> {
|
|
||||||
// 1. Environment variable override
|
|
||||||
const envPath = process.env.SMARTPROXY_RUST_BINARY;
|
|
||||||
if (envPath) {
|
|
||||||
if (await this.isExecutable(envPath)) {
|
|
||||||
logger.log('info', `RustProxy binary found via SMARTPROXY_RUST_BINARY: ${envPath}`, { component: 'rust-locator' });
|
|
||||||
return envPath;
|
|
||||||
}
|
|
||||||
logger.log('warn', `SMARTPROXY_RUST_BINARY set but not executable: ${envPath}`, { component: 'rust-locator' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Platform-specific optional npm package
|
|
||||||
const platformBinary = await this.findPlatformPackageBinary();
|
|
||||||
if (platformBinary) {
|
|
||||||
logger.log('info', `RustProxy binary found in platform package: ${platformBinary}`, { component: 'rust-locator' });
|
|
||||||
return platformBinary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Local development build
|
|
||||||
const localPaths = [
|
|
||||||
plugins.path.resolve(process.cwd(), 'rust/target/release/rustproxy'),
|
|
||||||
plugins.path.resolve(process.cwd(), 'rust/target/debug/rustproxy'),
|
|
||||||
];
|
|
||||||
for (const localPath of localPaths) {
|
|
||||||
if (await this.isExecutable(localPath)) {
|
|
||||||
logger.log('info', `RustProxy binary found at local path: ${localPath}`, { component: 'rust-locator' });
|
|
||||||
return localPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. System PATH
|
|
||||||
const systemPath = await this.findInPath('rustproxy');
|
|
||||||
if (systemPath) {
|
|
||||||
logger.log('info', `RustProxy binary found in system PATH: ${systemPath}`, { component: 'rust-locator' });
|
|
||||||
return systemPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('error', 'No RustProxy binary found. Set SMARTPROXY_RUST_BINARY, install the platform package, or build with: cd rust && cargo build --release', { component: 'rust-locator' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findPlatformPackageBinary(): Promise<string | null> {
|
|
||||||
const platform = process.platform;
|
|
||||||
const arch = process.arch;
|
|
||||||
const packageName = `@push.rocks/smartproxy-${platform}-${arch}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to resolve the platform-specific package
|
|
||||||
const packagePath = require.resolve(`${packageName}/rustproxy`);
|
|
||||||
if (await this.isExecutable(packagePath)) {
|
|
||||||
return packagePath;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Package not installed - expected for development
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isExecutable(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findInPath(binaryName: string): Promise<string | null> {
|
|
||||||
const pathDirs = (process.env.PATH || '').split(plugins.path.delimiter);
|
|
||||||
for (const dir of pathDirs) {
|
|
||||||
const fullPath = plugins.path.join(dir, binaryName);
|
|
||||||
if (await this.isExecutable(fullPath)) {
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,310 +1,180 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from '../../core/utils/logger.js';
|
import { logger } from '../../core/utils/logger.js';
|
||||||
import { RustBinaryLocator } from './rust-binary-locator.js';
|
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type { IRouteConfig } from './models/route-types.js';
|
||||||
import { ChildProcess, spawn } from 'child_process';
|
|
||||||
import { createInterface, Interface as ReadlineInterface } from 'readline';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management request sent to the Rust binary via stdin.
|
* Type-safe command definitions for the Rust proxy IPC protocol.
|
||||||
*/
|
*/
|
||||||
interface IManagementRequest {
|
type TSmartProxyCommands = {
|
||||||
id: string;
|
start: { params: { config: any }; result: void };
|
||||||
method: string;
|
stop: { params: Record<string, never>; result: void };
|
||||||
params: Record<string, any>;
|
updateRoutes: { params: { routes: IRouteConfig[] }; result: void };
|
||||||
|
getMetrics: { params: Record<string, never>; result: any };
|
||||||
|
getStatistics: { params: Record<string, never>; result: any };
|
||||||
|
provisionCertificate: { params: { routeName: string }; result: void };
|
||||||
|
renewCertificate: { params: { routeName: string }; result: void };
|
||||||
|
getCertificateStatus: { params: { routeName: string }; result: any };
|
||||||
|
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
||||||
|
getNftablesStatus: { params: Record<string, never>; result: any };
|
||||||
|
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
|
addListeningPort: { params: { port: number }; result: void };
|
||||||
|
removeListeningPort: { params: { port: number }; result: void };
|
||||||
|
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the package root directory using import.meta.url.
|
||||||
|
* This file is at ts/proxies/smart-proxy/, so package root is 3 levels up.
|
||||||
|
*/
|
||||||
|
function getPackageRoot(): string {
|
||||||
|
const thisDir = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url));
|
||||||
|
return plugins.path.resolve(thisDir, '..', '..', '..');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management response received from the Rust binary via stdout.
|
* Map Node.js process.platform/process.arch to tsrust's friendly name suffix.
|
||||||
|
* tsrust names cross-compiled binaries as: rustproxy_linux_amd64, rustproxy_linux_arm64, etc.
|
||||||
*/
|
*/
|
||||||
interface IManagementResponse {
|
function getTsrustPlatformSuffix(): string | null {
|
||||||
id: string;
|
const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
|
||||||
success: boolean;
|
const osMap: Record<string, string> = { linux: 'linux', darwin: 'macos' };
|
||||||
result?: any;
|
const os = osMap[process.platform];
|
||||||
error?: string;
|
const arch = archMap[process.arch];
|
||||||
|
if (os && arch) {
|
||||||
|
return `${os}_${arch}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management event received from the Rust binary (unsolicited).
|
* Build local search paths for the Rust binary, including dist_rust/ candidates
|
||||||
|
* (built by tsrust) and local development build paths.
|
||||||
*/
|
*/
|
||||||
interface IManagementEvent {
|
function buildLocalPaths(): string[] {
|
||||||
event: string;
|
const packageRoot = getPackageRoot();
|
||||||
data: any;
|
const suffix = getTsrustPlatformSuffix();
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
// dist_rust/ candidates (tsrust cross-compiled output)
|
||||||
|
if (suffix) {
|
||||||
|
paths.push(plugins.path.join(packageRoot, 'dist_rust', `rustproxy_${suffix}`));
|
||||||
|
}
|
||||||
|
paths.push(plugins.path.join(packageRoot, 'dist_rust', 'rustproxy'));
|
||||||
|
|
||||||
|
// Local dev build paths
|
||||||
|
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'release', 'rustproxy'));
|
||||||
|
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'debug', 'rustproxy'));
|
||||||
|
|
||||||
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge between TypeScript SmartProxy and the Rust binary.
|
* Bridge between TypeScript SmartProxy and the Rust binary.
|
||||||
* Communicates via JSON-over-stdin/stdout IPC protocol.
|
* Wraps @push.rocks/smartrust's RustBridge with type-safe command definitions.
|
||||||
*/
|
*/
|
||||||
export class RustProxyBridge extends plugins.EventEmitter {
|
export class RustProxyBridge extends plugins.EventEmitter {
|
||||||
private locator = new RustBinaryLocator();
|
private bridge: plugins.smartrust.RustBridge<TSmartProxyCommands>;
|
||||||
private process: ChildProcess | null = null;
|
|
||||||
private readline: ReadlineInterface | null = null;
|
constructor() {
|
||||||
private pendingRequests = new Map<string, {
|
super();
|
||||||
resolve: (value: any) => void;
|
|
||||||
reject: (error: Error) => void;
|
this.bridge = new plugins.smartrust.RustBridge<TSmartProxyCommands>({
|
||||||
timer: NodeJS.Timeout;
|
binaryName: 'rustproxy',
|
||||||
}>();
|
envVarName: 'SMARTPROXY_RUST_BINARY',
|
||||||
private requestCounter = 0;
|
platformPackagePrefix: '@push.rocks/smartproxy',
|
||||||
private isRunning = false;
|
localPaths: buildLocalPaths(),
|
||||||
private binaryPath: string | null = null;
|
maxPayloadSize: 100 * 1024 * 1024, // 100 MB – route configs with many entries can be large
|
||||||
private readonly requestTimeoutMs = 30000;
|
logger: {
|
||||||
|
log: (level: string, message: string, data?: Record<string, any>) => {
|
||||||
|
logger.log(level as any, message, data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward events from the inner bridge
|
||||||
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||||
|
this.emit('exit', code, signal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn the Rust binary in management mode.
|
* Spawn the Rust binary in management mode.
|
||||||
* Returns true if the binary was found and spawned successfully.
|
* Returns true if the binary was found and spawned successfully.
|
||||||
*/
|
*/
|
||||||
public async spawn(): Promise<boolean> {
|
public async spawn(): Promise<boolean> {
|
||||||
this.binaryPath = await this.locator.findBinary();
|
return this.bridge.spawn();
|
||||||
if (!this.binaryPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
try {
|
|
||||||
this.process = spawn(this.binaryPath!, ['--management'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stderr (logging from Rust goes here)
|
|
||||||
const stderrHandler = (data: Buffer) => {
|
|
||||||
const lines = data.toString().split('\n').filter(l => l.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
logger.log('debug', `[rustproxy] ${line}`, { component: 'rust-bridge' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.process.stderr?.on('data', stderrHandler);
|
|
||||||
|
|
||||||
// Handle stdout (JSON IPC)
|
|
||||||
this.readline = createInterface({ input: this.process.stdout! });
|
|
||||||
this.readline.on('line', (line: string) => {
|
|
||||||
this.handleLine(line.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle process exit
|
|
||||||
this.process.on('exit', (code, signal) => {
|
|
||||||
logger.log('info', `RustProxy process exited (code=${code}, signal=${signal})`, { component: 'rust-bridge' });
|
|
||||||
this.cleanup();
|
|
||||||
this.emit('exit', code, signal);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process.on('error', (err) => {
|
|
||||||
logger.log('error', `RustProxy process error: ${err.message}`, { component: 'rust-bridge' });
|
|
||||||
this.cleanup();
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the 'ready' event from Rust
|
|
||||||
const readyTimeout = setTimeout(() => {
|
|
||||||
logger.log('error', 'RustProxy did not send ready event within 10s', { component: 'rust-bridge' });
|
|
||||||
this.kill();
|
|
||||||
resolve(false);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
this.once('management:ready', () => {
|
|
||||||
clearTimeout(readyTimeout);
|
|
||||||
this.isRunning = true;
|
|
||||||
logger.log('info', 'RustProxy bridge connected', { component: 'rust-bridge' });
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.log('error', `Failed to spawn RustProxy: ${err.message}`, { component: 'rust-bridge' });
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a management command to the Rust process and wait for the response.
|
* Kill the Rust process and clean up.
|
||||||
*/
|
|
||||||
public async sendCommand(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
||||||
if (!this.process || !this.isRunning) {
|
|
||||||
throw new Error('RustProxy bridge is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = `req_${++this.requestCounter}`;
|
|
||||||
const request: IManagementRequest = { id, method, params };
|
|
||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pendingRequests.delete(id);
|
|
||||||
reject(new Error(`RustProxy command '${method}' timed out after ${this.requestTimeoutMs}ms`));
|
|
||||||
}, this.requestTimeoutMs);
|
|
||||||
|
|
||||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
||||||
|
|
||||||
const json = JSON.stringify(request) + '\n';
|
|
||||||
this.process!.stdin!.write(json, (err) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.pendingRequests.delete(id);
|
|
||||||
reject(new Error(`Failed to write to RustProxy stdin: ${err.message}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for each management command
|
|
||||||
|
|
||||||
public async startProxy(config: any): Promise<void> {
|
|
||||||
await this.sendCommand('start', { config });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stopProxy(): Promise<void> {
|
|
||||||
await this.sendCommand('stop');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
|
||||||
await this.sendCommand('updateRoutes', { routes });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getMetrics(): Promise<any> {
|
|
||||||
return this.sendCommand('getMetrics');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getStatistics(): Promise<any> {
|
|
||||||
return this.sendCommand('getStatistics');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async provisionCertificate(routeName: string): Promise<void> {
|
|
||||||
await this.sendCommand('provisionCertificate', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async renewCertificate(routeName: string): Promise<void> {
|
|
||||||
await this.sendCommand('renewCertificate', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCertificateStatus(routeName: string): Promise<any> {
|
|
||||||
return this.sendCommand('getCertificateStatus', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getListeningPorts(): Promise<number[]> {
|
|
||||||
const result = await this.sendCommand('getListeningPorts');
|
|
||||||
return result?.ports ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getNftablesStatus(): Promise<any> {
|
|
||||||
return this.sendCommand('getNftablesStatus');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
|
||||||
await this.sendCommand('setSocketHandlerRelay', { socketPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addListeningPort(port: number): Promise<void> {
|
|
||||||
await this.sendCommand('addListeningPort', { port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeListeningPort(port: number): Promise<void> {
|
|
||||||
await this.sendCommand('removeListeningPort', { port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
|
||||||
await this.sendCommand('loadCertificate', { domain, cert, key, ca });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill the Rust process and clean up all stdio streams.
|
|
||||||
*/
|
*/
|
||||||
public kill(): void {
|
public kill(): void {
|
||||||
if (this.process) {
|
this.bridge.kill();
|
||||||
const proc = this.process;
|
|
||||||
this.process = null;
|
|
||||||
this.isRunning = false;
|
|
||||||
|
|
||||||
// Close readline (reads from stdout)
|
|
||||||
if (this.readline) {
|
|
||||||
this.readline.close();
|
|
||||||
this.readline = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject pending requests
|
|
||||||
for (const [, pending] of this.pendingRequests) {
|
|
||||||
clearTimeout(pending.timer);
|
|
||||||
pending.reject(new Error('RustProxy process killed'));
|
|
||||||
}
|
|
||||||
this.pendingRequests.clear();
|
|
||||||
|
|
||||||
// Remove all listeners so nothing keeps references
|
|
||||||
proc.removeAllListeners();
|
|
||||||
proc.stdout?.removeAllListeners();
|
|
||||||
proc.stderr?.removeAllListeners();
|
|
||||||
proc.stdin?.removeAllListeners();
|
|
||||||
|
|
||||||
// Kill the process
|
|
||||||
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
||||||
|
|
||||||
// Destroy all stdio pipes to free handles
|
|
||||||
try { proc.stdin?.destroy(); } catch { /* ignore */ }
|
|
||||||
try { proc.stdout?.destroy(); } catch { /* ignore */ }
|
|
||||||
try { proc.stderr?.destroy(); } catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Unref process so Node doesn't wait for it
|
|
||||||
try { proc.unref(); } catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Force kill after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
||||||
}, 5000).unref();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the bridge is currently running.
|
* Whether the bridge is currently running.
|
||||||
*/
|
*/
|
||||||
public get running(): boolean {
|
public get running(): boolean {
|
||||||
return this.isRunning;
|
return this.bridge.running;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLine(line: string): void {
|
// --- Convenience methods for each management command ---
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: any;
|
public async startProxy(config: any): Promise<void> {
|
||||||
try {
|
await this.bridge.sendCommand('start', { config });
|
||||||
parsed = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
logger.log('warn', `Non-JSON output from RustProxy: ${line}`, { component: 'rust-bridge' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an event (has 'event' field)
|
|
||||||
if ('event' in parsed) {
|
|
||||||
const event = parsed as IManagementEvent;
|
|
||||||
this.emit(`management:${event.event}`, event.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise it's a response (has 'id' field)
|
|
||||||
if ('id' in parsed) {
|
|
||||||
const response = parsed as IManagementResponse;
|
|
||||||
const pending = this.pendingRequests.get(response.id);
|
|
||||||
if (pending) {
|
|
||||||
clearTimeout(pending.timer);
|
|
||||||
this.pendingRequests.delete(response.id);
|
|
||||||
if (response.success) {
|
|
||||||
pending.resolve(response.result);
|
|
||||||
} else {
|
|
||||||
pending.reject(new Error(response.error || 'Unknown error from RustProxy'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup(): void {
|
public async stopProxy(): Promise<void> {
|
||||||
this.isRunning = false;
|
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
||||||
this.process = null;
|
}
|
||||||
|
|
||||||
if (this.readline) {
|
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
||||||
this.readline.close();
|
await this.bridge.sendCommand('updateRoutes', { routes });
|
||||||
this.readline = null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Reject all pending requests
|
public async getMetrics(): Promise<any> {
|
||||||
for (const [id, pending] of this.pendingRequests) {
|
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
|
||||||
clearTimeout(pending.timer);
|
}
|
||||||
pending.reject(new Error('RustProxy process exited'));
|
|
||||||
}
|
public async getStatistics(): Promise<any> {
|
||||||
this.pendingRequests.clear();
|
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async provisionCertificate(routeName: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('provisionCertificate', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async renewCertificate(routeName: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('renewCertificate', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCertificateStatus(routeName: string): Promise<any> {
|
||||||
|
return this.bridge.sendCommand('getCertificateStatus', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getListeningPorts(): Promise<number[]> {
|
||||||
|
const result = await this.bridge.sendCommand('getListeningPorts', {} as Record<string, never>);
|
||||||
|
return result?.ports ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNftablesStatus(): Promise<any> {
|
||||||
|
return this.bridge.sendCommand('getNftablesStatus', {} as Record<string, never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addListeningPort(port: number): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('addListeningPort', { port });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeListeningPort(port: number): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('removeListeningPort', { port });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('loadCertificate', { domain, cert, key, ca });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { logger } from '../../core/utils/logger.js';
|
|||||||
|
|
||||||
// Rust bridge and helpers
|
// Rust bridge and helpers
|
||||||
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||||
import { RustBinaryLocator } from './rust-binary-locator.js';
|
|
||||||
import { RoutePreprocessor } from './route-preprocessor.js';
|
import { RoutePreprocessor } from './route-preprocessor.js';
|
||||||
import { SocketHandlerServer } from './socket-handler-server.js';
|
import { SocketHandlerServer } from './socket-handler-server.js';
|
||||||
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
||||||
@@ -11,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, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } 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';
|
||||||
|
|
||||||
@@ -69,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 || [],
|
||||||
@@ -120,7 +119,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
if (!spawned) {
|
if (!spawned) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
||||||
'or build locally with: cd rust && cargo build --release'
|
'or build locally with: pnpm build'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,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);
|
||||||
@@ -158,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();
|
||||||
@@ -335,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,
|
||||||
@@ -371,24 +404,49 @@ 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) {
|
for (const domain of certDomains) {
|
||||||
if (domain.includes('*')) continue;
|
if (provisionedDomains.has(domain)) continue;
|
||||||
|
provisionedDomains.add(domain);
|
||||||
|
|
||||||
|
// Build eventComms channel for this domain
|
||||||
|
let expiryDate: string | undefined;
|
||||||
|
let source = 'certProvisionFunction';
|
||||||
|
|
||||||
|
const eventComms: ICertProvisionEventComms = {
|
||||||
|
log: (msg) => logger.log('info', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
||||||
|
warn: (msg) => logger.log('warn', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
||||||
|
error: (msg) => logger.log('error', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
||||||
|
setExpiryDate: (date) => { expiryDate = date.toISOString(); },
|
||||||
|
setSource: (s) => { source = s; },
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: TSmartProxyCertProvisionObject = await provisionFn(domain);
|
const result: TSmartProxyCertProvisionObject = await provisionFn(domain, eventComms);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,19 +459,87 @@ 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit certificate-issued event
|
||||||
|
this.emit('certificate-issued', {
|
||||||
|
domain,
|
||||||
|
expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
|
||||||
|
source,
|
||||||
|
} satisfies ICertificateIssuedEvent);
|
||||||
}
|
}
|
||||||
} 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
|
// Emit certificate-failed event
|
||||||
if (this.settings.certProvisionFallbackToAcme !== false) {
|
this.emit('certificate-failed', {
|
||||||
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' });
|
domain,
|
||||||
|
error: err.message,
|
||||||
|
source,
|
||||||
|
} satisfies ICertificateFailedEvent);
|
||||||
|
|
||||||
|
// Fallback to ACME if enabled and route has a name
|
||||||
|
if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
|
||||||
|
try {
|
||||||
|
await this.bridge.provisionCertificate(route.name);
|
||||||
|
logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
|
||||||
|
} catch (acmeErr: any) {
|
||||||
|
logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
|
||||||
|
(this.settings.disableDefaultCert
|
||||||
|
? ' — TLS will fail for this domain (disableDefaultCert is true)'
|
||||||
|
: ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|||||||
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as plugins from '../../../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a self-signed fallback certificate (CN=SmartProxy Default Certificate, SAN=*).
|
||||||
|
* Used as the '*' wildcard fallback so TLS handshakes never reset due to missing certs.
|
||||||
|
*/
|
||||||
|
export function generateDefaultCertificate(): { cert: string; key: string } {
|
||||||
|
const forge = plugins.smartcrypto.nodeForge;
|
||||||
|
|
||||||
|
// Generate 2048-bit RSA keypair
|
||||||
|
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
||||||
|
|
||||||
|
// Create self-signed X.509 certificate
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keypair.publicKey;
|
||||||
|
cert.serialNumber = '01';
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||||
|
|
||||||
|
const attrs = [{ name: 'commonName', value: 'SmartProxy Default Certificate' }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
|
||||||
|
// Add wildcard SAN
|
||||||
|
cert.setExtensions([
|
||||||
|
{ name: 'subjectAltName', altNames: [{ type: 2 /* DNS */, value: '*' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
cert.sign(keypair.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
return {
|
||||||
|
cert: forge.pki.certificateToPem(cert),
|
||||||
|
key: forge.pki.privateKeyToPem(keypair.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export * from './route-validator.js';
|
|||||||
// Export route utilities for route operations
|
// Export 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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import {
|
import {
|
||||||
TlsRecordType,
|
TlsRecordType,
|
||||||
TlsHandshakeType,
|
TlsHandshakeType,
|
||||||
|
|||||||
Reference in New Issue
Block a user