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
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 23.1.0 - feat(rust-bridge)
|
||||||
|
integrate tsrust to build and locate cross-compiled Rust binaries; refactor rust-proxy bridge to use typed IPC and streamline process handling; add @push.rocks/smartrust and update build/dev dependencies
|
||||||
|
|
||||||
|
- Add tsrust to the build script and include dist_rust candidates when locating the Rust binary (enables cross-compiled artifacts produced by tsrust).
|
||||||
|
- Remove the old rust-binary-locator and refactor rust-proxy-bridge to use explicit, typed IPC command definitions and improved process spawn/cleanup logic.
|
||||||
|
- Introduce @push.rocks/smartrust for type-safe JSON IPC and export it via plugins; update README with expanded metrics documentation and change initialDataTimeout default from 60s to 120s.
|
||||||
|
- Add rust/.cargo/config.toml with aarch64 linker configuration to support cross-compilation for arm64.
|
||||||
|
- Bump several devDependencies and runtime dependencies (e.g. @git.zone/tsbuild, @git.zone/tstest, @push.rocks/smartserve, @push.rocks/taskbuffer, ws, minimatch, etc.).
|
||||||
|
- Update runtime message guiding local builds to use 'pnpm build' (tsrust) instead of direct cargo invocation.
|
||||||
|
|
||||||
## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy)
|
## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy)
|
||||||
remove nftables-proxy implementation, models, and utilities from the repository
|
remove nftables-proxy implementation, models, and utilities from the repository
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
},
|
},
|
||||||
"@ship.zone/szci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": []
|
"npmGlobalTools": []
|
||||||
|
},
|
||||||
|
"@git.zone/tsrust": {
|
||||||
|
"targets": ["linux_amd64", "linux_arm64"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
package.json
22
package.json
@@ -10,16 +10,17 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
||||||
"format": "(gitzone format)",
|
"format": "(gitzone format)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tstest": "^3.1.3",
|
"@git.zone/tsrust": "^1.3.0",
|
||||||
"@push.rocks/smartserve": "^1.4.0",
|
"@git.zone/tstest": "^3.1.8",
|
||||||
"@types/node": "^24.10.2",
|
"@push.rocks/smartserve": "^2.0.1",
|
||||||
|
"@types/node": "^25.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"why-is-node-running": "^3.2.2"
|
"why-is-node-running": "^3.2.2"
|
||||||
},
|
},
|
||||||
@@ -28,20 +29,21 @@
|
|||||||
"@push.rocks/smartacme": "^8.0.0",
|
"@push.rocks/smartacme": "^8.0.0",
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartfile": "^13.1.0",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
|
"@push.rocks/smartrust": "^1.1.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstring": "^4.1.0",
|
"@push.rocks/smartstring": "^4.1.0",
|
||||||
"@push.rocks/taskbuffer": "^3.5.0",
|
"@push.rocks/taskbuffer": "^4.2.0",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
"@types/minimatch": "^6.0.0",
|
"@types/minimatch": "^6.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.2",
|
||||||
"pretty-ms": "^9.3.0",
|
"pretty-ms": "^9.3.0",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
|||||||
2702
pnpm-lock.yaml
generated
2702
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
52
readme.md
52
readme.md
@@ -378,7 +378,9 @@ await proxy.updateRoutes([...newRoutes]);
|
|||||||
// Get real-time metrics
|
// Get real-time metrics
|
||||||
const metrics = proxy.getMetrics();
|
const metrics = proxy.getMetrics();
|
||||||
console.log(`Active connections: ${metrics.connections.active()}`);
|
console.log(`Active connections: ${metrics.connections.active()}`);
|
||||||
console.log(`Requests/sec: ${metrics.throughput.requestsPerSecond()}`);
|
console.log(`Bytes in: ${metrics.totals.bytesIn()}`);
|
||||||
|
console.log(`Requests/sec: ${metrics.requests.perSecond()}`);
|
||||||
|
console.log(`Throughput in: ${metrics.throughput.instant().in} bytes/sec`);
|
||||||
|
|
||||||
// Get detailed statistics from the Rust engine
|
// Get detailed statistics from the Rust engine
|
||||||
const stats = await proxy.getStatistics();
|
const stats = await proxy.getStatistics();
|
||||||
@@ -486,8 +488,8 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
|
|||||||
|
|
||||||
- **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics
|
- **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics
|
||||||
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks
|
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks
|
||||||
- **IPC** — JSON commands/events over stdin/stdout for seamless cross-language communication
|
- **IPC** — The TypeScript wrapper uses [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) for type-safe JSON commands/events over stdin/stdout
|
||||||
- **Socket Relay** — a Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
|
- **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
|
||||||
|
|
||||||
## 🎯 Route Configuration Reference
|
## 🎯 Route Configuration Reference
|
||||||
|
|
||||||
@@ -699,7 +701,7 @@ interface ISmartProxyOptions {
|
|||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
||||||
initialDataTimeout?: number; // Initial data/SNI timeout (default: 60s)
|
initialDataTimeout?: number; // Initial data/SNI timeout (default: 120s)
|
||||||
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
||||||
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
||||||
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
||||||
@@ -724,12 +726,40 @@ interface ISmartProxyOptions {
|
|||||||
// Behavior
|
// Behavior
|
||||||
enableDetailedLogging?: boolean; // Verbose connection logging
|
enableDetailedLogging?: boolean; // Verbose connection logging
|
||||||
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
|
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
|
||||||
|
|
||||||
// Rust binary
|
|
||||||
rustBinaryPath?: string; // Custom path to the Rust binary
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Certificate Issues
|
### Certificate Issues
|
||||||
@@ -746,13 +776,13 @@ interface ISmartProxyOptions {
|
|||||||
- ✅ Enable debug logging with `enableDetailedLogging: true`
|
- ✅ Enable debug logging with `enableDetailedLogging: true`
|
||||||
|
|
||||||
### Rust Binary Not Found
|
### Rust Binary Not Found
|
||||||
|
|
||||||
SmartProxy searches for the Rust binary in this order:
|
SmartProxy searches for the Rust binary in this order:
|
||||||
1. `SMARTPROXY_RUST_BINARY` environment variable
|
1. `SMARTPROXY_RUST_BINARY` environment variable
|
||||||
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
|
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
|
||||||
3. Local dev build (`./rust/target/release/rustproxy`)
|
3. `dist_rust/rustproxy` relative to the package root (built by `tsrust`)
|
||||||
4. System PATH (`rustproxy`)
|
4. Local dev build (`./rust/target/release/rustproxy`)
|
||||||
|
5. System PATH (`rustproxy`)
|
||||||
Set `rustBinaryPath` in options to override.
|
|
||||||
|
|
||||||
### Performance Tuning
|
### Performance Tuning
|
||||||
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
|
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
|
||||||
|
|||||||
2
rust/.cargo/config.toml
Normal file
2
rust/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '23.0.0',
|
version: '23.1.0',
|
||||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import * as smartlog from '@push.rocks/smartlog';
|
|||||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
lik,
|
lik,
|
||||||
@@ -47,6 +48,7 @@ export {
|
|||||||
smartlogDestinationLocal,
|
smartlogDestinationLocal,
|
||||||
taskbuffer,
|
taskbuffer,
|
||||||
smartrx,
|
smartrx,
|
||||||
|
smartrust,
|
||||||
};
|
};
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { logger } from '../../core/utils/logger.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locates the RustProxy binary using a priority-ordered search strategy:
|
|
||||||
* 1. SMARTPROXY_RUST_BINARY environment variable
|
|
||||||
* 2. Platform-specific optional npm package
|
|
||||||
* 3. Local development build at ./rust/target/release/rustproxy
|
|
||||||
* 4. System PATH
|
|
||||||
*/
|
|
||||||
export class RustBinaryLocator {
|
|
||||||
private cachedPath: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the RustProxy binary path.
|
|
||||||
* Returns null if no binary is available.
|
|
||||||
*/
|
|
||||||
public async findBinary(): Promise<string | null> {
|
|
||||||
if (this.cachedPath !== null) {
|
|
||||||
return this.cachedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = await this.searchBinary();
|
|
||||||
this.cachedPath = path;
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the cached binary path (e.g., after a failed launch).
|
|
||||||
*/
|
|
||||||
public clearCache(): void {
|
|
||||||
this.cachedPath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async searchBinary(): Promise<string | null> {
|
|
||||||
// 1. Environment variable override
|
|
||||||
const envPath = process.env.SMARTPROXY_RUST_BINARY;
|
|
||||||
if (envPath) {
|
|
||||||
if (await this.isExecutable(envPath)) {
|
|
||||||
logger.log('info', `RustProxy binary found via SMARTPROXY_RUST_BINARY: ${envPath}`, { component: 'rust-locator' });
|
|
||||||
return envPath;
|
|
||||||
}
|
|
||||||
logger.log('warn', `SMARTPROXY_RUST_BINARY set but not executable: ${envPath}`, { component: 'rust-locator' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Platform-specific optional npm package
|
|
||||||
const platformBinary = await this.findPlatformPackageBinary();
|
|
||||||
if (platformBinary) {
|
|
||||||
logger.log('info', `RustProxy binary found in platform package: ${platformBinary}`, { component: 'rust-locator' });
|
|
||||||
return platformBinary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Local development build
|
|
||||||
const localPaths = [
|
|
||||||
plugins.path.resolve(process.cwd(), 'rust/target/release/rustproxy'),
|
|
||||||
plugins.path.resolve(process.cwd(), 'rust/target/debug/rustproxy'),
|
|
||||||
];
|
|
||||||
for (const localPath of localPaths) {
|
|
||||||
if (await this.isExecutable(localPath)) {
|
|
||||||
logger.log('info', `RustProxy binary found at local path: ${localPath}`, { component: 'rust-locator' });
|
|
||||||
return localPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. System PATH
|
|
||||||
const systemPath = await this.findInPath('rustproxy');
|
|
||||||
if (systemPath) {
|
|
||||||
logger.log('info', `RustProxy binary found in system PATH: ${systemPath}`, { component: 'rust-locator' });
|
|
||||||
return systemPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('error', 'No RustProxy binary found. Set SMARTPROXY_RUST_BINARY, install the platform package, or build with: cd rust && cargo build --release', { component: 'rust-locator' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findPlatformPackageBinary(): Promise<string | null> {
|
|
||||||
const platform = process.platform;
|
|
||||||
const arch = process.arch;
|
|
||||||
const packageName = `@push.rocks/smartproxy-${platform}-${arch}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to resolve the platform-specific package
|
|
||||||
const packagePath = require.resolve(`${packageName}/rustproxy`);
|
|
||||||
if (await this.isExecutable(packagePath)) {
|
|
||||||
return packagePath;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Package not installed - expected for development
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isExecutable(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findInPath(binaryName: string): Promise<string | null> {
|
|
||||||
const pathDirs = (process.env.PATH || '').split(plugins.path.delimiter);
|
|
||||||
for (const dir of pathDirs) {
|
|
||||||
const fullPath = plugins.path.join(dir, binaryName);
|
|
||||||
if (await this.isExecutable(fullPath)) {
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,310 +1,179 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from '../../core/utils/logger.js';
|
import { logger } from '../../core/utils/logger.js';
|
||||||
import { RustBinaryLocator } from './rust-binary-locator.js';
|
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type { IRouteConfig } from './models/route-types.js';
|
||||||
import { ChildProcess, spawn } from 'child_process';
|
|
||||||
import { createInterface, Interface as ReadlineInterface } from 'readline';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management request sent to the Rust binary via stdin.
|
* Type-safe command definitions for the Rust proxy IPC protocol.
|
||||||
*/
|
*/
|
||||||
interface IManagementRequest {
|
type TSmartProxyCommands = {
|
||||||
id: string;
|
start: { params: { config: any }; result: void };
|
||||||
method: string;
|
stop: { params: Record<string, never>; result: void };
|
||||||
params: Record<string, any>;
|
updateRoutes: { params: { routes: IRouteConfig[] }; result: void };
|
||||||
|
getMetrics: { params: Record<string, never>; result: any };
|
||||||
|
getStatistics: { params: Record<string, never>; result: any };
|
||||||
|
provisionCertificate: { params: { routeName: string }; result: void };
|
||||||
|
renewCertificate: { params: { routeName: string }; result: void };
|
||||||
|
getCertificateStatus: { params: { routeName: string }; result: any };
|
||||||
|
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
||||||
|
getNftablesStatus: { params: Record<string, never>; result: any };
|
||||||
|
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
|
addListeningPort: { params: { port: number }; result: void };
|
||||||
|
removeListeningPort: { params: { port: number }; result: void };
|
||||||
|
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the package root directory using import.meta.url.
|
||||||
|
* This file is at ts/proxies/smart-proxy/, so package root is 3 levels up.
|
||||||
|
*/
|
||||||
|
function getPackageRoot(): string {
|
||||||
|
const thisDir = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url));
|
||||||
|
return plugins.path.resolve(thisDir, '..', '..', '..');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management response received from the Rust binary via stdout.
|
* Map Node.js process.platform/process.arch to tsrust's friendly name suffix.
|
||||||
|
* tsrust names cross-compiled binaries as: rustproxy_linux_amd64, rustproxy_linux_arm64, etc.
|
||||||
*/
|
*/
|
||||||
interface IManagementResponse {
|
function getTsrustPlatformSuffix(): string | null {
|
||||||
id: string;
|
const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
|
||||||
success: boolean;
|
const osMap: Record<string, string> = { linux: 'linux', darwin: 'macos' };
|
||||||
result?: any;
|
const os = osMap[process.platform];
|
||||||
error?: string;
|
const arch = archMap[process.arch];
|
||||||
|
if (os && arch) {
|
||||||
|
return `${os}_${arch}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management event received from the Rust binary (unsolicited).
|
* Build local search paths for the Rust binary, including dist_rust/ candidates
|
||||||
|
* (built by tsrust) and local development build paths.
|
||||||
*/
|
*/
|
||||||
interface IManagementEvent {
|
function buildLocalPaths(): string[] {
|
||||||
event: string;
|
const packageRoot = getPackageRoot();
|
||||||
data: any;
|
const suffix = getTsrustPlatformSuffix();
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
// dist_rust/ candidates (tsrust cross-compiled output)
|
||||||
|
if (suffix) {
|
||||||
|
paths.push(plugins.path.join(packageRoot, 'dist_rust', `rustproxy_${suffix}`));
|
||||||
|
}
|
||||||
|
paths.push(plugins.path.join(packageRoot, 'dist_rust', 'rustproxy'));
|
||||||
|
|
||||||
|
// Local dev build paths
|
||||||
|
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'release', 'rustproxy'));
|
||||||
|
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'debug', 'rustproxy'));
|
||||||
|
|
||||||
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge between TypeScript SmartProxy and the Rust binary.
|
* Bridge between TypeScript SmartProxy and the Rust binary.
|
||||||
* Communicates via JSON-over-stdin/stdout IPC protocol.
|
* Wraps @push.rocks/smartrust's RustBridge with type-safe command definitions.
|
||||||
*/
|
*/
|
||||||
export class RustProxyBridge extends plugins.EventEmitter {
|
export class RustProxyBridge extends plugins.EventEmitter {
|
||||||
private locator = new RustBinaryLocator();
|
private bridge: plugins.smartrust.RustBridge<TSmartProxyCommands>;
|
||||||
private process: ChildProcess | null = null;
|
|
||||||
private readline: ReadlineInterface | null = null;
|
constructor() {
|
||||||
private pendingRequests = new Map<string, {
|
super();
|
||||||
resolve: (value: any) => void;
|
|
||||||
reject: (error: Error) => void;
|
this.bridge = new plugins.smartrust.RustBridge<TSmartProxyCommands>({
|
||||||
timer: NodeJS.Timeout;
|
binaryName: 'rustproxy',
|
||||||
}>();
|
envVarName: 'SMARTPROXY_RUST_BINARY',
|
||||||
private requestCounter = 0;
|
platformPackagePrefix: '@push.rocks/smartproxy',
|
||||||
private isRunning = false;
|
localPaths: buildLocalPaths(),
|
||||||
private binaryPath: string | null = null;
|
logger: {
|
||||||
private readonly requestTimeoutMs = 30000;
|
log: (level: string, message: string, data?: Record<string, any>) => {
|
||||||
|
logger.log(level as any, message, data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward events from the inner bridge
|
||||||
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||||
|
this.emit('exit', code, signal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn the Rust binary in management mode.
|
* Spawn the Rust binary in management mode.
|
||||||
* Returns true if the binary was found and spawned successfully.
|
* Returns true if the binary was found and spawned successfully.
|
||||||
*/
|
*/
|
||||||
public async spawn(): Promise<boolean> {
|
public async spawn(): Promise<boolean> {
|
||||||
this.binaryPath = await this.locator.findBinary();
|
return this.bridge.spawn();
|
||||||
if (!this.binaryPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
try {
|
|
||||||
this.process = spawn(this.binaryPath!, ['--management'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stderr (logging from Rust goes here)
|
|
||||||
const stderrHandler = (data: Buffer) => {
|
|
||||||
const lines = data.toString().split('\n').filter(l => l.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
logger.log('debug', `[rustproxy] ${line}`, { component: 'rust-bridge' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.process.stderr?.on('data', stderrHandler);
|
|
||||||
|
|
||||||
// Handle stdout (JSON IPC)
|
|
||||||
this.readline = createInterface({ input: this.process.stdout! });
|
|
||||||
this.readline.on('line', (line: string) => {
|
|
||||||
this.handleLine(line.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle process exit
|
|
||||||
this.process.on('exit', (code, signal) => {
|
|
||||||
logger.log('info', `RustProxy process exited (code=${code}, signal=${signal})`, { component: 'rust-bridge' });
|
|
||||||
this.cleanup();
|
|
||||||
this.emit('exit', code, signal);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process.on('error', (err) => {
|
|
||||||
logger.log('error', `RustProxy process error: ${err.message}`, { component: 'rust-bridge' });
|
|
||||||
this.cleanup();
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the 'ready' event from Rust
|
|
||||||
const readyTimeout = setTimeout(() => {
|
|
||||||
logger.log('error', 'RustProxy did not send ready event within 10s', { component: 'rust-bridge' });
|
|
||||||
this.kill();
|
|
||||||
resolve(false);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
this.once('management:ready', () => {
|
|
||||||
clearTimeout(readyTimeout);
|
|
||||||
this.isRunning = true;
|
|
||||||
logger.log('info', 'RustProxy bridge connected', { component: 'rust-bridge' });
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.log('error', `Failed to spawn RustProxy: ${err.message}`, { component: 'rust-bridge' });
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a management command to the Rust process and wait for the response.
|
* Kill the Rust process and clean up.
|
||||||
*/
|
|
||||||
public async sendCommand(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
||||||
if (!this.process || !this.isRunning) {
|
|
||||||
throw new Error('RustProxy bridge is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = `req_${++this.requestCounter}`;
|
|
||||||
const request: IManagementRequest = { id, method, params };
|
|
||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pendingRequests.delete(id);
|
|
||||||
reject(new Error(`RustProxy command '${method}' timed out after ${this.requestTimeoutMs}ms`));
|
|
||||||
}, this.requestTimeoutMs);
|
|
||||||
|
|
||||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
||||||
|
|
||||||
const json = JSON.stringify(request) + '\n';
|
|
||||||
this.process!.stdin!.write(json, (err) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.pendingRequests.delete(id);
|
|
||||||
reject(new Error(`Failed to write to RustProxy stdin: ${err.message}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for each management command
|
|
||||||
|
|
||||||
public async startProxy(config: any): Promise<void> {
|
|
||||||
await this.sendCommand('start', { config });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stopProxy(): Promise<void> {
|
|
||||||
await this.sendCommand('stop');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
|
||||||
await this.sendCommand('updateRoutes', { routes });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getMetrics(): Promise<any> {
|
|
||||||
return this.sendCommand('getMetrics');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getStatistics(): Promise<any> {
|
|
||||||
return this.sendCommand('getStatistics');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async provisionCertificate(routeName: string): Promise<void> {
|
|
||||||
await this.sendCommand('provisionCertificate', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async renewCertificate(routeName: string): Promise<void> {
|
|
||||||
await this.sendCommand('renewCertificate', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCertificateStatus(routeName: string): Promise<any> {
|
|
||||||
return this.sendCommand('getCertificateStatus', { routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getListeningPorts(): Promise<number[]> {
|
|
||||||
const result = await this.sendCommand('getListeningPorts');
|
|
||||||
return result?.ports ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getNftablesStatus(): Promise<any> {
|
|
||||||
return this.sendCommand('getNftablesStatus');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
|
||||||
await this.sendCommand('setSocketHandlerRelay', { socketPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addListeningPort(port: number): Promise<void> {
|
|
||||||
await this.sendCommand('addListeningPort', { port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeListeningPort(port: number): Promise<void> {
|
|
||||||
await this.sendCommand('removeListeningPort', { port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
|
||||||
await this.sendCommand('loadCertificate', { domain, cert, key, ca });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill the Rust process and clean up all stdio streams.
|
|
||||||
*/
|
*/
|
||||||
public kill(): void {
|
public kill(): void {
|
||||||
if (this.process) {
|
this.bridge.kill();
|
||||||
const proc = this.process;
|
|
||||||
this.process = null;
|
|
||||||
this.isRunning = false;
|
|
||||||
|
|
||||||
// Close readline (reads from stdout)
|
|
||||||
if (this.readline) {
|
|
||||||
this.readline.close();
|
|
||||||
this.readline = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject pending requests
|
|
||||||
for (const [, pending] of this.pendingRequests) {
|
|
||||||
clearTimeout(pending.timer);
|
|
||||||
pending.reject(new Error('RustProxy process killed'));
|
|
||||||
}
|
|
||||||
this.pendingRequests.clear();
|
|
||||||
|
|
||||||
// Remove all listeners so nothing keeps references
|
|
||||||
proc.removeAllListeners();
|
|
||||||
proc.stdout?.removeAllListeners();
|
|
||||||
proc.stderr?.removeAllListeners();
|
|
||||||
proc.stdin?.removeAllListeners();
|
|
||||||
|
|
||||||
// Kill the process
|
|
||||||
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
||||||
|
|
||||||
// Destroy all stdio pipes to free handles
|
|
||||||
try { proc.stdin?.destroy(); } catch { /* ignore */ }
|
|
||||||
try { proc.stdout?.destroy(); } catch { /* ignore */ }
|
|
||||||
try { proc.stderr?.destroy(); } catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Unref process so Node doesn't wait for it
|
|
||||||
try { proc.unref(); } catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Force kill after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
||||||
}, 5000).unref();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the bridge is currently running.
|
* Whether the bridge is currently running.
|
||||||
*/
|
*/
|
||||||
public get running(): boolean {
|
public get running(): boolean {
|
||||||
return this.isRunning;
|
return this.bridge.running;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLine(line: string): void {
|
// --- Convenience methods for each management command ---
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: any;
|
public async startProxy(config: any): Promise<void> {
|
||||||
try {
|
await this.bridge.sendCommand('start', { config });
|
||||||
parsed = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
logger.log('warn', `Non-JSON output from RustProxy: ${line}`, { component: 'rust-bridge' });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an event (has 'event' field)
|
public async stopProxy(): Promise<void> {
|
||||||
if ('event' in parsed) {
|
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
||||||
const event = parsed as IManagementEvent;
|
|
||||||
this.emit(`management:${event.event}`, event.data);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise it's a response (has 'id' field)
|
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
||||||
if ('id' in parsed) {
|
await this.bridge.sendCommand('updateRoutes', { routes });
|
||||||
const response = parsed as IManagementResponse;
|
|
||||||
const pending = this.pendingRequests.get(response.id);
|
|
||||||
if (pending) {
|
|
||||||
clearTimeout(pending.timer);
|
|
||||||
this.pendingRequests.delete(response.id);
|
|
||||||
if (response.success) {
|
|
||||||
pending.resolve(response.result);
|
|
||||||
} else {
|
|
||||||
pending.reject(new Error(response.error || 'Unknown error from RustProxy'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup(): void {
|
public async getMetrics(): Promise<any> {
|
||||||
this.isRunning = false;
|
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
|
||||||
this.process = null;
|
|
||||||
|
|
||||||
if (this.readline) {
|
|
||||||
this.readline.close();
|
|
||||||
this.readline = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject all pending requests
|
public async getStatistics(): Promise<any> {
|
||||||
for (const [id, pending] of this.pendingRequests) {
|
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
||||||
clearTimeout(pending.timer);
|
|
||||||
pending.reject(new Error('RustProxy process exited'));
|
|
||||||
}
|
}
|
||||||
this.pendingRequests.clear();
|
|
||||||
|
public async provisionCertificate(routeName: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('provisionCertificate', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async renewCertificate(routeName: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('renewCertificate', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCertificateStatus(routeName: string): Promise<any> {
|
||||||
|
return this.bridge.sendCommand('getCertificateStatus', { routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getListeningPorts(): Promise<number[]> {
|
||||||
|
const result = await this.bridge.sendCommand('getListeningPorts', {} as Record<string, never>);
|
||||||
|
return result?.ports ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNftablesStatus(): Promise<any> {
|
||||||
|
return this.bridge.sendCommand('getNftablesStatus', {} as Record<string, never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addListeningPort(port: number): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('addListeningPort', { port });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeListeningPort(port: number): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('removeListeningPort', { port });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('loadCertificate', { domain, cert, key, ca });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { logger } from '../../core/utils/logger.js';
|
|||||||
|
|
||||||
// Rust bridge and helpers
|
// Rust bridge and helpers
|
||||||
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||||
import { RustBinaryLocator } from './rust-binary-locator.js';
|
|
||||||
import { RoutePreprocessor } from './route-preprocessor.js';
|
import { RoutePreprocessor } from './route-preprocessor.js';
|
||||||
import { SocketHandlerServer } from './socket-handler-server.js';
|
import { SocketHandlerServer } from './socket-handler-server.js';
|
||||||
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
||||||
@@ -120,7 +119,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
if (!spawned) {
|
if (!spawned) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
||||||
'or build locally with: cd rust && cargo build --release'
|
'or build locally with: pnpm build'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user