@push.rocks/smartproxy 30.0.1 → 31.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.
Files changed (40) hide show
  1. package/.smartconfig.json +5 -3
  2. package/changelog.md +91 -0
  3. package/dist_rust/rustproxy_linux_amd64_musl +0 -0
  4. package/dist_rust/rustproxy_linux_amd64_musl.third-party-notices.json +3358 -0
  5. package/dist_rust/rustproxy_linux_amd64_musl.third-party-notices.txt +44997 -0
  6. package/dist_rust/rustproxy_linux_amd64_musl.tsrust-build.json +5 -5
  7. package/dist_rust/rustproxy_linux_arm64_musl +0 -0
  8. package/dist_rust/rustproxy_linux_arm64_musl.third-party-notices.json +3358 -0
  9. package/dist_rust/rustproxy_linux_arm64_musl.third-party-notices.txt +44997 -0
  10. package/dist_rust/rustproxy_linux_arm64_musl.tsrust-build.json +5 -5
  11. package/dist_ts/00_commitinfo_data.js +1 -1
  12. package/dist_ts/index.d.ts +1 -1
  13. package/dist_ts/proxies/index.d.ts +1 -1
  14. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +16 -3
  15. package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +27 -0
  16. package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
  17. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +18 -2
  18. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +7 -2
  19. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +8 -3
  20. package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +18 -1
  21. package/dist_ts/proxies/smart-proxy/smart-proxy.js +106 -38
  22. package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +46 -3
  23. package/dist_ts/proxies/smart-proxy/utils/route-validator.js +293 -3
  24. package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +2 -2
  25. package/dist_ts/proxies/smart-proxy/utils/rust-config.js +9 -5
  26. package/native-notices/manifest.json +13 -1
  27. package/native-notices/readme.md +4 -2
  28. package/native-notices/runtime/libunwind-22.1.2/license.txt +311 -0
  29. package/package.json +10 -12
  30. package/readme.md +70 -13
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/index.ts +1 -1
  33. package/ts/proxies/index.ts +1 -1
  34. package/ts/proxies/smart-proxy/models/interfaces.ts +17 -3
  35. package/ts/proxies/smart-proxy/models/route-types.ts +28 -1
  36. package/ts/proxies/smart-proxy/models/rust-types.ts +21 -3
  37. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +13 -3
  38. package/ts/proxies/smart-proxy/smart-proxy.ts +115 -40
  39. package/ts/proxies/smart-proxy/utils/route-validator.ts +329 -4
  40. package/ts/proxies/smart-proxy/utils/rust-config.ts +10 -5
@@ -19,7 +19,7 @@ import { Mutex } from './utils/mutex.js';
19
19
  import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
20
20
 
21
21
  // Types
22
- import type { ISmartProxyOptions, ISmartProxySecurityPolicy, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent, ISmartProxyCertProvisionCertificate, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage } from './models/interfaces.js';
22
+ import type { ISmartProxyOptions, ISmartProxySecurityPolicy, TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent, ISmartProxyCertProvisionCertificate, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage } from './models/interfaces.js';
23
23
  import type { IRouteConfig } from './models/route-types.js';
24
24
  import type { IMetrics, IMetricsHistory } from './models/metrics-types.js';
