Compare commits

..

18 Commits

Author SHA1 Message Date
07e464fdac v24.0.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 16:32:02 +00:00
0e058594c9 BREAKING CHANGE(smart-proxy): move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling 2026-02-13 16:32:02 +00:00
e0af82c1ef v23.1.6
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 13:08:30 +00:00
efe3d80713 fix(smart-proxy): disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow 2026-02-13 13:08:30 +00:00
6b04bc612b v23.1.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-13 12:02:47 +00:00
e774ec87ca fix(smart-proxy): provision certificates for wildcard domains instead of skipping them 2026-02-13 12:02:47 +00:00
cbde778f09 v23.1.4
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 22:35:25 +00:00
bc2bc874a5 fix(tests): make tests more robust and bump small dependencies 2026-02-12 22:35:25 +00:00
fdabf807b0 v23.1.3
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 20:17:32 +00:00
81e0e6b4d8 fix(rustproxy): install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches 2026-02-12 20:17:32 +00:00
28fa69bf59 v23.1.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 13:48:30 +00:00
5019658032 fix(core): use node: scoped builtin imports and add route unit tests 2026-02-11 13:48:30 +00:00
a9fe365c78 v23.1.1
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 12:52:45 +00:00
32e0410227 fix(rust-proxy): increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies 2026-02-11 12:52:45 +00:00
fd56064495 v23.1.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-10 09:43:40 +00:00
3b7e6a6ed7 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 2026-02-10 09:43:40 +00:00
131ed8949a v23.0.0
Some checks failed
Default (tags) / security (push) Successful in 52s
Default (tags) / test (push) Failing after 48s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-09 17:11:37 +00:00
7b3009dc53 BREAKING CHANGE(proxies/nftables-proxy): remove nftables-proxy implementation, models, and utilities from the repository 2026-02-09 17:11:37 +00:00
51 changed files with 9952 additions and 4362 deletions

View File

@@ -1,5 +1,83 @@
# Changelog
## 2026-02-13 - 24.0.0 - BREAKING CHANGE(smart-proxy)
move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling
- Cert persistence removed from Rust side: CertStore is now an in-memory cache (no filesystem reads/writes). Rust no longer persists or loads certs from disk.
- ACME account credentials are no longer persisted by the library; AcmeClient uses ephemeral accounts only and account persistence APIs were removed.
- TypeScript API changes: removed certificateStore option and added ISmartProxyCertStore + certStore option for consumer-provided persistence (loadAll, save, optional remove).
- Default self-signed fallback certificate added (generateDefaultCertificate) and loaded as '*' unless disableDefaultCert is set.
- SmartProxy now pre-loads certificates from consumer certStore on startup and persists certificates by calling certStore.save() after provisioning.
- provisionCertificatesViaCallback signature changed to accept preloaded domains (prevents re-provisioning), and ACME fallback behavior adjusted with clearer logging.
- Rust cert manager methods made infallible for cache-only operations (load_static/store no longer return errors for cache insertions); removed store-backed load_all/remove/base_dir APIs.
- TCP listener tls_configs concurrency improved: switched to ArcSwap<HashMap<...>> so accept loops see hot-reloads immediately.
- Removed dependencies related to filesystem cert persistence from the tls crate (serde_json, tempfile) and corresponding Cargo.lock changes and test updates.
## 2026-02-13 - 23.1.6 - fix(smart-proxy)
disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow
- Pass an optional ACME override into buildRustConfig so Rust ACME can be disabled per-run
- Disable Rust ACME when certProvisionFunction is configured to avoid provisioning race conditions
- Normalize routing glob patterns into concrete domain identifiers for certificate provisioning (expand leading-star globs and warn on unsupported patterns)
- Deduplicate domains during provisioning to avoid repeated attempts
- When the callback returns 'http01', explicitly trigger Rust ACME for the route via bridge.provisionCertificate and log success/failure
## 2026-02-13 - 23.1.5 - fix(smart-proxy)
provision certificates for wildcard domains instead of skipping them
- Removed early continue that skipped domains containing '*' in the domain loop
- Now calls provisionFn for wildcard domains so certificate provisioning can proceed for wildcard hosts
- Fixes cases where wildcard domains never had certificates requested
## 2026-02-12 - 23.1.4 - fix(tests)
make tests more robust and bump small dependencies
- Bump dependencies: @push.rocks/smartrust ^1.2.1 and minimatch ^10.2.0
- Replace hardcoded ports with named constants (ECHO_PORT, PROXY_PORT, PROXY_PORT_1/2) to avoid collisions between tests
- Add server 'error' handlers and reject listen promises on server errors to prevent silent hangs
- Reduce test timeouts and intervals (shorter test durations, more frequent pings) to speed up test runs
- Ensure proxy is stopped between tests and remove forced process.exit; export tap.start() consistently
- Adjust assertions to match the new shorter ping/response counts
## 2026-02-12 - 23.1.3 - fix(rustproxy)
install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches
- Install ring-based rustls crypto provider at startup to prevent panics from instant-acme/hyper-rustls calling ClientConfig::builder() before TLS listeners are initialized
- Add a non-blocking 10ms peek to detect HTTP traffic in the TCP passthrough fast-path to avoid misrouting HTTP and ensure HTTP proxy handles CORS, errors, and request-level routing
- Skip the fast-path and fall back to the HTTP proxy when HTTP is detected (with a debug log)
- When no route matches for detected HTTP connections, send an HTTP 502 Bad Gateway response and close the connection instead of silently dropping it
## 2026-02-11 - 23.1.2 - fix(core)
use node: scoped builtin imports and add route unit tests
- Replaced bare Node built-in imports (events, fs, http, https, net, path, tls, url, http2, buffer, crypto) with 'node:' specifiers for ESM/bundler compatibility (files updated include ts/plugins.ts, ts/core/models/socket-types.ts, ts/core/utils/enhanced-connection-pool.ts, ts/core/utils/socket-tracker.ts, ts/protocols/common/fragment-handler.ts, ts/protocols/tls/sni/client-hello-parser.ts, ts/protocols/tls/sni/sni-extraction.ts, ts/protocols/websocket/utils.ts, ts/tls/sni/sni-handler.ts).
- Added new unit tests (test/test.bun.ts and test/test.deno.ts) covering route helpers, validators, matching, merging and cloning to improve test coverage.
## 2026-02-11 - 23.1.1 - fix(rust-proxy)
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)
remove nftables-proxy implementation, models, and utilities from the repository
- Deleted nftables-proxy module files under ts/proxies/nftables-proxy (index, models, utils, command executor, validators, etc.)
- Removed nftables-proxy exports from ts/index.ts and ts/proxies/index.ts
- Updated smart-proxy types to drop dependency on nftables proxy models
- Breaking change: any consumers importing nftables-proxy will no longer find those exports; update imports or install/use the extracted/alternative package if applicable
## 2026-02-09 - 22.6.0 - feat(smart-proxy)
add socket-handler relay, fast-path port-only forwarding, metrics and bridge improvements, and various TS/Rust integration fixes

