@push.rocks/smartproxy 28.0.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 +30 -0
- package/dist_rust/rustproxy_linux_amd64 +0 -0
- package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +4 -4
- package/dist_rust/rustproxy_linux_arm64 +0 -0
- package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +4 -2
- 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 +45 -40
- 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 +12 -2
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +56 -41
- package/package.json +2 -2
- package/readme.md +32 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/proxies/smart-proxy/models/interfaces.ts +4 -2
- 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 +40 -39
- 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 +74 -49
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// Export route validator (class-based and functional API)
|
|
9
9
|
export * from './route-validator.js';
|
|
10
10
|
|
|
11
|
+
// Export the error SmartProxy throws when it refuses a route set
|
|
12
|
+
export { RouteValidationError } from './route-validation-error.js';
|
|
13
|
+
export type { IRouteValidationErrorEntry } from './route-validation-error.js';
|
|
14
|
+
|
|
11
15
|
// Export route utilities for route operations
|
|
12
16
|
export * from './route-utils.js';
|
|
13
17
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The validation failures of one route, as the route validators report them.
|
|
3
|
+
*/
|
|
4
|
+
export interface IRouteValidationErrorEntry {
|
|
5
|
+
/** The route's position in the validated route list. */
|
|
6
|
+
index: number;
|
|
7
|
+
/**
|
|
8
|
+
* The route's label from `RouteValidator.getRouteLabels()`: its name, else its id,
|
|
9
|
+
* else `route[<index>]`; a name or id that several routes share gets `[<index>]` appended.
|
|
10
|
+
*/
|
|
11
|
+
route: string;
|
|
12
|
+
/** Every reason the route was rejected. */
|
|
13
|
+
messages: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_ROUTES_IN_MESSAGE = 10;
|
|
17
|
+
const MAX_MESSAGES_PER_ROUTE_IN_MESSAGE = 5;
|
|
18
|
+
const MAX_TEXT_LENGTH_IN_MESSAGE = 200;
|
|
19
|
+
|
|
20
|
+
function truncateText(textArg: string): string {
|
|
21
|
+
return textArg.length > MAX_TEXT_LENGTH_IN_MESSAGE
|
|
22
|
+
? `${textArg.slice(0, MAX_TEXT_LENGTH_IN_MESSAGE)}…`
|
|
23
|
+
: textArg;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatEntry(entryArg: IRouteValidationErrorEntry): string {
|
|
27
|
+
const shownMessages = entryArg.messages
|
|
28
|
+
.slice(0, MAX_MESSAGES_PER_ROUTE_IN_MESSAGE)
|
|
29
|
+
.map(truncateText);
|
|
30
|
+
const hiddenMessageCount = entryArg.messages.length - shownMessages.length;
|
|
31
|
+
if (hiddenMessageCount > 0) {
|
|
32
|
+
shownMessages.push(`and ${hiddenMessageCount} more`);
|
|
33
|
+
}
|
|
34
|
+
return `route '${truncateText(entryArg.route)}': ${shownMessages.join('; ')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatMessage(summaryArg: string, errorsArg: IRouteValidationErrorEntry[]): string {
|
|
38
|
+
const shownRoutes = errorsArg.slice(0, MAX_ROUTES_IN_MESSAGE).map(formatEntry);
|
|
39
|
+
const hiddenRouteCount = errorsArg.length - shownRoutes.length;
|
|
40
|
+
if (hiddenRouteCount > 0) {
|
|
41
|
+
shownRoutes.push(`and ${hiddenRouteCount} more route(s)`);
|
|
42
|
+
}
|
|
43
|
+
return `${summaryArg}: ${errorsArg.length} route(s) have errors: ${shownRoutes.join(' | ')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown when SmartProxy refuses a route set because it failed validation.
|
|
48
|
+
*
|
|
49
|
+
* `errors` lists every rejected route with all its reasons. The message names the
|
|
50
|
+
* first routes and reasons only, so a large route set cannot produce an unbounded message.
|
|
51
|
+
*/
|
|
52
|
+
export class RouteValidationError extends Error {
|
|
53
|
+
public readonly errors: IRouteValidationErrorEntry[];
|
|
54
|
+
|
|
55
|
+
constructor(summaryArg: string, errorsArg: IRouteValidationErrorEntry[]) {
|
|
56
|
+
super(formatMessage(summaryArg, errorsArg));
|
|
57
|
+
this.name = 'RouteValidationError';
|
|
58
|
+
this.errors = errorsArg;
|
|
59
|
+
}
|
|
60
|
+
}
|