25
25
  import type { IRustCertificateStatus, IRustChallengeOptions, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';
@@ -77,8 +77,12 @@ export class SmartProxy extends plugins.EventEmitter {
77
77
  maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3_600_000,
78
78
  inactivityTimeout: settingsArg.inactivityTimeout || 75_000,
79
79
  gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30_000,
80
- maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
81
- connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
80
+ // Only an omitted value takes the default: `null` means no per-IP limit (so neither `||`
81
+ // nor `??` fits), and 0 or a negative value is refused by the settings validation below.
82
+ maxConnectionsPerIP: settingsArg.maxConnectionsPerIP === undefined ? 100 : settingsArg.maxConnectionsPerIP,
83
+ connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute === undefined
84
+ ? 300
85
+ : settingsArg.connectionRateLimitPerMinute,
82
86
  keepAliveTreatment: settingsArg.keepAliveTreatment || 'standard',
83
87
  keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 4,
84
88
  extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 3_600_000,
@@ -210,16 +214,14 @@ export class SmartProxy extends plugins.EventEmitter {
210
214
  // Preprocess routes (strip JS functions, convert socket-handler routes)
211
215
  const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
212
216
 
213
- // When certProvisionFunction handles cert provisioning,
214
- // disable Rust's built-in ACME to prevent race condition.
215
- let acmeForRust = this.settings.acme;
216
- if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
217
- acmeForRust = { ...acmeForRust, enabled: false };
218
- logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
217
+ // When certProvisionFunction handles cert provisioning, the engine's ACME
218
+ // client orders only what it is asked to (buildRustProxyOptions).
219
+ if (this.settings.certProvisionFunction && this.settings.acme?.enabled) {
220
+ logger.log('info', 'Rust ACME orders certificates only on request — certProvisionFunction handles certificate provisioning', { component: 'smart-proxy' });
219
221
  }
220
222
 
221
223
  // Build Rust config
222
- const config = this.buildRustConfig(rustRoutes, acmeForRust);
224
+ const config = this.buildRustConfig(rustRoutes);
223
225
 
224
226
  // Start the Rust proxy
225
227
  await this.bridge.startProxy(config, RouteValidator.getRouteLabels(this.settings.routes));
@@ -385,6 +387,10 @@ export class SmartProxy extends plugins.EventEmitter {
385
387
  // Preprocess for Rust
386
388
  const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);
387
389
 
390
+ // Swap the kernel rules first: a refused rule leaves the running routes untouched
391
+ const previousRoutes = this.settings.routes;
392
+ await this.replaceNftablesRules(newRoutes, previousRoutes);
393
+
388
394
  try {
389
395
  if (hasChallengeRoutes) {
390
396
  await this.ensureChallengeProviderRelay();
@@ -403,6 +409,9 @@ export class SmartProxy extends plugins.EventEmitter {
403
409
  this.challengeProviderRelayServer = null;
404
410
  this.challengeRuntimeOptions = undefined;
405
411
  }
412
+ await this.replaceNftablesRules(previousRoutes).catch((restoreErr) => {
413
+ logger.log('error', `Failed to restore the previous NFTables rules: ${(restoreErr as Error).message}`, { component: 'smart-proxy' });
414
+ });
406
415
  throw err;
407
416
  }
408
417
 
@@ -449,13 +458,6 @@ export class SmartProxy extends plugins.EventEmitter {
449
458
  this.challengeRuntimeOptions = undefined;
450
459
  }
451
460
 
452
- // Update NFTables rules
453
- if (this.nftablesManager) {
454
- await this.nftablesManager.cleanup();
455
- this.nftablesManager = null;
456
- }
457
- await this.applyNftablesRules(newRoutes);
458
-
459
461
  // Update stored routes
460
462
  this.settings.routes = newRoutes;
461
463
 
@@ -653,16 +655,57 @@ export class SmartProxy extends plugins.EventEmitter {
653
655
  }
654
656
 
655
657
  /**
656
- * Apply NFTables rules for routes using the nftables forwarding engine.
658
+ * Replace the current NFTables rules with those of `routes`. When a rule fails, the
659
+ * rules of `fallbackRoutes` are applied again before the error is rethrown.
660
+ */
661
+ private async replaceNftablesRules(routes: IRouteConfig[], fallbackRoutes?: IRouteConfig[]): Promise<void> {
662
+ if (this.nftablesManager) {
663
+ await this.nftablesManager.cleanup();
664
+ this.nftablesManager = null;
665
+ }
666
+ try {
667
+ await this.applyNftablesRules(routes);
668
+ } catch (err) {
669
+ if (fallbackRoutes) {
670
+ await this.applyNftablesRules(fallbackRoutes).catch((restoreErr) => {
671
+ logger.log('error', `Failed to restore the previous NFTables rules: ${(restoreErr as Error).message}`, { component: 'smart-proxy' });
672
+ });
673
+ }
674
+ throw err;
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Apply NFTables rules for the enabled routes using the nftables forwarding engine.
680
+ * A rule that fails removes the rules applied before it.
657
681
  */
658
682
  private async applyNftablesRules(routes: IRouteConfig[]): Promise<void> {
659
- const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
683
+ const nftRoutes = routes.filter(r => r.enabled !== false && r.action.forwardingEngine === 'nftables');
660
684
  if (nftRoutes.length === 0) return;
661
685
 
662
686
  const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
663
687
  const nft = new plugins.smartnftables.SmartNftables({ tableName });
664
- await nft.initialize();
688
+ // Hold the manager before applying, so every rule that lands has a cleanup handle
689
+ this.nftablesManager = nft;
690
+ try {
691
+ await nft.initialize();
692
+ await this.addNftablesRules(nft, nftRoutes);
693
+ } catch (err) {
694
+ this.nftablesManager = null;
695
+ // Forced: a failed initialize() may have created the table without marking it initialized
696
+ await nft.cleanup({ force: true }).catch((cleanupErr) => {
697
+ logger.log('warn', `Failed to clean NFTables after a failed rule: ${(cleanupErr as Error).message}`, { component: 'smart-proxy' });
698
+ });
699
+ throw err;
700
+ }
701
+ logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
702
+ }
665
703
 
704
+ /** Add the port-forwarding rules of each route, one per listed port and target. */
705
+ private async addNftablesRules(
706
+ nft: InstanceType<typeof plugins.smartnftables.SmartNftables>,
707
+ nftRoutes: IRouteConfig[],
708
+ ): Promise<void> {
666
709
  for (const route of nftRoutes) {
667
710
  const routeId = route.name || 'unnamed';
668
711
  const targets = route.action.targets;
@@ -692,16 +735,13 @@ export class SmartProxy extends plugins.EventEmitter {
692
735
  }
693
736
  }
694
737
  }
695
-
696
- this.nftablesManager = nft;
697
- logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
698
738
  }
699
739
 
700
740
  /**
701
741
  * Build the Rust configuration object from TS settings.
702
742
  */
703
- private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
704
- return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions);
743
+ private buildRustConfig(routes: IRustProxyOptions['routes']): IRustProxyOptions {
744
+ return buildRustProxyOptions(this.settings, routes, this.challengeRuntimeOptions);
705
745
  }
706
746
 
707
747
  private hasChallengeRoutes(routes: IRouteConfig[]): boolean {
@@ -981,8 +1021,9 @@ export class SmartProxy extends plugins.EventEmitter {
981
1021
  /**
982
1022
  * Record the validity window of a certificate loaded into Rust.
983
1023
  * Prefers an explicit validUntil; otherwise parses the PEM.
1024
+ * @returns the recorded end of validity, or undefined if it is unknown
984
1025
  */
985
- private recordCertValidity(domain: string, publicKeyPem: string, validUntil?: number): void {
1026
+ private recordCertValidity(domain: string, publicKeyPem: string, validUntil?: number): number | undefined {
986
1027
  let resolved = validUntil;
987
1028
  if (!resolved && publicKeyPem) {
988
1029
  try {
@@ -994,7 +1035,9 @@ export class SmartProxy extends plugins.EventEmitter {
994
1035
  }
995
1036
  if (resolved && Number.isFinite(resolved)) {
996
1037
  this.certValidity.set(domain, resolved);
1038
+ return resolved;
997
1039
  }
1040
+ return undefined;
998
1041
  }
999
1042
 
1000
1043
  /** Whether the domain has a loaded cert that is not yet inside the renewal window. */
@@ -1125,6 +1168,9 @@ export class SmartProxy extends plugins.EventEmitter {
1125
1168
  };
1126
1169
 
1127
1170
  const timeoutMs = this.settings.certProvisionTimeout ?? 300_000; // 5 min default
1171
+ // Set when the domain's certificate is ordered through Rust ACME, so a failed
1172
+ // order is not placed a second time as the ACME fallback.
1173
+ let orderedThroughAcme = false;
1128
1174
 
1129
1175
  try {
1130
1176
  const result: TSmartProxyCertProvisionObject = await this.withTimeout(
@@ -1142,15 +1188,14 @@ export class SmartProxy extends plugins.EventEmitter {
1142
1188
  }
1143
1189
 
1144
1190
  if (result === 'http01') {
1145
- if (route.name) {
1146
- try {
1147
- await this.bridge.provisionCertificate(route.name);
1148
- logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
1149
- } catch (provisionErr: any) {
1150
- logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
1151
- 'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
1152
- }
1153
- }
1191
+ orderedThroughAcme = true;
1192
+ const acmeExpiryDate = await this.provisionThroughRustAcme(domain, route);
1193
+ logger.log('info', `Certificate issued through Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
1194
+ this.emit('certificate-issued', {
1195
+ domain,
1196
+ expiryDate: acmeExpiryDate,
1197
+ source,
1198
+ } satisfies ICertificateIssuedEvent);
1154
1199
  return 'http01';
1155
1200
  }
1156
1201
 
@@ -1190,7 +1235,8 @@ export class SmartProxy extends plugins.EventEmitter {
1190
1235
  }
1191
1236
  return 'unrecognized';
1192
1237
  } catch (err: any) {
1193
- logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
1238
+ const failedStep = orderedThroughAcme ? 'Rust ACME order' : 'certProvisionFunction';
1239
+ logger.log('warn', `${failedStep} failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
1194
1240
 
1195
1241
  // Start the per-domain failure cooldown so subsequent sweeps skip this
1196
1242
  // domain instead of re-running ACME orders back to back.
@@ -1202,11 +1248,16 @@ export class SmartProxy extends plugins.EventEmitter {
1202
1248
  source,
1203
1249
  } satisfies ICertificateFailedEvent);
1204
1250
 
1205
- // Fallback to ACME if enabled and route has a name
1206
- if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
1251
+ // Fall back to Rust ACME, unless the attempt that failed was that order.
1252
+ if (this.settings.certProvisionFallbackToAcme !== false && !orderedThroughAcme) {
1207
1253
  try {
1208
- await this.bridge.provisionCertificate(route.name);
1209
- logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
1254
+ const acmeExpiryDate = await this.provisionThroughRustAcme(domain, route);
1255
+ logger.log('info', `Certificate issued through the Rust ACME fallback for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
1256
+ this.emit('certificate-issued', {
1257
+ domain,
1258
+ expiryDate: acmeExpiryDate,
1259
+ source: 'acme',
1260
+ } satisfies ICertificateIssuedEvent);
1210
1261
  } catch (acmeErr: any) {
1211
1262
  logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
1212
1263
  (this.settings.disableDefaultCert
@@ -1218,6 +1269,30 @@ export class SmartProxy extends plugins.EventEmitter {
1218
1269
  }
1219
1270
  }
1220
1271
 
1272
+ /**
1273
+ * Order a domain's certificate through the engine's ACME client (HTTP-01 on
1274
+ * `acme.port`), for an 'http01' callback result or the ACME fallback. The engine
1275
+ * serves the certificate; its recorded validity lets sweeps skip the domain until
1276
+ * the renewal window.
1277
+ * @returns the certificate's expiry date (ISO 8601), if its PEM parses
1278
+ */
1279
+ private async provisionThroughRustAcme(domain: string, route: IRouteConfig): Promise<string | undefined> {
1280
+ if (!this.settings.acme?.enabled) {
1281
+ throw new Error(`Rust ACME cannot order a certificate for ${domain}: acme is not configured`);
1282
+ }
1283
+ if (!route.name) {
1284
+ throw new Error(`Rust ACME cannot order a certificate for ${domain}: its route has no name`);
1285
+ }
1286
+ const issued = await this.bridge.provisionCertificate(
1287
+ route.name,
1288
+ domain,
1289
+ this.settings.certProvisionTimeout ?? 300_000,
1290
+ );
1291
+ this.certFailureAt.delete(domain);
1292
+ const validUntil = this.recordCertValidity(domain, issued.cert);
1293
+ return validUntil ? new Date(validUntil).toISOString() : undefined;
1294
+ }
1295
+
1221
1296
  /**
1222
1297
  * Race a promise against a timeout. Rejects with the given message if the timeout fires first.
1223
1298
  */
@@ -1,3 +1,4 @@
1
+ import * as plugins from '../../../plugins.js';
1
2
  import { logger } from '../../../core/utils/logger.js';
2
3
  import {
3
4
  checkAssignedIpEntry,
@@ -5,7 +6,7 @@ import {
5
6
  checkSourceMatchIpEntry,
6
7
  describeRefusedIpEntry,
7
8
  } from './ip-entry-validation.js';
8
- import type { IInboundProxyProtocolPolicy, IRouteConfig, IRouteMatch, IRouteAction, TPortRange, TTransportProtocol } from '../models/route-types.js';
9
+ import type { IInboundProxyProtocolPolicy, IRouteBackendTls, IRouteConfig, IRouteMatch, IRouteAction, TPortRange, TTlsMode, TTransportProtocol } from '../models/route-types.js';
9
10
  import type { ISmartProxyOptions, ISmartProxySecurityPolicy } from '../models/interfaces.js';
10
11
  import { RouteValidationError, type IRouteValidationErrorEntry } from './route-validation-error.js';
11
12
 
@@ -211,6 +212,8 @@ export class RouteValidator {
211
212
  }
212
213
  }
213
214
 
215
+ errors.push(...this.getBackendTlsErrors(route));
216
+
214
217
  // QUIC routes require TLS with termination (QUIC mandates TLS 1.3)
215
218
  if (route.action.udp?.quic && route.action.type === 'forward') {
216
219
  if (!route.action.tls) {
@@ -277,6 +280,11 @@ export class RouteValidator {
277
280
  }
278
281
  }
279
282
 
283
+ // No enforcement reads security.authentication; accepting it would serve the route unauthenticated
284
+ if (route.security.authentication) {
285
+ errors.push('security.authentication is not supported; use security.basicAuth or security.jwtAuth');
286
+ }
287
+
280
288
  // Validate rate limits
281
289
  if (route.security.rateLimit) {
282
290
  if (route.security.rateLimit.maxRequests && route.security.rateLimit.maxRequests < 0) {
@@ -338,6 +346,33 @@ export class RouteValidator {
338
346
  }
339
347
  }
340
348
 
349
+ // Raw-forwarded traffic never reaches the HTTP layer that enforces these controls
350
+ const httpControl = this.httpSecurityControl(route);
351
+ const rawForwarding = httpControl ? this.rawForwardingReason(route) : null;
352
+ if (httpControl && rawForwarding) {
353
+ errors.push(
354
+ `${httpControl} is enforced only on HTTP traffic, but ${rawForwarding.reason}; ${rawForwarding.alternative}`,
355
+ );
356
+ }
357
+
358
+ // The TypeScript relay that serves dynamic targets cannot honour these settings
359
+ if (this.hasDynamicTarget(route)) {
360
+ for (const { setting, reason } of this.getDynamicTargetConflicts(route)) {
361
+ errors.push(
362
+ `${setting} is not supported with a dynamic target (a function host or port): ${reason}; use static targets`,
363
+ );
364
+ }
365
+ }
366
+
367
+ // The kernel forwards nftables routes before the proxy sees their packets
368
+ if (route.action?.forwardingEngine === 'nftables') {
369
+ for (const { setting, reason } of this.getNftablesConflicts(route)) {
370
+ errors.push(
371
+ `${setting} is not supported with forwardingEngine "nftables": ${reason}; use the default forwarding engine`,
372
+ );
373
+ }
374
+ }
375
+
341
376
  // Validate priority
342
377
  if (route.priority !== undefined && (route.priority < 0 || route.priority > 10000)) {
343
378
  errors.push('Priority must be between 0 and 10000');
@@ -349,6 +384,269 @@ export class RouteValidator {
349
384
  };
350
385
  }
351
386
 
387
+ /**
388
+ * The config path of the first access control the route sets that only the
389
+ * HTTP layer enforces. Mirrors `RouteConfig::http_security_control` in Rust.
390
+ */
391
+ private static httpSecurityControl(route: IRouteConfig): string | null {
392
+ const security = route.security;
393
+ if (security?.basicAuth?.enabled === true) {
394
+ return 'security.basicAuth';
395
+ }
396
+ if (security?.jwtAuth?.enabled === true) {
397
+ return 'security.jwtAuth';
398
+ }
399
+ if (security?.rateLimit?.enabled === true) {
400
+ return 'security.rateLimit';
401
+ }
402
+ if (security?.challenge) {
403
+ return 'security.challenge';
404
+ }
405
+ if ((route.action?.websocket?.allowedOrigins?.length ?? 0) > 0) {
406
+ return 'action.websocket.allowedOrigins';
407
+ }
408
+ return null;
409
+ }
410
+
411
+ /**
412
+ * Why the route forwards its traffic without the HTTP layer, and what to
413
+ * configure instead. Mirrors `raw_forwarding_reason` in Rust, which sees a
414
+ * dynamic-target route as the socket-handler route the preprocessor makes of it.
415
+ */
416
+ private static rawForwardingReason(route: IRouteConfig): { reason: string; alternative: string } | null {
417
+ if (route.action?.type === 'socket-handler') {
418
+ return {
419
+ reason: 'a socket-handler route hands the raw connection to its handler',
420
+ alternative: 'enforce it in the handler or use a forward route',
421
+ };
422
+ }
423
+ if (this.hasDynamicTarget(route)) {
424
+ return {
425
+ reason: 'a dynamic target (a function host or port) is served by a relay that forwards the raw connection',
426
+ alternative: 'use static targets',
427
+ };
428
+ }
429
+ if (this.tlsMode(route) === 'passthrough') {
430
+ return {
431
+ reason: 'TLS passthrough forwards the encrypted stream unread',
432
+ alternative: 'use tls.mode "terminate" or "terminate-and-reencrypt"',
433
+ };
434
+ }
435
+ if (route.match?.protocol === 'tcp') {
436
+ return {
437
+ reason: 'match.protocol "tcp" matches only non-HTTP traffic',
438
+ alternative: 'use match.protocol "http"',
439
+ };
440
+ }
441
+ const transport = route.match?.transport;
442
+ if ((transport === 'udp' || transport === 'all') && route.action?.udp?.quic?.enableHttp3 !== true) {
443
+ return {
444
+ reason: 'UDP and QUIC traffic without HTTP/3 is forwarded raw',
445
+ alternative: 'set action.udp.quic.enableHttp3 or serve UDP from a separate route',
446
+ };
447
+ }
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * The route's TLS mode: `action.tls`, else the first target's `tls`. Mirrors
453
+ * `RouteConfig::tls_mode` in Rust.
454
+ */
455
+ private static tlsMode(route: IRouteConfig): TTlsMode | undefined {
456
+ return route.action?.tls ? route.action.tls.mode : route.action?.targets?.[0]?.tls?.mode;
457
+ }
458
+
459
+ /**
460
+ * The problems of the backend TLS settings a route carries. Mirrors
461
+ * `validate_route_backend_tls` in Rust.
462
+ */
463
+ private static getBackendTlsErrors(route: IRouteConfig): string[] {
464
+ const settings: Array<[string, IRouteBackendTls]> = [];
465
+ if (route.action?.tls?.backend) {
466
+ settings.push(['action.tls.backend', route.action.tls.backend]);
467
+ }
468
+ (route.action?.targets ?? []).forEach((target, index) => {
469
+ if (target.tls?.backend) {
470
+ settings.push([`action.targets[${index}].tls.backend`, target.tls.backend]);
471
+ }
472
+ });
473
+ const passthrough = this.tlsMode(route) === 'passthrough';
474
+ const errors: string[] = [];
475
+ for (const [field, backend] of settings) {
476
+ if (passthrough) {
477
+ errors.push(
478
+ `${field} has no effect: TLS passthrough forwards the client's TLS and never connects to the backend over TLS`,
479
+ );
480
+ }
481
+ errors.push(...this.getBackendTlsSettingErrors(backend, field));
482
+ }
483
+ return errors;
484
+ }
485
+
486
+ /**
487
+ * The problems of one backend TLS setting; `field` names it in the messages. Mirrors
488
+ * `backend_tls_issues` in Rust.
489
+ */
490
+ private static getBackendTlsSettingErrors(settings: IRouteBackendTls, field: string): string[] {
491
+ const errors: string[] = [];
492
+ if (settings.ca !== undefined && settings.rejectUnauthorized === false) {
493
+ errors.push(
494
+ `${field}.ca conflicts with ${field}.rejectUnauthorized: false, which accepts any certificate; set one of them`,
495
+ );
496
+ }
497
+ if (settings.ca !== undefined && settings.ca.trim() === '') {
498
+ errors.push(`${field}.ca must contain PEM certificates`);
499
+ }
500
+ // The name the engine sends: trimmed, without trailing dots, lowercase
501
+ if (settings.serverName !== undefined && settings.serverName.trim().replace(/\.+$/, '') === '') {
502
+ errors.push(`${field}.serverName must not be empty`);
503
+ }
504
+ return errors;
505
+ }
506
+
507
+ /**
508
+ * Whether a target resolves its host or port through a function. The preprocessor
509
+ * hands such routes to the TypeScript relay.
510
+ */
511
+ private static hasDynamicTarget(route: IRouteConfig): boolean {
512
+ return (route.action?.targets ?? []).some(
513
+ (target) => typeof target.host === 'function' || typeof target.port === 'function',
514
+ );
515
+ }
516
+
517
+ /**
518
+ * The settings of a dynamic-target route that its relay cannot honour: it resolves one
519
+ * target per connection, connects without TLS or a PROXY header, and carries only TCP.
520
+ */
521
+ private static getDynamicTargetConflicts(route: IRouteConfig): Array<{ setting: string; reason: string }> {
522
+ const conflicts: Array<{ setting: string; reason: string }> = [];
523
+ const targets = route.action?.targets ?? [];
524
+ const noTargetSelection = 'the relay does not select between targets';
525
+ const noProxyProtocol = 'the relay sends no PROXY protocol header';
526
+ const noBackendTls = 'the relay connects to the target without TLS';
527
+ const onlyTcp = 'the relay carries only TCP connections';
528
+
529
+ if (targets.length > 1) {
530
+ conflicts.push({ setting: 'More than one target', reason: noTargetSelection });
531
+ }
532
+ if (route.action?.sendProxyProtocol) {
533
+ conflicts.push({ setting: 'action.sendProxyProtocol', reason: noProxyProtocol });
534
+ }
535
+ if (route.action?.tls?.mode === 'terminate-and-reencrypt') {
536
+ conflicts.push({ setting: 'action.tls.mode "terminate-and-reencrypt"', reason: noBackendTls });
537
+ }
538
+ if (route.action?.tls?.backend) {
539
+ conflicts.push({ setting: 'action.tls.backend', reason: noBackendTls });
540
+ }
541
+ targets.forEach((target, index) => {
542
+ if (target.match) {
543
+ conflicts.push({ setting: `action.targets[${index}].match`, reason: noTargetSelection });
544
+ }
545
+ if (target.sendProxyProtocol) {
546
+ conflicts.push({ setting: `action.targets[${index}].sendProxyProtocol`, reason: noProxyProtocol });
547
+ }
548
+ if (target.tls?.mode === 'terminate-and-reencrypt') {
549
+ conflicts.push({ setting: `action.targets[${index}].tls.mode "terminate-and-reencrypt"`, reason: noBackendTls });
550
+ }
551
+ if (target.tls?.backend) {
552
+ conflicts.push({ setting: `action.targets[${index}].tls.backend`, reason: noBackendTls });
553
+ }
554
+ if (target.backendTransport === 'udp') {
555
+ conflicts.push({ setting: `action.targets[${index}].backendTransport "udp"`, reason: onlyTcp });
556
+ }
557
+ });
558
+ if (route.match?.path) {
559
+ conflicts.push({ setting: 'match.path', reason: 'the relay forwards whole connections, not single requests' });
560
+ }
561
+ if (route.match?.headers) {
562
+ conflicts.push({ setting: 'match.headers', reason: 'the relay forwards whole connections, not single requests' });
563
+ }
564
+ const transport = route.match?.transport;
565
+ if (transport === 'udp' || transport === 'all') {
566
+ conflicts.push({ setting: `match.transport "${transport}"`, reason: onlyTcp });
567
+ }
568
+ return conflicts;
569
+ }
570
+
571
+ /**
572
+ * The settings of an nftables route that its kernel rules cannot honour: they forward each
573
+ * listed port to one IPv4 address and port before the proxy sees a packet.
574
+ */
575
+ private static getNftablesConflicts(route: IRouteConfig): Array<{ setting: string; reason: string }> {
576
+ const conflicts: Array<{ setting: string; reason: string }> = [];
577
+ const bypassed = 'the kernel forwards the packets before the proxy sees them';
578
+ const portOnly = 'the kernel rules match packets by port only';
579
+ const oneTarget = 'the kernel rules forward each port to one target';
580
+ const noProxyProtocol = 'the kernel rules send no PROXY protocol header';
581
+
582
+ for (const [key, value] of Object.entries(route.security ?? {})) {
583
+ if (value !== undefined) {
584
+ conflicts.push({ setting: `security.${key}`, reason: bypassed });
585
+ }
586
+ }
587
+ for (const key of ['domains', 'path', 'headers', 'clientIp', 'tlsVersion', 'protocol', 'inboundProxyProtocol'] as const) {
588
+ if (route.match?.[key] !== undefined) {
589
+ conflicts.push({ setting: `match.${key}`, reason: portOnly });
590
+ }
591
+ }
592
+ const ports = route.match?.ports;
593
+ for (const port of Array.isArray(ports) ? ports : [ports]) {
594
+ if (typeof port === 'object' && port !== null) {
595
+ conflicts.push({
596
+ setting: `The match.ports range ${port.from}-${port.to}`,
597
+ reason: 'the kernel rules forward single ports',
598
+ });
599
+ }
600
+ }
601
+ if (route.headers) {
602
+ conflicts.push({ setting: 'headers', reason: bypassed });
603
+ }
604
+ if (route.action?.tls && route.action.tls.mode !== 'passthrough') {
605
+ conflicts.push({ setting: `action.tls.mode "${route.action.tls.mode}"`, reason: bypassed });
606
+ }
607
+ if (route.action?.websocket) {
608
+ conflicts.push({ setting: 'action.websocket', reason: bypassed });
609
+ }
610
+ if (route.action?.advanced) {
611
+ conflicts.push({ setting: 'action.advanced', reason: bypassed });
612
+ }
613
+ if (route.action?.sendProxyProtocol) {
614
+ conflicts.push({ setting: 'action.sendProxyProtocol', reason: noProxyProtocol });
615
+ }
616
+
617
+ const targets = route.action?.targets ?? [];
618
+ if (targets.length > 1) {
619
+ conflicts.push({ setting: 'More than one target', reason: oneTarget });
620
+ }
621
+ targets.forEach((target, index) => {
622
+ const hosts = Array.isArray(target.host) ? target.host : [target.host];
623
+ if (hosts.length !== 1 || typeof hosts[0] !== 'string' || !plugins.net.isIPv4(hosts[0])) {
624
+ conflicts.push({
625
+ setting: `action.targets[${index}].host`,
626
+ reason: 'the kernel rules forward to one IPv4 address',
627
+ });
628
+ }
629
+ if (typeof target.port === 'function') {
630
+ conflicts.push({ setting: `action.targets[${index}].port`, reason: 'the kernel rules forward to a fixed port' });
631
+ }
632
+ if (target.match) {
633
+ conflicts.push({ setting: `action.targets[${index}].match`, reason: oneTarget });
634
+ }
635
+ if (target.tls && target.tls.mode !== 'passthrough') {
636
+ conflicts.push({ setting: `action.targets[${index}].tls.mode "${target.tls.mode}"`, reason: bypassed });
637
+ }
638
+ for (const key of ['websocket', 'headers', 'advanced'] as const) {
639
+ if (target[key]) {
640
+ conflicts.push({ setting: `action.targets[${index}].${key}`, reason: bypassed });
641
+ }
642
+ }
643
+ if (target.sendProxyProtocol) {
644
+ conflicts.push({ setting: `action.targets[${index}].sendProxyProtocol`, reason: noProxyProtocol });
645
+ }
646
+ });
647
+ return conflicts;
648
+ }
649
+
352
650
  /**
353
651
  * Label every route for validation reports: its name, else its id, else `route[<index>]`.
354
652
  * A name or id that several routes share gets `[<index>]` appended, so every label is unique.
@@ -363,14 +661,41 @@ export class RouteValidator {
363
661
  }
364
662
 
365
663
  /**
366
- * Validate the IP entries of the settings outside routes: the global `trustedProxyIPs`, the
664
+ * Validate the settings outside routes: the IP entries of the global `trustedProxyIPs`, the
367
665
  * `defaults.security` lists the engine copies into routes without security, and the global
368
- * `securityPolicy`. Answers every reason, empty when the settings are valid.
666
+ * `securityPolicy`; the per-IP limits `maxConnectionsPerIP` and `connectionRateLimitPerMinute`;
667
+ * and the default backend verification `backendTls`.
668
+ * Answers every reason, empty when the settings are valid.
369
669
  */
370
670
  public static validateSettings(
371
- settings: Pick<ISmartProxyOptions, 'trustedProxyIPs' | 'defaults' | 'securityPolicy'>,
671
+ settings: Pick<
672
+ ISmartProxyOptions,
673
+ | 'trustedProxyIPs'
674
+ | 'defaults'
675
+ | 'securityPolicy'
676
+ | 'maxConnectionsPerIP'
677
+ | 'connectionRateLimitPerMinute'
678
+ | 'backendTls'
679
+ >,
372
680
  ): string[] {
373
681
  const errors: string[] = [];
682
+ // Mirrors `backend_tls_default_issues` in Rust
683
+ if (settings.backendTls) {
684
+ const backendTls: IRouteBackendTls = settings.backendTls;
685
+ errors.push(...this.getBackendTlsSettingErrors(backendTls, 'backendTls'));
686
+ if (backendTls.serverName !== undefined) {
687
+ errors.push('backendTls.serverName is not supported; set serverName in a route or target tls.backend');
688
+ }
689
+ }
690
+ // A per-IP limit is a positive integer, or `null` for no limit. 0 is refused rather than read
691
+ // as "no limit": it would otherwise silently mean the opposite of what it says.
692
+ for (const field of ['maxConnectionsPerIP', 'connectionRateLimitPerMinute'] as const) {
693
+ const value: unknown = settings[field];
694
+ if (value === undefined || value === null) continue;
695
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
696
+ errors.push(`${field} must be a positive integer, or null for no per-IP limit (got ${String(value)})`);
697
+ }
698
+ }
374
699
  const checkList = (
375
700
  list: unknown,
376
701
  field: string,