7324
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -40,5 +40,8 @@
},
"@ship.zone/szci": {
"npmGlobalTools": []
},
"@git.zone/tsrust": {
"targets": ["linux_amd64", "linux_arm64"]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "22.6.0",
"version": "24.0.0",
"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.",
"main": "dist_ts/index.js",
@@ -10,16 +10,17 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
"build": "(tsbuild tsfolders --allowimplicitany)",
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
"format": "(gitzone format)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@push.rocks/smartserve": "^1.4.0",
"@types/node": "^24.10.2",
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tsrust": "^1.3.0",
"@git.zone/tstest": "^3.1.8",
"@push.rocks/smartserve": "^2.0.1",
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"why-is-node-running": "^3.2.2"
},
@@ -28,20 +29,21 @@
"@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartcrypto": "^2.0.4",
"@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/smartnetwork": "^4.4.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrust": "^1.2.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/taskbuffer": "^3.5.0",
"@push.rocks/taskbuffer": "^4.2.0",
"@tsclass/tsclass": "^9.3.0",
"@types/minimatch": "^6.0.0",
"@types/ws": "^8.18.1",
"minimatch": "^10.1.1",
"minimatch": "^10.2.0",
"pretty-ms": "^9.3.0",
"ws": "^8.18.3"
"ws": "^8.19.0"
},
"files": [
"ts/**/*",

2716
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

160
readme.md
View File

@@ -36,6 +36,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a
| 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data |
| 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts |
| 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information |
| 💾 **Consumer Cert Storage** | Bring your own persistence — SmartProxy never writes certs to disk |
## 🚀 Quick Start
@@ -378,7 +379,9 @@ await proxy.updateRoutes([...newRoutes]);
// Get real-time metrics
const metrics = proxy.getMetrics();
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
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
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
- **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
- **Socket Relay** — a Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
- **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
## 🎯 Route Configuration Reference
@@ -495,7 +543,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
```typescript
interface IRouteMatch {
ports: number | number[] | Array<{ from: number; to: number }>; // Port(s) to listen on
ports: number | number[] | Array<{ from: number; to: number }>; // Required — port(s) to listen on
domains?: string | string[]; // 'example.com', '*.example.com'
path?: string; // '/api/*', '/users/:id'
clientIp?: string[]; // ['10.0.0.0/8', '192.168.*']
@@ -515,11 +563,16 @@ interface IRouteMatch {
```typescript
interface IRouteTarget {
host: string | string[] | ((context: IRouteContext) => string);
host: string | string[] | ((context: IRouteContext) => string | string[]);
port: number | 'preserve' | ((context: IRouteContext) => number);
tls?: { ... }; // Per-target TLS override
priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method)
tls?: IRouteTls; // Per-target TLS override
priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method)
websocket?: IRouteWebSocket;
loadBalancing?: IRouteLoadBalancing;
sendProxyProtocol?: boolean;
headers?: IRouteHeaders;
advanced?: IRouteAdvanced;
}
```
@@ -611,6 +664,7 @@ import {
createPortMappingRoute, // Port mapping with context
createOffsetPortMappingRoute, // Simple port offset
createDynamicRoute, // Dynamic host/port via functions
createPortOffset, // Port offset factory
// Security Modifiers
addRateLimiting, // Add rate limiting to any route
@@ -678,7 +732,6 @@ interface ISmartProxyOptions {
port?: number; // HTTP-01 challenge port (default: 80)
renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable auto-renewal (default: true)
certificateStore?: string; // Directory to store certs (default: './certs')
renewCheckIntervalHours?: number; // Renewal check interval (default: 24)
};
@@ -686,6 +739,12 @@ interface ISmartProxyOptions {
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
// Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage")
certStore?: ISmartProxyCertStore;
// Self-signed fallback
disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false)
// Global defaults
defaults?: {
target?: { host: string; port: number };
@@ -699,7 +758,7 @@ interface ISmartProxyOptions {
// Timeouts
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)
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
@@ -724,36 +783,62 @@ interface ISmartProxyOptions {
// Behavior
enableDetailedLogging?: boolean; // Verbose connection logging
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
// Rust binary
rustBinaryPath?: string; // Custom path to the Rust binary
}
```
### NfTablesProxy Class
A standalone class for managing nftables NAT rules directly (Linux only, requires root):
### ISmartProxyCertStore Interface
```typescript
import { NfTablesProxy } from '@push.rocks/smartproxy';
interface ISmartProxyCertStore {
/** Called once on startup to pre-load persisted certs */
loadAll: () => Promise<Array<{
domain: string;
publicKey: string;
privateKey: string;
ca?: string;
}>>;
const nftProxy = new NfTablesProxy({
fromPort: [80, 443],
toPort: [8080, 8443],
toHost: 'backend-server',
protocol: 'tcp',
preserveSourceIP: true,
ipv6Support: true,
useIPSets: true,
qos: {
enabled: true,
maxRate: '1gbps'
}
});
/** Called after each successful cert provision */
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
await nftProxy.start(); // Apply nftables rules
const status = await nftProxy.getStatus();
await nftProxy.stop(); // Remove rules
/** 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
@@ -772,13 +857,13 @@ await nftProxy.stop(); // Remove rules
- ✅ Enable debug logging with `enableDetailedLogging: true`
### Rust Binary Not Found
SmartProxy searches for the Rust binary in this order:
1. `SMARTPROXY_RUST_BINARY` environment variable
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
3. Local dev build (`./rust/target/release/rustproxy`)
4. System PATH (`rustproxy`)
Set `rustBinaryPath` in options to override.
3. `dist_rust/rustproxy` relative to the package root (built by `tsrust`)
4. Local dev build (`./rust/target/release/rustproxy`)
5. System PATH (`rustproxy`)
### Performance Tuning
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
@@ -798,6 +883,7 @@ Set `rustBinaryPath` in options to override.
7. **✅ Validate Routes** — Use `RouteValidator.validateRoutes()` to catch config errors before deployment
8. **🔀 Atomic Updates** — Use `updateRoutes()` for hot-reloading routes (mutex-locked, no downtime)
9. **🎮 Use Socket Handlers** — For protocols beyond HTTP, implement custom socket handlers instead of fighting the proxy model
10. **💾 Use `certStore`** — Persist certs in your own storage to avoid re-provisioning on every restart
## License and Legal Information

2
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

40
rust/Cargo.lock generated
View File

@@ -285,12 +285,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -618,12 +612,6 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -866,19 +854,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
@@ -1084,8 +1059,6 @@ dependencies = [
"rustls",
"rustproxy-config",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -1260,19 +1233,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"

View File

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

View File

@@ -88,8 +88,8 @@ pub struct TcpListenerManager {
route_manager: Arc<ArcSwap<RouteManager>>,
/// Shared metrics collector
metrics: Arc<MetricsCollector>,
/// TLS acceptors indexed by domain
tls_configs: Arc<HashMap<String, TlsCertConfig>>,
/// TLS acceptors indexed by domain (ArcSwap for hot-reload visibility in accept loops)
tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
/// HTTP proxy service for HTTP-level forwarding
http_proxy: Arc<HttpProxyService>,
/// Connection configuration
@@ -118,7 +118,7 @@ impl TcpListenerManager {
listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics,
tls_configs: Arc::new(HashMap::new()),
tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
http_proxy,
conn_config: Arc::new(conn_config),
conn_tracker,
@@ -142,7 +142,7 @@ impl TcpListenerManager {
listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics,
tls_configs: Arc::new(HashMap::new()),
tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
http_proxy,
conn_config: Arc::new(conn_config),
conn_tracker,
@@ -161,8 +161,9 @@ impl TcpListenerManager {
}
/// Set TLS certificate configurations.
pub fn set_tls_configs(&mut self, configs: HashMap<String, TlsCertConfig>) {
self.tls_configs = Arc::new(configs);
/// Uses ArcSwap so running accept loops immediately see the new certs.
pub fn set_tls_configs(&self, configs: HashMap<String, TlsCertConfig>) {
self.tls_configs.store(Arc::new(configs));
}
/// Set the shared socket-handler relay path.
@@ -284,7 +285,7 @@ impl TcpListenerManager {
port: u16,
route_manager_swap: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
tls_configs: Arc<HashMap<String, TlsCertConfig>>,
tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
http_proxy: Arc<HttpProxyService>,
conn_config: Arc<ConnectionConfig>,
conn_tracker: Arc<ConnectionTracker>,
@@ -314,7 +315,8 @@ impl TcpListenerManager {
// Load the latest route manager from ArcSwap on each connection
let rm = route_manager_swap.load_full();
let m = Arc::clone(&metrics);
let tc = Arc::clone(&tls_configs);
// Load the latest TLS configs from ArcSwap on each connection
let tc = tls_configs.load_full();
let hp = Arc::clone(&http_proxy);
let cc = Arc::clone(&conn_config);
let ct = Arc::clone(&conn_tracker);
@@ -364,6 +366,10 @@ impl TcpListenerManager {
// 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,
// 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 {
port,
@@ -384,7 +390,28 @@ impl TcpListenerManager {
// 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 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_port = target.port.resolve(port);
let route_id = quick_match.route.id.as_deref();
@@ -562,6 +589,17 @@ impl TcpListenerManager {
Some(rm) => rm,
None => {
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(());
}
};

View File

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

View File

@@ -1,16 +1,15 @@
//! ACME (Let's Encrypt) integration using instant-acme.
//!
//! This module handles HTTP-01 challenge creation and certificate provisioning.
//! Supports persisting ACME account credentials to disk for reuse across restarts.
//! Account credentials are ephemeral — the consumer owns all persistence.
use std::path::{Path, PathBuf};
use instant_acme::{
Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus,
AccountCredentials,
};
use rcgen::{CertificateParams, KeyPair};
use thiserror::Error;
use tracing::{debug, info, warn};
use tracing::{debug, info};
#[derive(Debug, Error)]
pub enum AcmeError {
@@ -26,8 +25,6 @@ pub enum AcmeError {
NoHttp01Challenge,
#[error("Timeout waiting for order: {0}")]
Timeout(String),
#[error("Account persistence error: {0}")]
Persistence(String),
}
/// Pending HTTP-01 challenge that needs to be served.
@@ -41,8 +38,6 @@ pub struct PendingChallenge {
pub struct AcmeClient {
use_production: bool,
email: String,
/// Optional directory where account.json is persisted.
account_dir: Option<PathBuf>,
}
impl AcmeClient {
@@ -50,56 +45,15 @@ impl AcmeClient {
Self {
use_production,
email,
account_dir: None,
}
}
/// Create a new client with account persistence at the given directory.
pub fn with_persistence(email: String, use_production: bool, account_dir: impl AsRef<Path>) -> Self {
Self {
use_production,
email,
account_dir: Some(account_dir.as_ref().to_path_buf()),
}
}
/// Get or create an ACME account, persisting credentials if account_dir is set.
/// Create a new ACME account (ephemeral — not persisted).
async fn get_or_create_account(&self) -> Result<Account, AcmeError> {
let directory_url = self.directory_url();
// Try to restore from persisted credentials
if let Some(ref dir) = self.account_dir {
let account_file = dir.join("account.json");
if account_file.exists() {
match std::fs::read_to_string(&account_file) {
Ok(json) => {
match serde_json::from_str::<AccountCredentials>(&json) {
Ok(credentials) => {
match Account::from_credentials(credentials).await {
Ok(account) => {
debug!("Restored ACME account from {}", account_file.display());
return Ok(account);
}
Err(e) => {
warn!("Failed to restore ACME account, creating new: {}", e);
}
}
}
Err(e) => {
warn!("Invalid account.json, creating new account: {}", e);
}
}
}
Err(e) => {
warn!("Could not read account.json: {}", e);
}
}
}
}
// Create a new account
let contact = format!("mailto:{}", self.email);
let (account, credentials) = Account::create(
let (account, _credentials) = Account::create(
&NewAccount {
contact: &[&contact],
terms_of_service_agreed: true,
@@ -113,27 +67,6 @@ impl AcmeClient {
debug!("ACME account created");
// Persist credentials if we have a directory
if let Some(ref dir) = self.account_dir {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create account directory {}: {}", dir.display(), e);
} else {
let account_file = dir.join("account.json");
match serde_json::to_string_pretty(&credentials) {
Ok(json) => {
if let Err(e) = std::fs::write(&account_file, &json) {
warn!("Failed to persist ACME account to {}: {}", account_file.display(), e);
} else {
info!("ACME account credentials persisted to {}", account_file.display());
}
}
Err(e) => {
warn!("Failed to serialize account credentials: {}", e);
}
}
}
}
Ok(account)
}
@@ -158,7 +91,7 @@ impl AcmeClient {
{
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url());
// 1. Get or create ACME account (with persistence)
// 1. Get or create ACME account
let account = self.get_or_create_account().await?;
// 2. Create order
@@ -339,22 +272,4 @@ mod tests {
assert!(!client.directory_url().contains("staging"));
assert!(client.is_production());
}
#[test]
fn test_with_persistence_sets_account_dir() {
let tmp = tempfile::tempdir().unwrap();
let client = AcmeClient::with_persistence(
"test@example.com".to_string(),
false,
tmp.path(),
);
assert!(client.account_dir.is_some());
assert_eq!(client.account_dir.unwrap(), tmp.path());
}
#[test]
fn test_without_persistence_no_account_dir() {
let client = AcmeClient::new("test@example.com".to_string(), false);
assert!(client.account_dir.is_none());
}
}

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,11 @@ struct Cli {
#[tokio::main]
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();
// Initialize tracing - write to stderr so stdout is reserved for management IPC

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

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

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

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

View File

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

View File

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

View File

@@ -5,19 +5,27 @@ import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
let echoServer: net.Server;
let 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) => {
// Set a timeout for this test
tools.timeout(10000); // 10 seconds
// Create an echo server
echoServer = await new Promise<net.Server>((resolve) => {
echoServer = await new Promise<net.Server>((resolve, reject) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
socket.write(`ECHO: ${data}`);
});
});
server.listen(8888, () => {
console.log('Echo server listening on port 8888');
server.on('error', (err) => {
console.error(`Echo server error: ${err.message}`);
reject(err);
});
server.listen(ECHO_PORT, () => {
console.log(`Echo server listening on port ${ECHO_PORT}`);
resolve(server);
});
});
@@ -26,10 +34,10 @@ tap.test('port forwarding should not immediately close connections', async (tool
proxy = new SmartProxy({
routes: [{
name: 'test-forward',
match: { ports: 9999 },
match: { ports: PROXY_PORT_1 },
action: {
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();
// 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) => {
client.on('data', (data) => {
const response = data.toString();
client.end(); // Close the connection after receiving data
resolve(response);
});
client.on('error', reject);
client.write('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 () => {
@@ -59,7 +70,7 @@ tap.test('TLS passthrough should work correctly', async () => {
proxy = new SmartProxy({
routes: [{
name: 'tls-test',
match: { ports: 8443, domains: 'test.example.com' },
match: { ports: PROXY_PORT_2, domains: 'test.example.com' },
action: {
type: 'forward',
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(() => {
// Force exit after tests complete
setTimeout(() => {
console.log('Forcing process exit');
process.exit(0);
}, 1000);
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '22.6.0',
version: '24.0.0',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,6 @@
* SmartProxy main module exports
*/
// NFTables proxy exports
export * from './proxies/nftables-proxy/index.js';
// Export SmartProxy elements
export { SmartProxy } from './proxies/smart-proxy/index.js';
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';

View File

@@ -1,13 +1,13 @@
// node native scope
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as path from 'path';
import * as tls from 'tls';
import * as url from 'url';
import * as http2 from 'http2';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import * as path from 'node:path';
import * as tls from 'node:tls';
import * as url from 'node:url';
import * as http2 from 'node: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 taskbuffer from '@push.rocks/taskbuffer';
import * as smartrx from '@push.rocks/smartrx';
import * as smartrust from '@push.rocks/smartrust';
export {
lik,
@@ -47,6 +48,7 @@ export {
smartlogDestinationLocal,
taskbuffer,
smartrx,
smartrust,
};
// third party scope

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,3 @@ export { SharedRouteManager as SmartProxyRouteManager } from '../core/routing/ro
export * from './smart-proxy/utils/index.js';
// Export smart-proxy models except IAcmeOptions
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './smart-proxy/models/index.js';
// Export NFTables proxy (no conflicts)
export * from './nftables-proxy/index.js';

View File

@@ -1,6 +0,0 @@
/**
* NfTablesProxy implementation
*/
export * from './nftables-proxy.js';
export * from './models/index.js';
export * from './utils/index.js';

View File

@@ -1,30 +0,0 @@
/**
* Custom error classes for better error handling
*/
export class NftBaseError extends Error {
constructor(message: string) {
super(message);
this.name = 'NftBaseError';
}
}
export class NftValidationError extends NftBaseError {
constructor(message: string) {
super(message);
this.name = 'NftValidationError';
}
}
export class NftExecutionError extends NftBaseError {
constructor(message: string) {
super(message);
this.name = 'NftExecutionError';
}
}
export class NftResourceError extends NftBaseError {
constructor(message: string) {
super(message);
this.name = 'NftResourceError';
}
}

View File

@@ -1,5 +0,0 @@
/**
* Export all models
*/
export * from './interfaces.js';
export * from './errors.js';

View File

@@ -1,94 +0,0 @@
/**
* Interfaces for NfTablesProxy
*/
/**
* Represents a port range for forwarding
*/
export interface PortRange {
from: number;
to: number;
}
// Legacy interface name for backward compatibility
export type IPortRange = PortRange;
/**
* Settings for NfTablesProxy.
*/
export interface NfTableProxyOptions {
// Basic settings
fromPort: number | PortRange | Array<number | PortRange>; // Support single port, port range, or multiple ports/ranges
toPort: number | PortRange | Array<number | PortRange>;
toHost?: string; // Target host for proxying; defaults to 'localhost'
// Advanced settings
preserveSourceIP?: boolean; // If true, the original source IP is preserved
deleteOnExit?: boolean; // If true, clean up rules before process exit
protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp'
enableLogging?: boolean; // Enable detailed logging
ipv6Support?: boolean; // Enable IPv6 support
logFormat?: 'plain' | 'json'; // Format for logs
// Source filtering
ipAllowList?: string[]; // If provided, only these IPs are allowed
ipBlockList?: string[]; // If provided, these IPs are blocked
useIPSets?: boolean; // Use nftables sets for efficient IP management
// Rule management
forceCleanSlate?: boolean; // Clear all NfTablesProxy rules before starting
tableName?: string; // Custom table name (defaults to 'portproxy')
// Connection management
maxRetries?: number; // Maximum number of retries for failed commands
retryDelayMs?: number; // Delay between retries in milliseconds
useAdvancedNAT?: boolean; // Use connection tracking for stateful NAT
// Quality of Service
qos?: {
enabled: boolean;
maxRate?: string; // e.g. "10mbps"
priority?: number; // 1 (highest) to 10 (lowest)
markConnections?: boolean; // Mark connections for easier management
};
// Integration with PortProxy/NetworkProxy
netProxyIntegration?: {
enabled: boolean;
redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy
sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination
};
}
// Legacy interface name for backward compatibility
export type INfTableProxySettings = NfTableProxyOptions;
/**
* Interface for status reporting
*/
export interface NfTablesStatus {
active: boolean;
ruleCount: {
total: number;
added: number;
verified: number;
};
tablesConfigured: { family: string; tableName: string }[];
metrics: {
forwardedConnections?: number;
activeConnections?: number;
bytesForwarded?: {
sent: number;
received: number;
};
};
qosEnabled?: boolean;
ipSetsConfigured?: {
name: string;
elementCount: number;
type: string;
}[];
}
// Legacy interface name for backward compatibility
export type INfTablesStatus = NfTablesStatus;

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
/**
* NFTables Proxy Utilities
*
* This module exports utility functions and classes for NFTables operations.
*/
// Command execution
export { NftCommandExecutor } from './nft-command-executor.js';
export type { INftLoggerFn, INftExecutorOptions } from './nft-command-executor.js';
// Port specification normalization
export {
normalizePortSpec,
validatePorts,
formatPortRange,
portSpecToNftExpr,
rangesOverlap,
mergeOverlappingRanges,
countPorts,
isPortInSpec
} from './nft-port-spec-normalizer.js';
// Rule validation
export {
isValidIP,
isValidIPv4,
isValidIPv6,
isValidHostname,
isValidTableName,
isValidRate,
validateIPs,
validateHost,
validateTableName,
validateQosSettings,
validateSettings,
isIPForFamily,
filterIPsByFamily
} from './nft-rule-validator.js';

View File

@@ -1,162 +0,0 @@
/**
* NFTables Command Executor
*
* Handles command execution with retry logic, temp file management,
* and error handling for nftables operations.
*/
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import { delay } from '../../../core/utils/async-utils.js';
import { AsyncFileSystem } from '../../../core/utils/fs-utils.js';
import { NftExecutionError } from '../models/index.js';
const execAsync = promisify(exec);
export interface INftLoggerFn {
(level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: Record<string, any>): void;
}
export interface INftExecutorOptions {
maxRetries?: number;
retryDelayMs?: number;
tempFilePath?: string;
}
/**
* NFTables command executor with retry logic and temp file support
*/
export class NftCommandExecutor {
private static readonly NFT_CMD = 'nft';
private maxRetries: number;
private retryDelayMs: number;
private tempFilePath: string;
constructor(
private log: INftLoggerFn,
options: INftExecutorOptions = {}
) {
this.maxRetries = options.maxRetries || 3;
this.retryDelayMs = options.retryDelayMs || 1000;
this.tempFilePath = options.tempFilePath || `/tmp/nft-rules-${Date.now()}.nft`;
}
/**
* Execute a command with retry capability
*/
async executeWithRetry(command: string, maxRetries?: number, retryDelayMs?: number): Promise<string> {
const retries = maxRetries ?? this.maxRetries;
const delayMs = retryDelayMs ?? this.retryDelayMs;
let lastError: Error | undefined;
for (let i = 0; i < retries; i++) {
try {
const { stdout } = await execAsync(command);
return stdout;
} catch (err) {
lastError = err as Error;
this.log('warn', `Command failed (attempt ${i+1}/${retries}): ${command}`, { error: lastError.message });
// Wait before retry, unless it's the last attempt
if (i < retries - 1) {
await delay(delayMs);
}
}
}
throw new NftExecutionError(`Failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`);
}
/**
* Execute system command synchronously (single attempt, no retry)
* Used only for exit handlers where the process is terminating anyway.
*/
executeSync(command: string): string {
try {
return execSync(command, { timeout: 5000 }).toString();
} catch (err) {
this.log('warn', `Sync command failed: ${command}`, { error: (err as Error).message });
throw err;
}
}
/**
* Execute nftables commands with a temporary file
*/
async executeWithTempFile(rulesetContent: string): Promise<void> {
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
try {
await this.executeWithRetry(
`${NftCommandExecutor.NFT_CMD} -f ${this.tempFilePath}`,
this.maxRetries,
this.retryDelayMs
);
} finally {
// Always clean up the temp file
await AsyncFileSystem.remove(this.tempFilePath);
}
}
/**
* Check if nftables is available
*/
async checkAvailability(): Promise<boolean> {
try {
await this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} --version`, this.maxRetries, this.retryDelayMs);
return true;
} catch (err) {
this.log('error', `nftables is not available: ${(err as Error).message}`);
return false;
}
}
/**
* Check if connection tracking modules are loaded
*/
async checkConntrackModules(): Promise<boolean> {
try {
await this.executeWithRetry('lsmod | grep nf_conntrack', this.maxRetries, this.retryDelayMs);
return true;
} catch (err) {
this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work');
return false;
}
}
/**
* Run an nft command directly
*/
async nft(args: string): Promise<string> {
return this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} ${args}`, this.maxRetries, this.retryDelayMs);
}
/**
* Run an nft command synchronously (for cleanup on exit)
*/
nftSync(args: string): string {
return this.executeSync(`${NftCommandExecutor.NFT_CMD} ${args}`);
}
/**
* Get the NFT command path
*/
static get nftCmd(): string {
return NftCommandExecutor.NFT_CMD;
}
/**
* Update the temp file path
*/
setTempFilePath(path: string): void {
this.tempFilePath = path;
}
/**
* Update retry settings
*/
setRetryOptions(maxRetries: number, retryDelayMs: number): void {
this.maxRetries = maxRetries;
this.retryDelayMs = retryDelayMs;
}
}

View File

@@ -1,125 +0,0 @@
/**
* NFTables Port Specification Normalizer
*
* Handles normalization and validation of port specifications
* for nftables rules.
*/
import type { PortRange } from '../models/index.js';
import { NftValidationError } from '../models/index.js';
/**
* Normalizes port specifications into an array of port ranges
*/
export function normalizePortSpec(portSpec: number | PortRange | Array<number | PortRange>): PortRange[] {
const result: PortRange[] = [];
if (Array.isArray(portSpec)) {
// If it's an array, process each element
for (const spec of portSpec) {
result.push(...normalizePortSpec(spec));
}
} else if (typeof portSpec === 'number') {
// Single port becomes a range with the same start and end
result.push({ from: portSpec, to: portSpec });
} else {
// Already a range
result.push(portSpec);
}
return result;
}
/**
* Validates port numbers or ranges
*/
export function validatePorts(port: number | PortRange | Array<number | PortRange>): void {
if (Array.isArray(port)) {
port.forEach(p => validatePorts(p));
return;
}
if (typeof port === 'number') {
if (port < 1 || port > 65535) {
throw new NftValidationError(`Invalid port number: ${port}`);
}
} else if (typeof port === 'object') {
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`);
}
}
}
/**
* Format port range for nftables rule
*/
export function formatPortRange(range: PortRange): string {
if (range.from === range.to) {
return String(range.from);
}
return `${range.from}-${range.to}`;
}
/**
* Convert port spec to nftables expression
*/
export function portSpecToNftExpr(portSpec: number | PortRange | Array<number | PortRange>): string {
const ranges = normalizePortSpec(portSpec);
if (ranges.length === 1) {
return formatPortRange(ranges[0]);
}
// Multiple ports/ranges need to use a set
const ports = ranges.map(formatPortRange);
return `{ ${ports.join(', ')} }`;
}
/**
* Check if two port ranges overlap
*/
export function rangesOverlap(range1: PortRange, range2: PortRange): boolean {
return range1.from <= range2.to && range2.from <= range1.to;
}
/**
* Merge overlapping port ranges
*/
export function mergeOverlappingRanges(ranges: PortRange[]): PortRange[] {
if (ranges.length <= 1) return ranges;
// Sort by start port
const sorted = [...ranges].sort((a, b) => a.from - b.from);
const merged: PortRange[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const lastMerged = merged[merged.length - 1];
if (current.from <= lastMerged.to + 1) {
// Ranges overlap or are adjacent, merge them
lastMerged.to = Math.max(lastMerged.to, current.to);
} else {
// No overlap, add as new range
merged.push(current);
}
}
return merged;
}
/**
* Calculate the total number of ports in a port specification
*/
export function countPorts(portSpec: number | PortRange | Array<number | PortRange>): number {
const ranges = normalizePortSpec(portSpec);
return ranges.reduce((total, range) => total + (range.to - range.from + 1), 0);
}
/**
* Check if a port is within the given specification
*/
export function isPortInSpec(port: number, portSpec: number | PortRange | Array<number | PortRange>): boolean {
const ranges = normalizePortSpec(portSpec);
return ranges.some(range => port >= range.from && port <= range.to);
}

