@push.rocks/smartproxy 29.1.0 → 30.0.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.
@@ -9,7 +9,7 @@
9
9
  export * from './route-validator.js';
10
10
 
11
11
  // Export the error SmartProxy throws when it refuses a route set
12
- export { RouteValidationError } from './route-validation-error.js';
12
+ export { RouteValidationError, SettingsValidationError } from './route-validation-error.js';
13
13
  export type { IRouteValidationErrorEntry } from './route-validation-error.js';
14
14
 
15
15
  // Export route utilities for route operations
@@ -0,0 +1,201 @@
1
+ import * as plugins from '../../../plugins.js';
2
+
3
+ /**
4
+ * Validation of configured IP entries, one rule per Rust parser that reads them.
5
+ *
6
+ * Every check accepts exactly what its Rust reader can match and answers why it refuses anything
7
+ * else, so a configuration that validates never carries an entry the engine silently ignores:
8
+ *
9
+ * - Security lists (`security.ipAllowList`, `security.ipBlockList`, `defaults.security`,
10
+ * `securityPolicy.blockedIps`/`blockedCidrs`) are read by `rustproxy-security` `IpPattern::parse`:
11
+ * `*`, an address or an `ipnet` prefix, compared as parsed addresses with the client converted
12
+ * from IPv4-mapped IPv6 to IPv4. Anything else becomes `0.0.0.0` and never matches.
13
+ * - Source matches (`match.clientIp`, `trustedProxyIPs` of a route policy or the settings) are read
14
+ * by `rustproxy-routing` `matchers::ip_matches`: `*`, an `ipnet` prefix, a four-part IPv4 wildcard,
15
+ * or an exact address compared as text against the client's Rust `Display` form, after a leading
16
+ * `::ffff:` is stripped from both sides.
17
+ * - VPN assigned addresses (`security.vpn.allowedAssignedIps`) are compared as text against the
18
+ * address the VPN metadata carries.
19
+ *
20
+ * A check answers `null` for an entry it accepts, else the reason it refuses it.
21
+ */
22
+
23
+ const ipv4MappedPrefixLength = 96;
24
+
25
+ /** An address without a zone index, in any textual form `node:net` and Rust both read. */
26
+ const ipFamilyOf = (value: string): 0 | 4 | 6 => {
27
+ if (value.includes('%')) return 0;
28
+ return plugins.net.isIP(value) as 0 | 4 | 6;
29
+ };
30
+
31
+ /** The eight 16-bit segments of an IPv6 address `node:net` already accepted. */
32
+ const ipv6SegmentsOf = (address: string): number[] => {
33
+ const toSegments = (text: string): number[] => {
34
+ if (text === '') return [];
35
+ const segments: number[] = [];
36
+ for (const part of text.split(':')) {
37
+ if (part.includes('.')) {
38
+ const [a, b, c, d] = part.split('.').map(Number);
39
+ segments.push((a << 8) | b, (c << 8) | d);
40
+ } else {
41
+ segments.push(parseInt(part, 16));
42
+ }
43
+ }
44
+ return segments;
45
+ };
46
+ const compressionAt = address.indexOf('::');
47
+ if (compressionAt === -1) {
48
+ return toSegments(address);
49
+ }
50
+ const head = toSegments(address.slice(0, compressionAt));
51
+ const tail = toSegments(address.slice(compressionAt + 2));
52
+ return [...head, ...new Array<number>(8 - head.length - tail.length).fill(0), ...tail];
53
+ };
54
+
55
+ /** The IPv4 address an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) carries, else `null`. */
56
+ const ipv4MappedOf = (segments: number[]): string | null => {
57
+ if (segments.slice(0, 5).some((segment) => segment !== 0) || segments[5] !== 0xffff) {
58
+ return null;
59
+ }
60
+ return [segments[6] >> 8, segments[6] & 0xff, segments[7] >> 8, segments[7] & 0xff].join('.');
61
+ };
62
+
63
+ /**
64
+ * The address as Rust's `Display` writes it: IPv4 unchanged (`node:net` admits only plain decimal
65
+ * octets), IPv4-mapped IPv6 as `::ffff:a.b.c.d`, any other IPv6 in lowercase hex without leading
66
+ * zeros and with the first longest run of two or more zero segments written as `::`.
67
+ */
68
+ export const formatIpAddress = (address: string): string => {
69
+ if (ipFamilyOf(address) !== 6) {
70
+ return address;
71
+ }
72
+ const segments = ipv6SegmentsOf(address);
73
+ const mapped = ipv4MappedOf(segments);
74
+ if (mapped !== null) {
75
+ return `::ffff:${mapped}`;
76
+ }
77
+ let longestStart = 0;
78
+ let longestLength = 0;
79
+ let currentStart = 0;
80
+ let currentLength = 0;
81
+ segments.forEach((segment, index) => {
82
+ if (segment === 0) {
83
+ if (currentLength === 0) currentStart = index;
84
+ currentLength += 1;
85
+ if (currentLength > longestLength) {
86
+ longestStart = currentStart;
87
+ longestLength = currentLength;
88
+ }
89
+ } else {
90
+ currentLength = 0;
91
+ }
92
+ });
93
+ const hex = (part: number[]) => part.map((segment) => segment.toString(16)).join(':');
94
+ if (longestLength > 1) {
95
+ return `${hex(segments.slice(0, longestStart))}::${hex(segments.slice(longestStart + longestLength))}`;
96
+ }
97
+ return hex(segments);
98
+ };
99
+
100
+ interface IParsedPrefix {
101
+ address: string;
102
+ family: 4 | 6;
103
+ length: number;
104
+ }
105
+
106
+ /**
107
+ * An address, `/` and a prefix length as `ipnet` parses it: up to two digits and at most 32 for
108
+ * IPv4, up to three digits and at most 128 for IPv6. Host bits may be set.
109
+ */
110
+ const parsePrefix = (value: string): IParsedPrefix | null => {
111
+ const parts = value.split('/');
112
+ if (parts.length !== 2) return null;
113
+ const [address, lengthText] = parts;
114
+ const family = ipFamilyOf(address);
115
+ if (family === 4 && /^\d{1,2}$/.test(lengthText) && Number(lengthText) <= 32) {
116
+ return { address, family, length: Number(lengthText) };
117
+ }
118
+ if (family === 6 && /^\d{1,3}$/.test(lengthText) && Number(lengthText) <= 128) {
119
+ return { address, family, length: Number(lengthText) };
120
+ }
121
+ return null;
122
+ };
123
+
124
+ /** The IPv4 prefix an IPv4-mapped IPv6 prefix of at least 96 bits covers, else `null`. */
125
+ const ipv4PrefixOfMapped = (prefix: IParsedPrefix): string | null => {
126
+ if (prefix.family !== 6 || prefix.length < ipv4MappedPrefixLength) return null;
127
+ const mapped = ipv4MappedOf(ipv6SegmentsOf(prefix.address));
128
+ return mapped === null ? null : `${mapped}/${prefix.length - ipv4MappedPrefixLength}`;
129
+ };
130
+
131
+ /** A four-part IPv4 wildcard with plain decimal octets first and only `*` parts after them. */
132
+ const isIpv4TrailingWildcard = (value: string): boolean => {
133
+ const parts = value.split('.');
134
+ if (parts.length !== 4) return false;
135
+ const firstWildcard = parts.indexOf('*');
136
+ if (firstWildcard === -1) return false;
137
+ return parts.slice(0, firstWildcard).every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255)
138
+ && parts.slice(firstWildcard).every((part) => part === '*');
139
+ };
140
+
141
+ const zoneIndexReason = 'a zone index (%…) is not supported';
142
+
143
+ /** Checks an entry of a security list: `ipAllowList`, `ipBlockList` or a global block list. */
144
+ export const checkSecurityIpEntry = (entry: unknown): string | null => {
145
+ if (typeof entry !== 'string') return 'must be a string';
146
+ if (entry === '*') return null;
147
+ if (entry.includes('%')) return zoneIndexReason;
148
+ const family = ipFamilyOf(entry);
149
+ if (family !== 0) {
150
+ const mapped = family === 6 ? ipv4MappedOf(ipv6SegmentsOf(entry)) : null;
151
+ return mapped === null
152
+ ? null
153
+ : `an IPv4-mapped address never matches, because clients are compared as IPv4; write ${mapped}`;
154
+ }
155
+ const prefix = parsePrefix(entry);
156
+ if (prefix) {
157
+ const ipv4Prefix = ipv4PrefixOfMapped(prefix);
158
+ return ipv4Prefix === null
159
+ ? null
160
+ : `an IPv4-mapped prefix never matches, because clients are compared as IPv4; write ${ipv4Prefix}`;
161
+ }
162
+ return "must be '*', an IPv4 or IPv6 address, or an address prefix such as 192.0.2.0/24";
163
+ };
164
+
165
+ /** Checks a source-match entry: `match.clientIp` or a `trustedProxyIPs` entry. */
166
+ export const checkSourceMatchIpEntry = (entry: unknown): string | null => {
167
+ if (typeof entry !== 'string') return 'must be a string';
168
+ if (entry === '*') return null;
169
+ if (entry.includes('%')) return zoneIndexReason;
170
+ if (ipFamilyOf(entry) !== 0) {
171
+ const canonical = formatIpAddress(entry);
172
+ return canonical === entry
173
+ ? null
174
+ : `an exact address is compared as text with the client address; write ${canonical}`;
175
+ }
176
+ const prefix = parsePrefix(entry);
177
+ if (prefix) {
178
+ const ipv4Prefix = ipv4PrefixOfMapped(prefix);
179
+ return ipv4Prefix === null
180
+ ? null
181
+ : `an IPv4-mapped prefix never matches, because clients are compared as IPv4; write ${ipv4Prefix}`;
182
+ }
183
+ if (isIpv4TrailingWildcard(entry)) return null;
184
+ return "must be '*', an IPv4 or IPv6 address, an address prefix such as 192.0.2.0/24, "
185
+ + 'or a four-part IPv4 wildcard such as 192.0.2.*';
186
+ };
187
+
188
+ /** Checks a `security.vpn.allowedAssignedIps` entry. */
189
+ export const checkAssignedIpEntry = (entry: unknown): string | null => {
190
+ if (typeof entry !== 'string') return 'must be a string';
191
+ if (entry.includes('%')) return zoneIndexReason;
192
+ if (ipFamilyOf(entry) === 0) return 'must be one IPv4 or IPv6 address';
193
+ const canonical = formatIpAddress(entry);
194
+ return canonical === entry
195
+ ? null
196
+ : `an assigned address is compared as text with the VPN client's address; write ${canonical}`;
197
+ };
198
+
199
+ /** Formats one refused entry for a validation message. */
200
+ export const describeRefusedIpEntry = (labelArg: string, entryArg: unknown, reasonArg: string): string =>
201
+ `${labelArg}: ${String(entryArg)} (${reasonArg})`;
@@ -58,3 +58,23 @@ export class RouteValidationError extends Error {
58
58
  this.errors = errorsArg;
59
59
  }
