@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.
Files changed (33) hide show
  1. package/changelog.md +47 -0
  2. package/dist_rust/rustproxy_linux_amd64 +0 -0
  3. package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +5 -5
  4. package/dist_rust/rustproxy_linux_arm64 +0 -0
  5. package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +2 -2
  7. package/dist_ts/index.d.ts +1 -1
  8. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +4 -2
  9. package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +12 -6
  10. package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
  11. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +17 -0
  12. package/dist_ts/proxies/smart-proxy/models/rust-types.js +3 -2
  13. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +28 -4
  14. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +75 -6
  15. package/dist_ts/proxies/smart-proxy/smart-proxy.js +46 -41
  16. package/dist_ts/proxies/smart-proxy/utils/index.d.ts +2 -0
  17. package/dist_ts/proxies/smart-proxy/utils/index.js +3 -1
  18. package/dist_ts/proxies/smart-proxy/utils/route-validation-error.d.ts +24 -0
  19. package/dist_ts/proxies/smart-proxy/utils/route-validation-error.js +41 -0
  20. package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +14 -2
  21. package/dist_ts/proxies/smart-proxy/utils/route-validator.js +178 -43
  22. package/package.json +3 -3
  23. package/readme.md +74 -4
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/index.ts +1 -1
  26. package/ts/proxies/smart-proxy/models/interfaces.ts +4 -2
  27. package/ts/proxies/smart-proxy/models/route-types.ts +14 -6
  28. package/ts/proxies/smart-proxy/models/rust-types.ts +20 -0
  29. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +106 -13
  30. package/ts/proxies/smart-proxy/smart-proxy.ts +41 -40
  31. package/ts/proxies/smart-proxy/utils/index.ts +4 -0
  32. package/ts/proxies/smart-proxy/utils/route-validation-error.ts +60 -0
  33. 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` or `socket-handler`)
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.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartproxy',
6
- version: '27.22.0',
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. If not set, the binary is located
293
- * automatically via env var, platform package, local build, or PATH.
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
- * Test route response configuration
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 IRouteTestResponse {
238
+ export interface IRouteFixedResponse {
233
239
  status: number;
234
- headers: Record<string, string>;
235
- body: string;
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 type {
4
- IRustCertificateStatus,
5
- IRustChallengeOptions,
6
- IRustMetricsSnapshot,
7
- IRustProxyOptions,
8
- IRustMetricsHistory,
9
- IRustRouteConfig,
10
- IRustStatistics,
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
- public async startProxy(config: IRustProxyOptions): Promise<void> {
159
- await this.bridge.sendCommand('start', { config });
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
- public async updateRoutes(routes: IRustRouteConfig[]): Promise<void> {
167
- await this.bridge.sendCommand('updateRoutes', { routes });
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 Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
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 Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
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 errors: string[] = [];
733
+ const labels = RouteValidator.getRouteLabels(routes);
734
+ const entries: IRouteValidationErrorEntry[] = [];
733
735
 
734
- for (const route of routes) {
735
- const routeName = route.name || route.id || 'unnamed route';
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(`${routeName}: challenge routes must set a stable route name or id`);
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(`${routeName}: challenge routes must use forward actions`);
766
+ errors.push('challenge routes must use forward actions');
765
767
  }
766
768
  if (route.action.forwardingEngine === 'nftables') {
767
- errors.push(`${routeName}: challenge routes cannot use nftables forwarding`);
769
+ errors.push('challenge routes cannot use nftables forwarding');
768
770
  }
769
771
  if (route.action.tls?.mode === 'passthrough') {
770
- errors.push(`${routeName}: challenge routes cannot use TLS passthrough`);
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(`${routeName}: challenge routes cannot use dynamic target host or port functions`);
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(`${routeName}: challenge routes cannot use target TLS passthrough`);
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(`${routeName}: challenge routes cannot use pure UDP transport`);
785
+ errors.push('challenge routes cannot use pure UDP transport');
784
786
  }
785
787
  if (route.match.transport === 'all' && !hasHttp3Transport) {
786
- errors.push(`${routeName}: challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
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(`${routeName}: challenge routes must set match.protocol to 'http'`);
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(`${routeName}: ${path}.providerId must be a non-empty string`);
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(`${routeName}: ${path}.challengeType must be a non-empty string`);
799
+ errors.push(`${path}.challengeType must be a non-empty string`);
798
800
  }
799
- this.validateChallengeIntentShape(challenge, errors, routeName, path);
800
- this.collectForbiddenChallengeKeys(challenge, path, errors, routeName);
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(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
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(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
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 (errors.length > 0) {
819
- throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
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(`${routeName}: ${challengePath} must be an object`);
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(`${routeName}: ${challengePath}.settings must be an object when set`);
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(`${routeName}: ${challengePath}.applyTo must be an object when set`);
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(`${routeName}: ${challengePath}.clearance must be an object when set`);
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(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
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[], routeName: string): void {
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(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
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(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
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, routeName);
911
+ this.collectForbiddenChallengeKeys(nestedValue, childPath, errors);
911
912
  }
912
913
  }
913
914
  }