View File

@@ -1,156 +0,0 @@
/**
* NFTables Rule Validator
*
* Handles validation of settings and inputs for nftables operations.
* Prevents command injection and ensures valid values.
*/
import type { PortRange, NfTableProxyOptions } from '../models/index.js';
import { NftValidationError } from '../models/index.js';
import { validatePorts } from './nft-port-spec-normalizer.js';
// IP address validation patterns
const IPV4_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
const IPV6_REGEX = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
const HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
const TABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
const RATE_REGEX = /^[0-9]+[kKmMgG]?bps$/;
/**
* Validates an IP address (IPv4 or IPv6)
*/
export function isValidIP(ip: string): boolean {
return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip);
}
/**
* Validates an IPv4 address
*/
export function isValidIPv4(ip: string): boolean {
return IPV4_REGEX.test(ip);
}
/**
* Validates an IPv6 address
*/
export function isValidIPv6(ip: string): boolean {
return IPV6_REGEX.test(ip);
}
/**
* Validates a hostname
*/
export function isValidHostname(hostname: string): boolean {
return HOSTNAME_REGEX.test(hostname);
}
/**
* Validates a table name for nftables
*/
export function isValidTableName(tableName: string): boolean {
return TABLE_NAME_REGEX.test(tableName);
}
/**
* Validates a rate specification (e.g., "10mbps")
*/
export function isValidRate(rate: string): boolean {
return RATE_REGEX.test(rate);
}
/**
* Validates an array of IP addresses
*/
export function validateIPs(ips?: string[]): void {
if (!ips) return;
for (const ip of ips) {
if (!isValidIP(ip)) {
throw new NftValidationError(`Invalid IP address format: ${ip}`);
}
}
}
/**
* Validates a host (can be hostname or IP)
*/
export function validateHost(host?: string): void {
if (!host) return;
if (!isValidHostname(host) && !isValidIP(host)) {
throw new NftValidationError(`Invalid host format: ${host}`);
}
}
/**
* Validates a table name
*/
export function validateTableName(tableName?: string): void {
if (!tableName) return;
if (!isValidTableName(tableName)) {
throw new NftValidationError(
`Invalid table name: ${tableName}. Only alphanumeric characters and underscores are allowed.`
);
}
}
/**
* Validates QoS settings
*/
export function validateQosSettings(qos?: NfTableProxyOptions['qos']): void {
if (!qos?.enabled) return;
if (qos.maxRate && !isValidRate(qos.maxRate)) {
throw new NftValidationError(
`Invalid rate format: ${qos.maxRate}. Use format like "10mbps", "1gbps", etc.`
);
}
if (qos.priority !== undefined) {
if (qos.priority < 1 || qos.priority > 10 || !Number.isInteger(qos.priority)) {
throw new NftValidationError(
`Invalid priority: ${qos.priority}. Must be an integer between 1 and 10.`
);
}
}
}
/**
* Validates all NfTablesProxy settings
*/
export function validateSettings(settings: NfTableProxyOptions): void {
// Validate port numbers
validatePorts(settings.fromPort);
validatePorts(settings.toPort);
// Validate IP addresses
validateIPs(settings.ipAllowList);
validateIPs(settings.ipBlockList);
// Validate target host
validateHost(settings.toHost);
// Validate table name
validateTableName(settings.tableName);
// Validate QoS settings
validateQosSettings(settings.qos);
}
/**
* Check if an IP matches the given family (ip or ip6)
*/
export function isIPForFamily(ip: string, family: 'ip' | 'ip6'): boolean {
if (family === 'ip6') {
return ip.includes(':');
}
return ip.includes('.');
}
/**
* Filter IPs by family
*/
export function filterIPsByFamily(ips: string[], family: 'ip' | 'ip6'): string[] {
return ips.filter(ip => isIPForFamily(ip, family));
}

