@push.rocks/smartproxy 27.22.0 → 28.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +47 -0
- package/dist_rust/rustproxy_linux_amd64 +0 -0
- package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +5 -5
- package/dist_rust/rustproxy_linux_arm64 +0 -0
- package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +4 -2
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +12 -6
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +17 -0
- package/dist_ts/proxies/smart-proxy/models/rust-types.js +3 -2
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +28 -4
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +75 -6
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +46 -41
- package/dist_ts/proxies/smart-proxy/utils/index.d.ts +2 -0
- package/dist_ts/proxies/smart-proxy/utils/index.js +3 -1
- package/dist_ts/proxies/smart-proxy/utils/route-validation-error.d.ts +24 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validation-error.js +41 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +14 -2
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +178 -43
- package/package.json +3 -3
- package/readme.md +74 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -1
- package/ts/proxies/smart-proxy/models/interfaces.ts +4 -2
- package/ts/proxies/smart-proxy/models/route-types.ts +14 -6
- package/ts/proxies/smart-proxy/models/rust-types.ts +20 -0
- package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +106 -13
- package/ts/proxies/smart-proxy/smart-proxy.ts +41 -40
- package/ts/proxies/smart-proxy/utils/index.ts +4 -0
- package/ts/proxies/smart-proxy/utils/route-validation-error.ts +60 -0
- package/ts/proxies/smart-proxy/utils/route-validator.ts +208 -51
package/readme.md
CHANGED
|
@@ -103,7 +103,7 @@ SmartProxy uses a powerful **match/action** pattern that makes routing predictab
|
|
|
103
103
|
|
|
104
104
|
Every route consists of:
|
|
105
105
|
- **Match** — What traffic to capture (ports, domains, paths, transport, protocol, IPs, headers)
|
|
106
|
-
- **Action** — What to do with it (`forward
|
|
106
|
+
- **Action** — What to do with it (`forward`, `socket-handler` or `respond`)
|
|
107
107
|
- **Security** (optional) — IP allow/block lists, rate limits, authentication
|
|
108
108
|
- **Headers** (optional) — Request/response header manipulation with template variables
|
|
109
109
|
- **Name/Priority** (optional) — For identification and ordering
|
|
@@ -137,6 +137,40 @@ const proxy = new SmartProxy({
|
|
|
137
137
|
});
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
### 🚧 Fixed Response (Maintenance / Stopped Service)
|
|
141
|
+
|
|
142
|
+
A `respond` route answers every HTTP request itself, without an upstream. Keep a stopped service's hostname routed and tell clients when to retry instead of failing with 502 or a TLS error:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const proxy = new SmartProxy({
|
|
146
|
+
routes: [{
|
|
147
|
+
name: 'app-stopped',
|
|
148
|
+
match: { ports: 443, domains: 'app.example.com' },
|
|
149
|
+
action: {
|
|
150
|
+
type: 'respond',
|
|
151
|
+
tls: { mode: 'terminate', certificate: 'auto' },
|
|
152
|
+
response: {
|
|
153
|
+
status: 503,
|
|
154
|
+
headers: {
|
|
155
|
+
'Retry-After': '30',
|
|
156
|
+
'Cache-Control': 'no-store',
|
|
157
|
+
'Content-Type': 'application/json'
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({ error: 'service stopped' })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}]
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `status` is 200–599. `headers` must not set `connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `te`, `trailer`, `proxy-connection` or `content-length` (SmartProxy computes it).
|
|
167
|
+
- `body` is UTF-8 text of at most 64 KiB and needs an explicit `content-type` header; 204 and 304 carry no body.
|
|
168
|
+
- TLS is optional; when set it must be `mode: 'terminate'` (static certificate or `'auto'`). Responses go out over HTTP/1.1 and h2. Route `security`, rate limits, challenges and `maxConnections` apply before the response.
|
|
169
|
+
- Respond routes are HTTP over TCP only: `action.udp`, `match.transport: 'udp' | 'all'` and HTTP/3 are rejected in this version. `targets`, socket handlers and nftables forwarding are rejected too.
|
|
170
|
+
- Switch a hostname between `forward` and `respond` with `updateRoutes()`.
|
|
171
|
+
|
|
172
|
+
**Migrating from `advanced.testResponse`:** SmartProxy 28 removes `action.advanced.testResponse`. Move its `status`, `headers` and `body` to `action.response`, set `action.type: 'respond'`, drop the `targets`, and add a `content-type` header when the body is not empty.
|
|
173
|
+
|
|
140
174
|
### ⚖️ Load Balancer
|
|
141
175
|
|
|
142
176
|
For equivalent backends, put the backend hosts into one target's `host` array and choose a target-level load-balancing algorithm. Multiple `targets` are for sub-routing with `target.match` and `priority`.
|
|
@@ -891,6 +925,7 @@ type TPortRange = number | Array<number | { from: number; to: number }>;
|
|
|
891
925
|
|------|-------------|
|
|
892
926
|
| `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
|
|
893
927
|
| `socket-handler` | Custom socket/datagram handling function in TypeScript |
|
|
928
|
+
| `respond` | Answer every HTTP request with the fixed `action.response` (status, headers, body); no upstream, TCP only |
|
|
894
929
|
|
|
895
930
|
`targets` are evaluated as route-internal sub-routes by `target.match` and `target.priority`. For load balancing across equivalent upstreams, use a single target with `host: ['a', 'b', 'c']` and target-level `loadBalancing`.
|
|
896
931
|
|
|
@@ -1044,7 +1079,15 @@ import {
|
|
|
1044
1079
|
findBestMatchingRoute, // Find best matching route
|
|
1045
1080
|
cloneRoute, // Deep-clone a route
|
|
1046
1081
|
generateRouteId, // Generate deterministic route ID
|
|
1047
|
-
RouteValidator, // Validate route configurations
|
|
1082
|
+
RouteValidator, // Validate route configurations (validateRoutes, getRouteLabels)
|
|
1083
|
+
RouteValidationError, // Thrown when SmartProxy refuses a route set; carries the per-route reasons
|
|
1084
|
+
MAX_FIXED_RESPONSE_BODY_BYTES, // Largest `respond` route body in UTF-8 bytes (65536)
|
|
1085
|
+
} from '@push.rocks/smartproxy';
|
|
1086
|
+
|
|
1087
|
+
import type {
|
|
1088
|
+
IRouteConfig, // A route: match + action
|
|
1089
|
+
IRouteFixedResponse, // Fixed response of a `respond` route (status, headers, body)
|
|
1090
|
+
IRouteValidationErrorEntry, // One rejected route in RouteValidationError.errors (index, route, messages)
|
|
1048
1091
|
} from '@push.rocks/smartproxy';
|
|
1049
1092
|
```
|
|
1050
1093
|
|
|
@@ -1056,14 +1099,14 @@ All routes are configured as plain `IRouteConfig` objects with `match` and `acti
|
|
|
1056
1099
|
|
|
1057
1100
|
```typescript
|
|
1058
1101
|
class SmartProxy extends EventEmitter {
|
|
1059
|
-
constructor(options: ISmartProxyOptions);
|
|
1102
|
+
constructor(options: ISmartProxyOptions); // Throws RouteValidationError for invalid routes
|
|
1060
1103
|
|
|
1061
1104
|
// Lifecycle
|
|
1062
1105
|
start(): Promise<void>;
|
|
1063
1106
|
stop(): Promise<void>;
|
|
1064
1107
|
|
|
1065
1108
|
// Route Management (atomic, mutex-locked)
|
|
1066
|
-
updateRoutes(routes: IRouteConfig[]): Promise<void>;
|
|
1109
|
+
updateRoutes(routes: IRouteConfig[]): Promise<void>; // Rejects with RouteValidationError for invalid routes
|
|
1067
1110
|
updateSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void>;
|
|
1068
1111
|
registerChallengeProvider(providerId: string, provider: IChallengeProvider): void;
|
|
1069
1112
|
|
|
@@ -1262,6 +1305,31 @@ The percentile methods are part of the public metrics shape. In the current Rust
|
|
|
1262
1305
|
|
|
1263
1306
|
## 🐛 Troubleshooting
|
|
1264
1307
|
|
|
1308
|
+
### Route Validation Errors
|
|
1309
|
+
|
|
1310
|
+
SmartProxy validates every route set before it applies it, first in TypeScript and then in the Rust engine. When either refuses one, the constructor, `start()` or `updateRoutes()` throws a `RouteValidationError` and the running routes stay unchanged. `assertValidRoute()` throws the same error for a single route:
|
|
1311
|
+
|
|
1312
|
+
```typescript
|
|
1313
|
+
import { RouteValidationError } from '@push.rocks/smartproxy';
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
await proxy.updateRoutes(newRoutes);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error instanceof RouteValidationError) {
|
|
1319
|
+
// A configuration problem, e.g. answer an admin API call with 422
|
|
1320
|
+
for (const { index, route, messages } of error.errors) {
|
|
1321
|
+
console.error(`#${index} ${route}: ${messages.join('; ')}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
throw error;
|
|
1325
|
+
}
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
- `error.name` is `'RouteValidationError'`.
|
|
1329
|
+
- `error.errors` lists every rejected route with all its reasons, for example `response.body requires an explicit content-type header`. `index` is the route's position in the list you passed. `route` is its label from `RouteValidator.getRouteLabels()`: the name, else the id, else `route[<index>]`; a name or id that several routes share gets `[<index>]` appended, so no route's reasons hide another's.
|
|
1330
|
+
- `error.message` names the first 10 routes with up to 5 reasons each, shortens long names and reasons to 200 characters and counts the rest, so it stays bounded for large route sets. Read `error.errors` for the complete list.
|
|
1331
|
+
- Checks that only the Rust engine makes, such as duplicate route ids or an `inboundProxyProtocol` policy without route-level or global `trustedProxyIPs`, arrive the same way; their message starts with `Route validation failed`.
|
|
1332
|
+
|
|
1265
1333
|
### Certificate Issues
|
|
1266
1334
|
- ✅ Ensure domain DNS points to your server
|
|
1267
1335
|
- ✅ Port 80 must be accessible for ACME HTTP-01 challenges
|
|
@@ -1285,6 +1353,8 @@ SmartProxy searches for the Rust binary in this order:
|
|
|
1285
1353
|
5. Local dev build (`./rust/target/release/rustproxy`)
|
|
1286
1354
|
6. System PATH (`rustproxy`)
|
|
1287
1355
|
|
|
1356
|
+
`rustBinaryPath` is an explicit choice, not a first guess: when it is set, SmartProxy uses only that file. If it is missing or not executable, `start()` rejects with a `RustBinaryLocatorError` naming the path and does not fall back to the other locations.
|
|
1357
|
+
|
|
1288
1358
|
### QUIC / HTTP3 Caveats
|
|
1289
1359
|
- **GREASE frames are disabled.** The underlying h3 crate sends [GREASE frames](https://www.rfc-editor.org/rfc/rfc9114.html#frame-reserved) by default to test protocol extensibility. However, some HTTP/3 clients and servers don't properly ignore unknown frame types, causing 400/500 errors or stream hangs ([h3#206](https://github.com/hyperium/h3/issues/206)). SmartProxy disables GREASE on both the server side (for incoming H3 requests) and the client side (for H3 backend connections) to maximize compatibility.
|
|
1290
1360
|
- **HTTP/3 is pre-release.** The h3 ecosystem (h3 0.0.8, h3-quinn 0.0.10, quinn 0.11) is still pre-1.0. Expect rough edges.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@push.rocks/smartproxy',
|
|
6
|
-
version: '
|
|
6
|
+
version: '28.1.0',
|
|
7
7
|
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.'
|
|
8
8
|
}
|
package/ts/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { SmartProxy } from './proxies/smart-proxy/index.js';
|
|
|
7
7
|
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';
|
|
8
8
|
|
|
9
9
|
// Export smart-proxy models
|
|
10
|
-
export type { ISmartProxyOptions, ISmartProxySecurityPolicy, ISmartProxyChallengeOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext, TDatagramHandler, IDatagramInfo, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage } from './proxies/smart-proxy/models/index.js';
|
|
10
|
+
export type { ISmartProxyOptions, ISmartProxySecurityPolicy, ISmartProxyChallengeOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteFixedResponse, IRouteTls, IRouteContext, TDatagramHandler, IDatagramInfo, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage } from './proxies/smart-proxy/models/index.js';
|
|
11
11
|
export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js';
|
|
12
12
|
export * from './proxies/smart-proxy/utils/index.js';
|
|
13
13
|
|
|
@@ -289,8 +289,10 @@ export interface ISmartProxyOptions {
|
|
|
289
289
|
certStore?: ISmartProxyCertStore;
|
|
290
290
|
|
|
291
291
|
/**
|
|
292
|
-
* Path to the RustProxy binary.
|
|
293
|
-
*
|
|
292
|
+
* Path to the RustProxy binary. When set, it is the only candidate: `start()` rejects
|
|
293
|
+
* with a `RustBinaryLocatorError` if it is missing or not executable, without falling
|
|
294
|
+
* back to discovery. If not set, the binary is located automatically via env var,
|
|
295
|
+
* platform package, local build, or PATH.
|
|
294
296
|
*/
|
|
295
297
|
rustBinaryPath?: string;
|
|
296
298
|
}
|
|
@@ -7,7 +7,7 @@ export type { IRouteContext };
|
|
|
7
7
|
/**
|
|
8
8
|
* Supported action types for route configurations
|
|
9
9
|
*/
|
|
10
|
-
export type TRouteActionType = 'forward' | 'socket-handler';
|
|
10
|
+
export type TRouteActionType = 'forward' | 'socket-handler' | 'respond';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Socket handler function type
|
|
@@ -227,12 +227,18 @@ export interface IRouteStaticFiles {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
|
-
*
|
|
230
|
+
* Fixed response answered by a `respond` route without contacting any upstream.
|
|
231
|
+
*
|
|
232
|
+
* - `status` must be an integer from 200 to 599.
|
|
233
|
+
* - `headers` must not contain hop-by-hop or computed headers (`connection`, `keep-alive`,
|
|
234
|
+
* `transfer-encoding`, `upgrade`, `te`, `trailer`, `proxy-connection`, `content-length`).
|
|
235
|
+
* - A non-empty `body` requires an explicit `content-type` header; 204 and 304 carry no body.
|
|
236
|
+
* - `body` is UTF-8 text of at most 65536 bytes.
|
|
231
237
|
*/
|
|
232
|
-
export interface
|
|
238
|
+
export interface IRouteFixedResponse {
|
|
233
239
|
status: number;
|
|
234
|
-
headers
|
|
235
|
-
body
|
|
240
|
+
headers?: Record<string, string>;
|
|
241
|
+
body?: string;
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
/**
|
|
@@ -253,7 +259,6 @@ export interface IRouteAdvanced {
|
|
|
253
259
|
headers?: Record<string, string>;
|
|
254
260
|
keepAlive?: boolean;
|
|
255
261
|
staticFiles?: IRouteStaticFiles;
|
|
256
|
-
testResponse?: IRouteTestResponse;
|
|
257
262
|
urlRewrite?: IRouteUrlRewrite; // URL rewriting configuration
|
|
258
263
|
// Additional advanced options would go here
|
|
259
264
|
}
|
|
@@ -322,6 +327,9 @@ export interface IRouteAction {
|
|
|
322
327
|
// NFTables-specific options
|
|
323
328
|
nftables?: INfTablesOptions;
|
|
324
329
|
|
|
330
|
+
// Fixed response (required when type is 'respond')
|
|
331
|
+
response?: IRouteFixedResponse;
|
|
332
|
+
|
|
325
333
|
// Socket handler function (when type is 'socket-handler')
|
|
326
334
|
socketHandler?: TSocketHandler;
|
|
327
335
|
|
|
@@ -44,6 +44,26 @@ export interface IRustRouteConfig extends Omit<IRouteConfig, 'match' | 'action'>
|
|
|
44
44
|
action: IRustRouteAction;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** `errorCode` of a Rust `start` or `updateRoutes` response that refused its routes in validation. */
|
|
48
|
+
export const RUST_ROUTE_VALIDATION_ERROR_CODE = 'ROUTE_VALIDATION';
|
|
49
|
+
|
|
50
|
+
/** One rejected route in the `errorData` of a `ROUTE_VALIDATION` response. */
|
|
51
|
+
export interface IRustRouteValidationIssue {
|
|
52
|
+
/** The route's position in the sent route list. */
|
|
53
|
+
index: number;
|
|
54
|
+
/**
|
|
55
|
+
* The engine's label of the route as it received it. The preprocessor names unnamed routes
|
|
56
|
+
* `route_<index>`, so TypeScript relabels issues by `index` and does not surface this label.
|
|
57
|
+
*/
|
|
58
|
+
route: string;
|
|
59
|
+
messages: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `errorData` of a `ROUTE_VALIDATION` response. */
|
|
63
|
+
export interface IRustRouteValidationErrorData {
|
|
64
|
+
routes: IRustRouteValidationIssue[];
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
export interface IRustAcmeOptions extends Omit<IAcmeOptions, 'routeForwards'> {}
|
|
48
68
|
|
|
49
69
|
export interface IRustChallengeOptions extends Required<Pick<
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { logger } from '../../core/utils/logger.js';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import {
|
|
4
|
+
RUST_ROUTE_VALIDATION_ERROR_CODE,
|
|
5
|
+
type IRustCertificateStatus,
|
|
6
|
+
type IRustChallengeOptions,
|
|
7
|
+
type IRustMetricsSnapshot,
|
|
8
|
+
type IRustProxyOptions,
|
|
9
|
+
type IRustMetricsHistory,
|
|
10
|
+
type IRustRouteConfig,
|
|
11
|
+
type IRustRouteValidationErrorData,
|
|
12
|
+
type IRustStatistics,
|
|
11
13
|
} from './models/rust-types.js';
|
|
14
|
+
import { RouteValidationError } from './utils/route-validation-error.js';
|
|
12
15
|
import type { IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage, ISmartProxySecurityPolicy } from './models/interfaces.js';
|
|
13
16
|
|
|
14
17
|
const MAXIMUM_RUST_CONNECTION_ID = 18_446_744_073_709_551_615n;
|
|
@@ -26,6 +29,69 @@ function validateActiveConnectionSnapshotOptions(options: IActiveConnectionSnaps
|
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
function isRustRouteValidationErrorData(
|
|
33
|
+
data: unknown,
|
|
34
|
+
sentRouteCount: number,
|
|
35
|
+
): data is IRustRouteValidationErrorData {
|
|
36
|
+
if (
|
|
37
|
+
typeof data !== 'object'
|
|
38
|
+
|| data === null
|
|
39
|
+
|| !('routes' in data)
|
|
40
|
+
|| !Array.isArray(data.routes)
|
|
41
|
+
|| data.routes.length === 0
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return data.routes.every((issue: unknown) =>
|
|
46
|
+
typeof issue === 'object'
|
|
47
|
+
&& issue !== null
|
|
48
|
+
&& 'index' in issue
|
|
49
|
+
&& typeof issue.index === 'number'
|
|
50
|
+
&& Number.isSafeInteger(issue.index)
|
|
51
|
+
&& issue.index >= 0
|
|
52
|
+
&& issue.index < sentRouteCount
|
|
53
|
+
&& 'route' in issue
|
|
54
|
+
&& typeof issue.route === 'string'
|
|
55
|
+
&& 'messages' in issue
|
|
56
|
+
&& Array.isArray(issue.messages)
|
|
57
|
+
&& issue.messages.every((message: unknown) => typeof message === 'string'),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Turn a Rust `ROUTE_VALIDATION` refusal into a `RouteValidationError` carrying the
|
|
63
|
+
* per-route reasons from its `errorData`. Any other error is returned unchanged.
|
|
64
|
+
*
|
|
65
|
+
* `routeLabels` holds `RouteValidator.getRouteLabels()` of the caller's routes, one per sent
|
|
66
|
+
* route. Entries are relabelled by index: the preprocessor names unnamed routes for Rust, so
|
|
67
|
+
* the engine's own labels can differ from the ones the caller sees.
|
|
68
|
+
*/
|
|
69
|
+
export function toRouteValidationError(error: unknown, routeLabels: string[]): unknown {
|
|
70
|
+
if (
|
|
71
|
+
!(error instanceof plugins.smartrust.RustBridgeRequestError)
|
|
72
|
+
|| error.responseErrorCode !== RUST_ROUTE_VALIDATION_ERROR_CODE
|
|
73
|
+
) {
|
|
74
|
+
return error;
|
|
75
|
+
}
|
|
76
|
+
if (!isRustRouteValidationErrorData(error.responseErrorData, routeLabels.length)) {
|
|
77
|
+
return new Error(`RustProxy sent a route validation refusal with malformed errorData: ${error.message}`, {
|
|
78
|
+
cause: error,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return new RouteValidationError(
|
|
82
|
+
'Route validation failed',
|
|
83
|
+
error.responseErrorData.routes.map(({ index, messages }) => ({ index, route: routeLabels[index], messages })),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertOneLabelPerRoute(routes: IRustRouteConfig[], routeLabels: string[]): void {
|
|
88
|
+
if (routes.length !== routeLabels.length) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`RustProxyBridge got ${routeLabels.length} route label(s) for ${routes.length} route(s); they must match one to one`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
29
95
|
/**
|
|
30
96
|
* Type-safe command definitions for the Rust proxy IPC protocol.
|
|
31
97
|
*/
|
|
@@ -97,6 +163,14 @@ function buildLocalPaths(): string[] {
|
|
|
97
163
|
return paths;
|
|
98
164
|
}
|
|
99
165
|
|
|
166
|
+
export interface IRustProxyBridgeOptions {
|
|
167
|
+
/**
|
|
168
|
+
* Explicit path to the RustProxy binary. When set, it is the only candidate:
|
|
169
|
+
* `spawn()` rejects with a `RustBinaryLocatorError` if it is missing or not executable.
|
|
170
|
+
*/
|
|
171
|
+
binaryPath?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
100
174
|
/**
|
|
101
175
|
* Bridge between TypeScript SmartProxy and the Rust binary.
|
|
102
176
|
* Wraps @push.rocks/smartrust's RustBridge with type-safe command definitions.
|
|
@@ -104,11 +178,12 @@ function buildLocalPaths(): string[] {
|
|
|
104
178
|
export class RustProxyBridge extends plugins.EventEmitter {
|
|
105
179
|
private bridge: plugins.smartrust.RustBridge<TSmartProxyCommands>;
|
|
106
180
|
|
|
107
|
-
constructor() {
|
|
181
|
+
constructor(options: IRustProxyBridgeOptions = {}) {
|
|
108
182
|
super();
|
|
109
183
|
|
|
110
184
|
this.bridge = new plugins.smartrust.RustBridge<TSmartProxyCommands>({
|
|
111
185
|
binaryName: 'rustproxy',
|
|
186
|
+
binaryPath: options.binaryPath,
|
|
112
187
|
envVarName: 'SMARTPROXY_RUST_BINARY',
|
|
113
188
|
platformPackagePrefix: '@push.rocks/smartproxy',
|
|
114
189
|
localPaths: buildLocalPaths(),
|
|
@@ -155,16 +230,34 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|
|
155
230
|
|
|
156
231
|
// --- Convenience methods for each management command ---
|
|
157
232
|
|
|
158
|
-
|
|
159
|
-
|
|
233
|
+
/**
|
|
234
|
+
* @param routeLabels `RouteValidator.getRouteLabels()` of the routes before preprocessing,
|
|
235
|
+
* used to label a route validation refusal.
|
|
236
|
+
*/
|
|
237
|
+
public async startProxy(config: IRustProxyOptions, routeLabels: string[]): Promise<void> {
|
|
238
|
+
assertOneLabelPerRoute(config.routes, routeLabels);
|
|
239
|
+
try {
|
|
240
|
+
await this.bridge.sendCommand('start', { config });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw toRouteValidationError(error, routeLabels);
|
|
243
|
+
}
|
|
160
244
|
}
|
|
161
245
|
|
|
162
246
|
public async stopProxy(): Promise<void> {
|
|
163
247
|
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
|
164
248
|
}
|
|
165
249
|
|
|
166
|
-
|
|
167
|
-
|
|
250
|
+
/**
|
|
251
|
+
* @param routeLabels `RouteValidator.getRouteLabels()` of the routes before preprocessing,
|
|
252
|
+
* used to label a route validation refusal.
|
|
253
|
+
*/
|
|
254
|
+
public async updateRoutes(routes: IRustRouteConfig[], routeLabels: string[]): Promise<void> {
|
|
255
|
+
assertOneLabelPerRoute(routes, routeLabels);
|
|
256
|
+
try {
|
|
257
|
+
await this.bridge.sendCommand('updateRoutes', { routes });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw toRouteValidationError(error, routeLabels);
|
|
260
|
+
}
|
|
168
261
|
}
|
|
169
262
|
|
|
170
263
|
public async setSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void> {
|
|
@@ -12,6 +12,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
|
|
12
12
|
// Route management
|
|
13
13
|
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
|
14
14
|
import { RouteValidator } from './utils/route-validator.js';
|
|
15
|
+
import { RouteValidationError, type IRouteValidationErrorEntry } from './utils/route-validation-error.js';
|
|
15
16
|
import { buildRustProxyOptions } from './utils/rust-config.js';
|
|
16
17
|
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
|
17
18
|
import { Mutex } from './utils/mutex.js';
|
|
@@ -107,7 +108,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
107
108
|
const validation = RouteValidator.validateRoutes(this.settings.routes);
|
|
108
109
|
if (!validation.valid) {
|
|
109
110
|
RouteValidator.logValidationErrors(validation.errors);
|
|
110
|
-
throw new
|
|
111
|
+
throw new RouteValidationError('Initial route validation failed', validation.entries);
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -126,7 +127,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
126
127
|
routes: this.settings.routes,
|
|
127
128
|
});
|
|
128
129
|
|
|
129
|
-
this.bridge = new RustProxyBridge();
|
|
130
|
+
this.bridge = new RustProxyBridge({ binaryPath: this.settings.rustBinaryPath });
|
|
130
131
|
this.preprocessor = new RoutePreprocessor();
|
|
131
132
|
this.metricsAdapter = new RustMetricsAdapter(
|
|
132
133
|
this.bridge,
|
|
@@ -216,7 +217,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
216
217
|
const config = this.buildRustConfig(rustRoutes, acmeForRust);
|
|
217
218
|
|
|
218
219
|
// Start the Rust proxy
|
|
219
|
-
await this.bridge.startProxy(config);
|
|
220
|
+
await this.bridge.startProxy(config, RouteValidator.getRouteLabels(this.settings.routes));
|
|
220
221
|
|
|
221
222
|
// Now that Rust proxy is running, configure socket handler relay
|
|
222
223
|
if (this.socketHandlerServer) {
|
|
@@ -370,7 +371,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
370
371
|
const validation = RouteValidator.validateRoutes(newRoutes);
|
|
371
372
|
if (!validation.valid) {
|
|
372
373
|
RouteValidator.logValidationErrors(validation.errors);
|
|
373
|
-
throw new
|
|
374
|
+
throw new RouteValidationError('Route validation failed', validation.entries);
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes);
|
|
@@ -389,7 +390,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
389
390
|
}
|
|
390
391
|
|
|
391
392
|
// Send to Rust
|
|
392
|
-
await this.bridge.updateRoutes(rustRoutes);
|
|
393
|
+
await this.bridge.updateRoutes(rustRoutes, RouteValidator.getRouteLabels(newRoutes));
|
|
393
394
|
} catch (err) {
|
|
394
395
|
if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) {
|
|
395
396
|
await this.bridge.setChallengeProviderRelay('').catch(() => undefined);
|
|
@@ -580,7 +581,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
580
581
|
for (const route of this.settings.routes || []) {
|
|
581
582
|
if (!route.match.domains) continue;
|
|
582
583
|
if (
|
|
583
|
-
route.action.type !== 'forward' ||
|
|
584
|
+
(route.action.type !== 'forward' && route.action.type !== 'respond') ||
|
|
584
585
|
!route.action.tls ||
|
|
585
586
|
route.action.tls.mode === 'passthrough' ||
|
|
586
587
|
route.action.tls.certificate !== 'auto'
|
|
@@ -729,10 +730,11 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
729
730
|
|
|
730
731
|
private async validateChallengeRoutes(routes: IRouteConfig[]): Promise<void> {
|
|
731
732
|
const manifestCache = new Map<string, plugins.smartchallenge.IChallengeProviderManifest>();
|
|
732
|
-
const
|
|
733
|
+
const labels = RouteValidator.getRouteLabels(routes);
|
|
734
|
+
const entries: IRouteValidationErrorEntry[] = [];
|
|
733
735
|
|
|
734
|
-
for (const route of routes) {
|
|
735
|
-
const
|
|
736
|
+
for (const [index, route] of routes.entries()) {
|
|
737
|
+
const errors: string[] = [];
|
|
736
738
|
const challengeEntries: Array<{
|
|
737
739
|
path: string;
|
|
738
740
|
challenge: plugins.smartchallenge.IRouteChallengeConfig;
|
|
@@ -758,50 +760,50 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
758
760
|
if (challengeEntries.length === 0) continue;
|
|
759
761
|
|
|
760
762
|
if (!route.name && !route.id) {
|
|
761
|
-
errors.push(
|
|
763
|
+
errors.push('challenge routes must set a stable route name or id');
|
|
762
764
|
}
|
|
763
765
|
if (route.action.type !== 'forward') {
|
|
764
|
-
errors.push(
|
|
766
|
+
errors.push('challenge routes must use forward actions');
|
|
765
767
|
}
|
|
766
768
|
if (route.action.forwardingEngine === 'nftables') {
|
|
767
|
-
errors.push(
|
|
769
|
+
errors.push('challenge routes cannot use nftables forwarding');
|
|
768
770
|
}
|
|
769
771
|
if (route.action.tls?.mode === 'passthrough') {
|
|
770
|
-
errors.push(
|
|
772
|
+
errors.push('challenge routes cannot use TLS passthrough');
|
|
771
773
|
}
|
|
772
774
|
for (const target of route.action.targets || []) {
|
|
773
775
|
if (typeof target.host === 'function' || typeof target.port === 'function') {
|
|
774
|
-
errors.push(
|
|
776
|
+
errors.push('challenge routes cannot use dynamic target host or port functions');
|
|
775
777
|
}
|
|
776
778
|
if (target.tls?.mode === 'passthrough') {
|
|
777
|
-
errors.push(
|
|
779
|
+
errors.push('challenge routes cannot use target TLS passthrough');
|
|
778
780
|
}
|
|
779
781
|
}
|
|
780
782
|
const hasHttp3Transport = route.match.transport === 'all'
|
|
781
783
|
&& route.action.udp?.quic?.enableHttp3 === true;
|
|
782
784
|
if (route.match.transport === 'udp') {
|
|
783
|
-
errors.push(
|
|
785
|
+
errors.push('challenge routes cannot use pure UDP transport');
|
|
784
786
|
}
|
|
785
787
|
if (route.match.transport === 'all' && !hasHttp3Transport) {
|
|
786
|
-
errors.push(
|
|
788
|
+
errors.push(`challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
|
|
787
789
|
}
|
|
788
790
|
if (route.match.protocol !== 'http') {
|
|
789
|
-
errors.push(
|
|
791
|
+
errors.push(`challenge routes must set match.protocol to 'http'`);
|
|
790
792
|
}
|
|
791
793
|
|
|
792
794
|
for (const { path, challenge } of challengeEntries) {
|
|
793
795
|
if (!challenge.providerId || typeof challenge.providerId !== 'string') {
|
|
794
|
-
errors.push(`${
|
|
796
|
+
errors.push(`${path}.providerId must be a non-empty string`);
|
|
795
797
|
}
|
|
796
798
|
if (!challenge.challengeType || typeof challenge.challengeType !== 'string') {
|
|
797
|
-
errors.push(`${
|
|
799
|
+
errors.push(`${path}.challengeType must be a non-empty string`);
|
|
798
800
|
}
|
|
799
|
-
this.validateChallengeIntentShape(challenge, errors,
|
|
800
|
-
this.collectForbiddenChallengeKeys(challenge, path, errors
|
|
801
|
+
this.validateChallengeIntentShape(challenge, errors, path);
|
|
802
|
+
this.collectForbiddenChallengeKeys(challenge, path, errors);
|
|
801
803
|
|
|
802
804
|
const provider = this.challengeProviders.get(challenge.providerId);
|
|
803
805
|
if (!provider) {
|
|
804
|
-
errors.push(
|
|
806
|
+
errors.push(`challenge provider '${challenge.providerId}' is not registered`);
|
|
805
807
|
continue;
|
|
806
808
|
}
|
|
807
809
|
let manifest = manifestCache.get(challenge.providerId);
|
|
@@ -810,24 +812,27 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
810
812
|
manifestCache.set(challenge.providerId, manifest);
|
|
811
813
|
}
|
|
812
814
|
if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
|
|
813
|
-
errors.push(
|
|
815
|
+
errors.push(`challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
|
|
814
816
|
}
|
|
815
817
|
}
|
|
818
|
+
|
|
819
|
+
if (errors.length > 0) {
|
|
820
|
+
entries.push({ index, route: labels[index], messages: errors });
|
|
821
|
+
}
|
|
816
822
|
}
|
|
817
823
|
|
|
818
|
-
if (
|
|
819
|
-
throw new
|
|
824
|
+
if (entries.length > 0) {
|
|
825
|
+
throw new RouteValidationError('Challenge route validation failed', entries);
|
|
820
826
|
}
|
|
821
827
|
}
|
|
822
828
|
|
|
823
829
|
private validateChallengeIntentShape(
|
|
824
830
|
challengeArg: unknown,
|
|
825
831
|
errors: string[],
|
|
826
|
-
routeName: string,
|
|
827
832
|
challengePath = 'security.challenge',
|
|
828
833
|
): void {
|
|
829
834
|
if (!this.isPlainRecord(challengeArg)) {
|
|
830
|
-
errors.push(`${
|
|
835
|
+
errors.push(`${challengePath} must be an object`);
|
|
831
836
|
return;
|
|
832
837
|
}
|
|
833
838
|
|
|
@@ -836,25 +841,23 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
836
841
|
challengePath,
|
|
837
842
|
new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']),
|
|
838
843
|
errors,
|
|
839
|
-
routeName,
|
|
840
844
|
);
|
|
841
845
|
|
|
842
846
|
const settings = challengeArg.settings;
|
|
843
847
|
if (settings !== undefined && !this.isPlainRecord(settings)) {
|
|
844
|
-
errors.push(`${
|
|
848
|
+
errors.push(`${challengePath}.settings must be an object when set`);
|
|
845
849
|
}
|
|
846
850
|
|
|
847
851
|
const applyTo = challengeArg.applyTo;
|
|
848
852
|
if (applyTo !== undefined) {
|
|
849
853
|
if (!this.isPlainRecord(applyTo)) {
|
|
850
|
-
errors.push(`${
|
|
854
|
+
errors.push(`${challengePath}.applyTo must be an object when set`);
|
|
851
855
|
} else {
|
|
852
856
|
this.validateAllowedChallengeKeys(
|
|
853
857
|
applyTo,
|
|
854
858
|
`${challengePath}.applyTo`,
|
|
855
859
|
new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']),
|
|
856
860
|
errors,
|
|
857
|
-
routeName,
|
|
858
861
|
);
|
|
859
862
|
}
|
|
860
863
|
}
|
|
@@ -862,14 +865,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
862
865
|
const clearance = challengeArg.clearance;
|
|
863
866
|
if (clearance !== undefined) {
|
|
864
867
|
if (!this.isPlainRecord(clearance)) {
|
|
865
|
-
errors.push(`${
|
|
868
|
+
errors.push(`${challengePath}.clearance must be an object when set`);
|
|
866
869
|
} else {
|
|
867
870
|
this.validateAllowedChallengeKeys(
|
|
868
871
|
clearance,
|
|
869
872
|
`${challengePath}.clearance`,
|
|
870
873
|
new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']),
|
|
871
874
|
errors,
|
|
872
|
-
routeName,
|
|
873
875
|
);
|
|
874
876
|
}
|
|
875
877
|
}
|
|
@@ -880,11 +882,10 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
880
882
|
pathArg: string,
|
|
881
883
|
allowedKeysArg: Set<string>,
|
|
882
884
|
errors: string[],
|
|
883
|
-
routeName: string,
|
|
884
885
|
): void {
|
|
885
886
|
for (const key of Object.keys(valueArg)) {
|
|
886
887
|
if (!allowedKeysArg.has(key)) {
|
|
887
|
-
errors.push(`${
|
|
888
|
+
errors.push(`${pathArg}.${key} is not part of the persisted challenge intent shape`);
|
|
888
889
|
}
|
|
889
890
|
}
|
|
890
891
|
}
|
|
@@ -895,19 +896,19 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
895
896
|
&& !Array.isArray(valueArg);
|
|
896
897
|
}
|
|
897
898
|
|
|
898
|
-
private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[]
|
|
899
|
+
private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[]): void {
|
|
899
900
|
if (!value || typeof value !== 'object') return;
|
|
900
901
|
const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
|
|
901
902
|
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
|
902
903
|
const childPath = `${path}.${key}`;
|
|
903
904
|
if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
|
|
904
|
-
errors.push(`${
|
|
905
|
+
errors.push(`${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
|
|
905
906
|
}
|
|
906
907
|
if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
|
|
907
|
-
errors.push(`${
|
|
908
|
+
errors.push(`${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
|
|
908
909
|
}
|
|
909
910
|
if (nestedValue && typeof nestedValue === 'object') {
|
|
910
|
-
this.collectForbiddenChallengeKeys(nestedValue, childPath, errors
|
|
911
|
+
this.collectForbiddenChallengeKeys(nestedValue, childPath, errors);
|
|
911
912
|
}
|
|
912
913
|
}
|
|
913
914
|
}
|