@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
@@ -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
+ }
@@ -1,12 +1,13 @@
1
1
  import { logger } from '../../../core/utils/logger.js';
2
2
  import type { IInboundProxyProtocolPolicy, IRouteConfig, IRouteMatch, IRouteAction, TPortRange, TTransportProtocol } from '../models/route-types.js';
3
+ import { RouteValidationError, type IRouteValidationErrorEntry } from './route-validation-error.js';
3
4
 
4
5
  /**
5
6
  * Validates route configurations for correctness and safety
6
7
  */
7
8
  export class RouteValidator {
8
9
  private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
9
- private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
10
+ private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler', 'respond'];
10
11
  private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss', 'udp', 'quic', 'http3'];
11
12
  private static readonly MAX_PORTS = 100;
12
13
  private static readonly MAX_DOMAINS = 1000;
@@ -149,6 +150,14 @@ export class RouteValidator {
149
150
  }
150
151
  }
151
152
 
153
+ // Validate fixed response
154
+ if (route.action.type === 'respond') {
155
+ errors.push(...getRespondActionErrors(route.action));
156
+ if (route.match) {
157
+ errors.push(...getRespondMatchErrors(route.match));
158
+ }
159
+ }
160
+
152
161
  // Validate forward target
153
162
  if (route.action.type === 'forward') {
154
163
  if (!route.action.targets || route.action.targets.length === 0) {
@@ -315,62 +324,84 @@ export class RouteValidator {
315
324
  }
316
325
 
317
326
  /**
318
- * Validate multiple route configurations
327
+ * Label every route for validation reports: its name, else its id, else `route[<index>]`.
328
+ * A name or id that several routes share gets `[<index>]` appended, so every label is unique.
329
+ */
330
+ public static getRouteLabels(routes: IRouteConfig[]): string[] {
331
+ const baseLabels = routes.map((route, index) => route.name || route.id || `route[${index}]`);
332
+ const labelCounts = new Map<string, number>();
333
+ for (const label of baseLabels) {
334
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
335
+ }
336
+ return baseLabels.map((label, index) => (labelCounts.get(label)! > 1 ? `${label}[${index}]` : label));
337
+ }
338
+
339
+ /**
340
+ * Validate multiple route configurations.
341
+ *
342
+ * `entries` lists every invalid route by position and label; `errors` maps the same
343
+ * labels to the reasons.
319
344
  */
320
- public static validateRoutes(routes: IRouteConfig[]): { valid: boolean; errors: Map<string, string[]> } {
321
- const errorMap = new Map<string, string[]>();
322
- let valid = true;
345
+ public static validateRoutes(routes: IRouteConfig[]): {
346
+ valid: boolean;
347
+ errors: Map<string, string[]>;
348
+ entries: IRouteValidationErrorEntry[];
349
+ } {
350
+ const messagesByIndex: string[][] = routes.map(() => []);
323
351
 
324
352
  // Check for duplicate route names
325
353
  const routeNames = new Set<string>();
326
- for (const route of routes) {
354
+ routes.forEach((route, index) => {
327
355
  if (route.name && routeNames.has(route.name)) {
328
- const existingErrors = errorMap.get(route.name) || [];
329
- existingErrors.push('Duplicate route name');
330
- errorMap.set(route.name, existingErrors);
331
- valid = false;
356
+ messagesByIndex[index].push('Duplicate route name');
332
357
  }
333
358
  if (route.name) {
334
359
  routeNames.add(route.name);
335
360
  }
336
- }
361
+ });
337
362
 
338
363
  // Validate each route
339
- for (const route of routes) {
340
- const result = this.validateRoute(route);
341
- if (!result.valid) {
342
- errorMap.set(route.name || 'unnamed', result.errors);
343
- valid = false;
344
- }
345
- }
364
+ routes.forEach((route, index) => {
365
+ messagesByIndex[index].push(...this.validateRoute(route).errors);
366
+ });
367
+
368
+ const labels = this.getRouteLabels(routes);
346
369
 
347
370
  // Check for conflicting routes
348
- const conflicts = this.findRouteConflicts(routes);
349
- if (conflicts.length > 0) {
350
- for (const conflict of conflicts) {
351
- const existingErrors = errorMap.get(conflict.route) || [];
352
- existingErrors.push(conflict.message);
353
- errorMap.set(conflict.route, existingErrors);
354
- }
355
- valid = false;
371
+ for (const conflict of this.findRouteConflicts(routes, labels)) {
372
+ messagesByIndex[conflict.index].push(conflict.message);
356
373
  }
357
374
 
358
- return { valid, errors: errorMap };
375
+ const entries: IRouteValidationErrorEntry[] = [];
376
+ messagesByIndex.forEach((messages, index) => {
377
+ if (messages.length > 0) {
378
+ entries.push({ index, route: labels[index], messages });
379
+ }
380
+ });
381
+
382
+ return {
383
+ valid: entries.length === 0,
384
+ errors: new Map(entries.map((entry) => [entry.route, entry.messages])),
385
+ entries,
386
+ };
359
387
  }
360
388
 
361
389
  /**
362
390
  * Find potential conflicts between routes
363
391
  */
364
- private static findRouteConflicts(routes: IRouteConfig[]): Array<{ route: string; message: string }> {
365
- const conflicts: Array<{ route: string; message: string }> = [];
366
-
367
- // Group routes by port
368
- const portMap = new Map<number, IRouteConfig[]>();
369
-
370
- for (const route of routes) {
392
+ private static findRouteConflicts(
393
+ routes: IRouteConfig[],
394
+ labels: string[],
395
+ ): Array<{ index: number; message: string }> {
396
+ const conflicts: Array<{ index: number; message: string }> = [];
397
+
398
+ // Group route indices by port
399
+ const portMap = new Map<number, number[]>();
400
+
401
+ routes.forEach((route, index) => {
371
402
  if (route.match?.ports) {
372
403
  const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
373
-
404
+
374
405
  // Expand port ranges to individual ports
375
406
  const expandedPorts: number[] = [];
376
407
  for (const port of ports) {
@@ -382,25 +413,25 @@ export class RouteValidator {
382
413
  }
383
414
  }
384
415
  }
385
-
416
+
386
417
  for (const port of expandedPorts) {
387
418
  const routesOnPort = portMap.get(port) || [];
388
- routesOnPort.push(route);
419
+ routesOnPort.push(index);
389
420
  portMap.set(port, routesOnPort);
390
421
  }
391
422
  }
392
- }
423
+ });
393
424
 
394
425
  // Check for conflicting catch-all routes on the same port
395
426
  for (const [port, routesOnPort] of portMap) {
396
- const catchAllRoutes = routesOnPort.filter(r =>
397
- this.isCatchAllDomainMatch(r.match) && !this.hasSourceScope(r.match)
427
+ const catchAllRoutes = routesOnPort.filter((index) =>
428
+ this.isCatchAllDomainMatch(routes[index].match) && !this.hasSourceScope(routes[index].match)
398
429
  );
399
-
430
+
400
431
  if (catchAllRoutes.length > 1) {
401
- for (const route of catchAllRoutes) {
432
+ for (const index of catchAllRoutes) {
402
433
  conflicts.push({
403
- route: route.name || 'unnamed',
434
+ index,
404
435
  message: `Multiple catch-all routes on port ${port}`
405
436
  });
406
437
  }
@@ -411,9 +442,9 @@ export class RouteValidator {
411
442
  // a shorthand for the full port/transport listener policy, so every route
412
443
  // sharing that listener must either all omit it or all use an identical one.
413
444
  const listenerPolicies = new Map<string, { route: string; fingerprint: string }>();
414
- for (const route of routes) {
445
+ routes.forEach((route, index) => {
415
446
  if (!route.match?.ports) {
416
- continue;
447
+ return;
417
448
  }
418
449
 
419
450
  for (const port of this.expandPorts(route.match.ports)) {
@@ -423,18 +454,18 @@ export class RouteValidator {
423
454
  const existing = listenerPolicies.get(key);
424
455
  if (!existing) {
425
456
  listenerPolicies.set(key, {
426
- route: route.name || 'unnamed',
457
+ route: labels[index],
427
458
  fingerprint,
428
459
  });
429
460
  } else if (existing.fingerprint !== fingerprint) {
430
461
  conflicts.push({
431
- route: route.name || 'unnamed',
462
+ index,
432
463
  message: `Conflicting inbound PROXY protocol policy on ${transport} port ${port} (differs from ${existing.route})`,
433
464
  });
434
465
  }
435
466
  }
436
467
  }
437
- }
468
+ });
438
469
 
439
470
  return conflicts;
440
471
  }
@@ -719,6 +750,120 @@ export function validateRouteMatch(match: IRouteMatch): { valid: boolean; errors
719
750
  };
720
751
  }
721
752
 
753
+ /** Largest fixed-response body a `respond` route may carry, in UTF-8 bytes. */
754
+ export const MAX_FIXED_RESPONSE_BODY_BYTES = 65536;
755
+
756
+ /** Hop-by-hop or computed headers a fixed response must not set. */
757
+ const FIXED_RESPONSE_FORBIDDEN_HEADERS = [
758
+ 'connection',
759
+ 'keep-alive',
760
+ 'transfer-encoding',
761
+ 'upgrade',
762
+ 'te',
763
+ 'trailer',
764
+ 'proxy-connection',
765
+ 'content-length',
766
+ ];
767
+
768
+ const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
769
+ const HTTP_HEADER_VALUE_PATTERN = /^[\t\x20-\x7E]*$/;
770
+
771
+ /**
772
+ * Returns the action-level errors of a `respond` route: the fixed response itself,
773
+ * and the forwarding, handler, UDP and TLS options a fixed response cannot use.
774
+ */
775
+ function getRespondActionErrors(action: IRouteAction): string[] {
776
+ const errors: string[] = [];
777
+
778
+ if (action.targets !== undefined) {
779
+ errors.push('respond action must not define targets');
780
+ }
781
+ if (action.socketHandler !== undefined || action.datagramHandler !== undefined) {
782
+ errors.push('respond action must not define a socketHandler or datagramHandler');
783
+ }
784
+ if (action.forwardingEngine !== undefined || action.nftables !== undefined) {
785
+ errors.push('respond action must not define forwardingEngine or nftables options');
786
+ }
787
+ if (action.udp !== undefined) {
788
+ errors.push('respond action does not support UDP or HTTP/3 (action.udp)');
789
+ }
790
+ if (action.tls && action.tls.mode !== 'terminate') {
791
+ errors.push(`respond action supports only TLS mode "terminate", got "${action.tls.mode}"`);
792
+ }
793
+
794
+ const response = action.response;
795
+ if (!response || typeof response !== 'object') {
796
+ errors.push('respond action requires a response object');
797
+ return errors;
798
+ }
799
+
800
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status > 599) {
801
+ errors.push(`response.status must be an integer from 200 to 599, got ${response.status}`);
802
+ }
803
+
804
+ const seenHeaders = new Set<string>();
805
+ let hasContentType = false;
806
+ if (response.headers !== undefined) {
807
+ if (!response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
808
+ errors.push('response.headers must be an object of header names to string values');
809
+ } else {
810
+ for (const [name, value] of Object.entries(response.headers)) {
811
+ const lowerName = name.toLowerCase();
812
+ if (!HTTP_TOKEN_PATTERN.test(name)) {
813
+ errors.push(`response.headers has an invalid header name: ${name}`);
814
+ continue;
815
+ }
816
+ if (FIXED_RESPONSE_FORBIDDEN_HEADERS.includes(lowerName)) {
817
+ errors.push(`response.headers must not set ${lowerName}`);
818
+ }
819
+ if (seenHeaders.has(lowerName)) {
820
+ errors.push(`response.headers sets ${lowerName} more than once`);
821
+ }
822
+ seenHeaders.add(lowerName);
823
+ if (typeof value !== 'string' || !HTTP_HEADER_VALUE_PATTERN.test(value)) {
824
+ errors.push(`response.headers.${name} must be a string of visible ASCII characters`);
825
+ }
826
+ if (lowerName === 'content-type') {
827
+ hasContentType = true;
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ if (response.body !== undefined) {
834
+ if (typeof response.body !== 'string') {
835
+ errors.push('response.body must be a string');
836
+ } else if (response.body.length > 0) {
837
+ const bodyBytes = new TextEncoder().encode(response.body).length;
838
+ if (bodyBytes > MAX_FIXED_RESPONSE_BODY_BYTES) {
839
+ errors.push(`response.body is ${bodyBytes} bytes, the maximum is ${MAX_FIXED_RESPONSE_BODY_BYTES}`);
840
+ }
841
+ if (response.status === 204 || response.status === 304) {
842
+ errors.push(`response.status ${response.status} must not carry a body`);
843
+ }
844
+ if (!hasContentType) {
845
+ errors.push('response.body requires an explicit content-type header');
846
+ }
847
+ }
848
+ }
849
+
850
+ return errors;
851
+ }
852
+
853
+ /**
854
+ * Returns the match-level errors of a `respond` route: a fixed response is HTTP over TCP only.
855
+ */
856
+ function getRespondMatchErrors(match: IRouteMatch): string[] {
857
+ const errors: string[] = [];
858
+ if (match.transport === 'udp' || match.transport === 'all') {
859
+ errors.push(`respond routes support only TCP transport, got "${match.transport}"`);
860
+ }
861
+ if (match.protocol !== undefined && match.protocol !== 'http') {
862
+ errors.push(`respond routes support only protocol "http", got "${match.protocol}"`);
863
+ }
864
+ return errors;
865
+ }
866
+
722
867
  /**
723
868
  * Validates a route action configuration
724
869
  * @param action Route action configuration to validate
@@ -729,10 +874,14 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
729
874
 
730
875
  if (!action.type) {
731
876
  errors.push('Action type is required');
732
- } else if (!['forward', 'socket-handler'].includes(action.type)) {
877
+ } else if (!['forward', 'socket-handler', 'respond'].includes(action.type)) {
733
878
  errors.push(`Invalid action type: ${action.type}`);
734
879
  }
735
880
 
881
+ if (action.type === 'respond') {
882
+ errors.push(...getRespondActionErrors(action));
883
+ }
884
+
736
885
  if (action.type === 'forward') {
737
886
  if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
738
887
  errors.push('Targets array is required for forward action');
@@ -836,6 +985,10 @@ export function validateRouteConfig(route: IRouteConfig): { valid: boolean; erro
836
985
  }
837
986
  }
838
987
 
988
+ if (route.match && route.action?.type === 'respond') {
989
+ errors.push(...getRespondMatchErrors(route.match).map(err => `Match: ${err}`));
990
+ }
991
+
839
992
  return {
840
993
  valid: errors.length === 0,
841
994
  errors
@@ -889,6 +1042,8 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
889
1042
  case 'socket-handler':
890
1043
  return (!!route.action.socketHandler && typeof route.action.socketHandler === 'function') ||
891
1044
  (!!route.action.datagramHandler && typeof route.action.datagramHandler === 'function');
1045
+ case 'respond':
1046
+ return !!route.action.response && typeof route.action.response.status === 'number';
892
1047
  default:
893
1048
  return false;
894
1049
  }
@@ -899,12 +1054,14 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
899
1054
  * Useful for immediate validation when creating routes
900
1055
  * @param route Route configuration to validate
901
1056
  * @returns The validated route configuration
902
- * @throws Error if the route configuration is invalid
1057
+ * @throws RouteValidationError if the route configuration is invalid
903
1058
  */
904
1059
  export function assertValidRoute(route: IRouteConfig): IRouteConfig {
905
1060
  const validation = validateRouteConfig(route);
906
1061
  if (!validation.valid) {
907
- throw new Error(`Invalid route configuration: ${validation.errors.join(', ')}`);
1062
+ throw new RouteValidationError('Invalid route configuration', [
1063
+ { index: 0, route: RouteValidator.getRouteLabels([route])[0], messages: validation.errors },
1064
+ ]);
908
1065
  }
909
1066
  return route;
910
1067
  }