View File

@@ -2,6 +2,6 @@
* SmartProxy models
*/
// Export everything except IAcmeOptions from interfaces
export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js';
export type { ISmartProxyOptions, ISmartProxyCertStore, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js';
export * from './route-types.js';
export * from './metrics-types.js';

View File

@@ -10,11 +10,23 @@ export interface IAcmeOptions {
useProduction?: boolean; // Use Let's Encrypt production (default: false)
renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable automatic renewal (default: true)
certificateStore?: string; // Directory to store certificates (default: './certs')
skipConfiguredCerts?: boolean;
renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
routeForwards?: any[];
}
/**
* Consumer-provided certificate storage.
* SmartProxy never writes certs to disk — the consumer owns all persistence.
*/
export interface ISmartProxyCertStore {
/** Load all stored certs on startup (called once before cert provisioning) */
loadAll: () => Promise<Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }>>;
/** Save a cert after successful provisioning */
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
/** Remove a cert (optional) */
remove?: (domain: string) => Promise<void>;
}
import type { IRouteConfig } from './route-types.js';
/**
@@ -136,6 +148,20 @@ export interface ISmartProxyOptions {
*/
certProvisionFallbackToAcme?: boolean;
/**
* Disable the default self-signed fallback certificate.
* When false (default), a self-signed cert is generated at startup and loaded
* as '*' so TLS handshakes never fail due to missing certs.
*/
disableDefaultCert?: boolean;
/**
* Consumer-provided cert storage. SmartProxy never writes certs to disk.
* On startup, loadAll() is called to pre-load persisted certs.
* After each successful cert provision, save() is called.
*/
certStore?: ISmartProxyCertStore;
/**
* Path to the RustProxy binary. If not set, the binary is located
* automatically via env var, platform package, local build, or PATH.

View File

@@ -1,6 +1,4 @@
import * as plugins from '../../../plugins.js';
// Certificate types removed - use local definition
import type { PortRange } from '../../../proxies/nftables-proxy/models/interfaces.js';
import type { IRouteContext } from '../../../core/models/route-context.js';
// Re-export IRouteContext for convenience

View File

@@ -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;
}
}

View File

@@ -1,310 +1,180 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';
import { RustBinaryLocator } from './rust-binary-locator.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 {
id: string;
method: string;
params: Record<string, any>;
type TSmartProxyCommands = {
start: { params: { config: any }; result: void };
stop: { params: Record<string, never>; result: void };
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 {
id: string;
success: boolean;
result?: any;
error?: string;
function getTsrustPlatformSuffix(): string | null {
const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
const osMap: Record<string, string> = { linux: 'linux', darwin: 'macos' };
const os = osMap[process.platform];
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 {
event: string;
data: any;
function buildLocalPaths(): string[] {
const packageRoot = getPackageRoot();
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.
* 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 {
private locator = new RustBinaryLocator();
private process: ChildProcess | null = null;
private readline: ReadlineInterface | null = null;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}>();
private requestCounter = 0;
private isRunning = false;
private binaryPath: string | null = null;
private readonly requestTimeoutMs = 30000;
private bridge: plugins.smartrust.RustBridge<TSmartProxyCommands>;
constructor() {
super();
this.bridge = new plugins.smartrust.RustBridge<TSmartProxyCommands>({
binaryName: 'rustproxy',
envVarName: 'SMARTPROXY_RUST_BINARY',
platformPackagePrefix: '@push.rocks/smartproxy',
localPaths: buildLocalPaths(),
maxPayloadSize: 100 * 1024 * 1024, // 100 MB route configs with many entries can be large
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.
* Returns true if the binary was found and spawned successfully.
*/
public async spawn(): Promise<boolean> {
this.binaryPath = await this.locator.findBinary();
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);
}
});
return this.bridge.spawn();
}
/**
* Send a management command to the Rust process and wait for the response.
*/
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.
* Kill the Rust process and clean up.
*/
public kill(): void {
if (this.process) {
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();
}
this.bridge.kill();
}
/**
* Whether the bridge is currently running.
*/
public get running(): boolean {
return this.isRunning;
return this.bridge.running;
}
private handleLine(line: string): void {
if (!line) return;
// --- Convenience methods for each management command ---
let parsed: any;
try {
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'));
}
}
}
public async startProxy(config: any): Promise<void> {
await this.bridge.sendCommand('start', { config });
}
private cleanup(): void {
this.isRunning = false;
this.process = null;
public async stopProxy(): Promise<void> {
await this.bridge.sendCommand('stop', {} as Record<string, never>);
}
if (this.readline) {
this.readline.close();
this.readline = null;
}
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
await this.bridge.sendCommand('updateRoutes', { routes });
}
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('RustProxy process exited'));
}
this.pendingRequests.clear();
public async getMetrics(): Promise<any> {
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
}
public async getStatistics(): Promise<any> {
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 });
}
}