60
60
  }
61
+
62
+ /**
63
+ * Thrown when SmartProxy refuses settings outside its routes because they failed validation.
64
+ *
65
+ * `messages` lists every reason. The message names the first reasons only.
66
+ */
67
+ export class SettingsValidationError extends Error {
68
+ public readonly messages: string[];
69
+
70
+ constructor(summaryArg: string, messagesArg: string[]) {
71
+ const shownMessages = messagesArg.slice(0, MAX_MESSAGES_PER_ROUTE_IN_MESSAGE).map(truncateText);
72
+ const hiddenMessageCount = messagesArg.length - shownMessages.length;
73
+ if (hiddenMessageCount > 0) {
74
+ shownMessages.push(`and ${hiddenMessageCount} more`);
75
+ }
76
+ super(`${summaryArg}: ${shownMessages.join('; ')}`);
77
+ this.name = 'SettingsValidationError';
78
+ this.messages = messagesArg;
79
+ }
80
+ }
@@ -1,5 +1,12 @@
1
1
  import { logger } from '../../../core/utils/logger.js';
2
+ import {
3
+ checkAssignedIpEntry,
4
+ checkSecurityIpEntry,
5
+ checkSourceMatchIpEntry,
6
+ describeRefusedIpEntry,
7
+ } from './ip-entry-validation.js';
2
8
  import type { IInboundProxyProtocolPolicy, IRouteConfig, IRouteMatch, IRouteAction, TPortRange, TTransportProtocol } from '../models/route-types.js';