View File

@@ -3,7 +3,6 @@ import { logger } from '../../core/utils/logger.js';
// Rust bridge and helpers
import { RustProxyBridge } from './rust-proxy-bridge.js';
import { RustBinaryLocator } from './rust-binary-locator.js';
import { RoutePreprocessor } from './route-preprocessor.js';
import { SocketHandlerServer } from './socket-handler-server.js';
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
@@ -11,10 +10,11 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js';
// Types
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject } from './models/interfaces.js';
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js';
@@ -69,7 +69,6 @@ export class SmartProxy extends plugins.EventEmitter {
useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false,
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [],
@@ -120,7 +119,7 @@ export class SmartProxy extends plugins.EventEmitter {
if (!spawned) {
throw new Error(
'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)
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
const config = this.buildRustConfig(rustRoutes);
const config = this.buildRustConfig(rustRoutes, acmeForRust);
// Start the Rust proxy
await this.bridge.startProxy(config);
@@ -158,8 +165,34 @@ export class SmartProxy extends plugins.EventEmitter {
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
}
// Load default self-signed fallback certificate (domain: '*')
if (!this.settings.disableDefaultCert) {
try {
const defaultCert = generateDefaultCertificate();
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
} catch (err: any) {
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
}
}
// Load consumer-stored certificates
const preloadedDomains = new Set<string>();
if (this.settings.certStore) {
try {
const stored = await this.settings.certStore.loadAll();
for (const entry of stored) {
await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
preloadedDomains.add(entry.domain);
}
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
} catch (err: any) {
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
}
}
// Handle certProvisionFunction
await this.provisionCertificatesViaCallback();
await this.provisionCertificatesViaCallback(preloadedDomains);
// Start metrics polling
this.metricsAdapter.startPolling();
@@ -335,20 +368,20 @@ export class SmartProxy extends plugins.EventEmitter {
/**
* 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 {
routes,
defaults: this.settings.defaults,
acme: this.settings.acme
acme: acme
? {
enabled: this.settings.acme.enabled,
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction,
port: this.settings.acme.port,
renewThresholdDays: this.settings.acme.renewThresholdDays,
autoRenew: this.settings.acme.autoRenew,
certificateStore: this.settings.acme.certificateStore,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours,
enabled: acme.enabled,
email: acme.email,
useProduction: acme.useProduction,
port: acme.port,
renewThresholdDays: acme.renewThresholdDays,
autoRenew: acme.autoRenew,
renewCheckIntervalHours: acme.renewCheckIntervalHours,
}
: undefined,
connectionTimeout: this.settings.connectionTimeout,
@@ -371,24 +404,36 @@ export class SmartProxy extends plugins.EventEmitter {
* If the callback returns a cert object, load it into Rust.
* If it returns 'http01', let Rust handle ACME.
*/
private async provisionCertificatesViaCallback(): Promise<void> {
private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
const provisionFn = this.settings.certProvisionFunction;
if (!provisionFn) return;
const provisionedDomains = new Set<string>(skipDomains);
for (const route of this.settings.routes) {
if (route.action.tls?.certificate !== 'auto') continue;
if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
for (const domain of domains) {
if (domain.includes('*')) continue;
const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);
for (const domain of certDomains) {
if (provisionedDomains.has(domain)) continue;
provisionedDomains.add(domain);
try {
const result: TSmartProxyCertProvisionObject = await provisionFn(domain);
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;
}
@@ -401,19 +446,73 @@ export class SmartProxy extends plugins.EventEmitter {
certObj.privateKey,
);
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
// Persist to consumer store
if (this.settings.certStore?.save) {
try {
await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey);
} catch (storeErr: any) {
logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
}
}
}
} catch (err: any) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
// Fallback to ACME if enabled
if (this.settings.certProvisionFallbackToAcme !== false) {
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' });
// Fallback to ACME if enabled and route has a name
if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
try {
await this.bridge.provisionCertificate(route.name);
logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
} catch (acmeErr: any) {
logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
(this.settings.disableDefaultCert
? ' — TLS will fail for this domain (disableDefaultCert is true)'
: ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
}
}
}
}
}
}
/**
* 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 {
if (!domain || domain.length === 0) return false;
if (domain.includes('*')) return false;

View File

@@ -0,0 +1,36 @@
import * as plugins from '../../../plugins.js';
/**
* Generate a self-signed fallback certificate (CN=SmartProxy Default Certificate, SAN=*).
* Used as the '*' wildcard fallback so TLS handshakes never reset due to missing certs.
*/
export function generateDefaultCertificate(): { cert: string; key: string } {
const forge = plugins.smartcrypto.nodeForge;
// Generate 2048-bit RSA keypair
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
// Create self-signed X.509 certificate
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [{ name: 'commonName', value: 'SmartProxy Default Certificate' }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// Add wildcard SAN
cert.setExtensions([
{ name: 'subjectAltName', altNames: [{ type: 2 /* DNS */, value: '*' }] },
]);
cert.sign(keypair.privateKey, forge.md.sha256.create());
return {
cert: forge.pki.certificateToPem(cert),
key: forge.pki.privateKeyToPem(keypair.privateKey),
};
}

View File

@@ -14,6 +14,9 @@ export * from './route-validator.js';
// Export route utilities for route operations
export * from './route-utils.js';
// Export default certificate generator
export { generateDefaultCertificate } from './default-cert-generator.js';
// Export additional functions from route-helpers that weren't already exported
export {
createApiGatewayRoute,

View File

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