9
+ import type { ISmartProxyOptions, ISmartProxySecurityPolicy } from '../models/interfaces.js';
3
10
  import { RouteValidationError, type IRouteValidationErrorEntry } from './route-validation-error.js';
4
11
 
5
12
  /**
@@ -88,8 +95,9 @@ export class RouteValidator {
88
95
  const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp];
89
96
 
90
97
  for (const ip of ips) {
91
- if (!this.isValidIPPattern(ip)) {
92
- errors.push(`Invalid IP pattern: ${ip}`);
98
+ const reason = checkSourceMatchIpEntry(ip);
99
+ if (reason !== null) {
100
+ errors.push(describeRefusedIpEntry('Invalid IP pattern', ip, reason));
93
101
  }
94
102
  }
95
103
  }
@@ -105,8 +113,9 @@ export class RouteValidator {
105
113
  errors.push('inboundProxyProtocol.trustedProxyIPs must be a non-empty array when specified');
106
114
  } else {
107
115
  for (const ip of policy.trustedProxyIPs) {
108
- if (!this.isValidIPPattern(ip)) {
109
- errors.push(`Invalid trusted proxy IP pattern: ${ip}`);
116
+ const reason = checkSourceMatchIpEntry(ip);
117
+ if (reason !== null) {
118
+ errors.push(describeRefusedIpEntry('Invalid trusted proxy IP pattern', ip, reason));
110
119
  }
111
120
  }
112
121
  }
@@ -226,17 +235,19 @@ export class RouteValidator {
226
235
  const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
227
236
 
228
237
  for (const entry of allowList) {
229
- if (typeof entry === 'string') {
230
- if (!this.isValidIPPattern(entry)) {
231
- errors.push(`Invalid IP pattern in allow list: ${entry}`);
232
- }
233
- } else if (entry && typeof entry === 'object') {
234
- if (!this.isValidIPPattern(entry.ip)) {
235
- errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`);
238
+ if (entry && typeof entry === 'object') {
239
+ const reason = checkSecurityIpEntry(entry.ip);
240
+ if (reason !== null) {
241
+ errors.push(describeRefusedIpEntry('Invalid IP pattern in domain-scoped allow entry', entry.ip, reason));
236
242
  }
237
243
  if (!Array.isArray(entry.domains) || entry.domains.length === 0) {
238
244
  errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`);
239
245
  }
246
+ } else {
247
+ const reason = checkSecurityIpEntry(entry);
248
+ if (reason !== null) {
249
+ errors.push(describeRefusedIpEntry('Invalid IP pattern in allow list', entry, reason));
250
+ }
240
251
  }
241
252
  }
242
253
  }
@@ -245,8 +256,23 @@ export class RouteValidator {
245
256
  const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList];
246
257
 
247
258
  for (const ip of blockList) {
248
- if (!this.isValidIPPattern(ip)) {
249
- errors.push(`Invalid IP pattern in block list: ${ip}`);
259
+ const reason = checkSecurityIpEntry(ip);
260
+ if (reason !== null) {
261
+ errors.push(describeRefusedIpEntry('Invalid IP pattern in block list', ip, reason));
262
+ }
263
+ }
264
+ }
265
+
266
+ const assignedIps = route.security.vpn?.allowedAssignedIps;
267
+ if (assignedIps !== undefined) {
268
+ if (!Array.isArray(assignedIps)) {
269
+ errors.push('security.vpn.allowedAssignedIps must be an array');
270
+ } else {
271
+ for (const ip of assignedIps) {
272
+ const reason = checkAssignedIpEntry(ip);
273
+ if (reason !== null) {
274
+ errors.push(describeRefusedIpEntry('Invalid VPN assigned IP', ip, reason));
275
+ }
250
276
  }
251
277
  }
252
278
  }
@@ -336,6 +362,61 @@ export class RouteValidator {
336
362
  return baseLabels.map((label, index) => (labelCounts.get(label)! > 1 ? `${label}[${index}]` : label));
337
363
  }
338
364
 
365
+ /**
366
+ * Validate the IP entries of the settings outside routes: the global `trustedProxyIPs`, the
367
+ * `defaults.security` lists the engine copies into routes without security, and the global
368
+ * `securityPolicy`. Answers every reason, empty when the settings are valid.
369
+ */
370
+ public static validateSettings(
371
+ settings: Pick<ISmartProxyOptions, 'trustedProxyIPs' | 'defaults' | 'securityPolicy'>,
372
+ ): string[] {
373
+ const errors: string[] = [];
374
+ const checkList = (
375
+ list: unknown,
376
+ field: string,
377
+ check: (entry: unknown) => string | null,
378
+ ) => {
379
+ if (list === undefined) return;
380
+ if (!Array.isArray(list)) {
381
+ errors.push(`${field} must be an array`);
382
+ return;
383
+ }
384
+ for (const entry of list) {
385
+ const reason = check(entry);
386
+ if (reason !== null) {
387
+ errors.push(describeRefusedIpEntry(`Invalid IP pattern in ${field}`, entry, reason));
388
+ }
389
+ }
390
+ };
391
+ checkList(settings.trustedProxyIPs, 'trustedProxyIPs', checkSourceMatchIpEntry);
392
+ checkList(settings.defaults?.security?.ipAllowList, 'defaults.security.ipAllowList', checkSecurityIpEntry);
393
+ checkList(settings.defaults?.security?.ipBlockList, 'defaults.security.ipBlockList', checkSecurityIpEntry);
394
+ if (settings.securityPolicy) {
395
+ errors.push(...this.validateSecurityPolicy(settings.securityPolicy));
396
+ }
397
+ return errors;
398
+ }
399
+
400
+ /** Validate the IP entries of a global ingress security policy. */
401
+ public static validateSecurityPolicy(policy: ISmartProxySecurityPolicy): string[] {
402
+ const errors: string[] = [];
403
+ for (const field of ['blockedIps', 'blockedCidrs'] as const) {
404
+ const list: unknown = policy[field];
405
+ if (list === undefined) continue;
406
+ if (!Array.isArray(list)) {
407
+ errors.push(`securityPolicy.${field} must be an array`);
408
+ continue;
409
+ }
410
+ for (const entry of list) {
411
+ const reason = checkSecurityIpEntry(entry);
412
+ if (reason !== null) {
413
+ errors.push(describeRefusedIpEntry(`Invalid IP pattern in securityPolicy.${field}`, entry, reason));
414
+ }
415
+ }
416
+ }
417
+ return errors;
418
+ }
419
+
339
420
  /**
340
421
  * Validate multiple route configurations.
341
422
  *
@@ -567,76 +648,6 @@ export class RouteValidator {
567
648
  return true;
568
649
  }
569
650
 
570
- /**
571
- * Validate IP pattern
572
- */
573
- private static isValidIPPattern(ip: string): boolean {
574
- if (!ip || typeof ip !== 'string') return false;
575
- if (ip === '*') return true;
576
-
577
- // Check for CIDR notation
578
- if (ip.includes('/')) {
579
- const [addr, prefix] = ip.split('/');
580
- const prefixNum = parseInt(prefix, 10);
581
-
582
- if (addr.includes(':')) {
583
- // IPv6 CIDR
584
- return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128;
585
- } else {
586
- // IPv4 CIDR
587
- return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32;
588
- }
589
- }
590
-
591
- // Check for range
592
- if (ip.includes('-')) {
593
- const [start, end] = ip.split('-');
594
- return (this.isValidIPv4(start) && this.isValidIPv4(end)) ||
595
- (this.isValidIPv6(start) && this.isValidIPv6(end));
596
- }
597
-
598
- // Check for wildcards in IPv4
599
- if (ip.includes('*') && !ip.includes(':')) {
600
- const parts = ip.split('.');
601
- // Allow 1-4 parts for wildcard patterns (e.g., '10.*', '192.168.*', '192.168.1.*')
602
- if (parts.length < 1 || parts.length > 4) return false;
603
-
604
- for (const part of parts) {
605
- if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
606
- if (part !== '*' && parseInt(part, 10) > 255) return false;
607
- }
608
-
609
- return true;
610
- }
611
-
612
- // Regular IP address
613
- return this.isValidIPv4(ip) || this.isValidIPv6(ip);
614
- }
615
-
616
- /**
617
- * Validate IPv4 address
618
- */
619
- private static isValidIPv4(ip: string): boolean {
620
- const parts = ip.split('.');
621
- if (parts.length !== 4) return false;
622
-
623
- for (const part of parts) {
624
- const num = parseInt(part, 10);
625
- if (isNaN(num) || num < 0 || num > 255) return false;
626
- }
627
-
628
- return true;
629
- }
630
-
631
- /**
632
- * Validate IPv6 address
633
- */
634
- private static isValidIPv6(ip: string): boolean {
635
- // IPv6 validation including IPv6-mapped IPv4 addresses (::ffff:x.x.x.x)
636
- const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::|::ffff:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
637
- return ipv6Pattern.test(ip);
638
- }
639
-
640
651
  /**
641
652
  * Log validation errors
642
653
  */