@push.rocks/smartproxy 30.1.0 → 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 (33) hide show
  1. package/.smartconfig.json +5 -3
  2. package/changelog.md +73 -0
  3. package/dist_rust/rustproxy_linux_amd64_musl +0 -0
  4. package/dist_rust/rustproxy_linux_amd64_musl.tsrust-build.json +5 -5
  5. package/dist_rust/rustproxy_linux_arm64_musl +0 -0
  6. package/dist_rust/rustproxy_linux_arm64_musl.tsrust-build.json +5 -5
  7. package/dist_ts/00_commitinfo_data.js +1 -1
  8. package/dist_ts/index.d.ts +1 -1
  9. package/dist_ts/proxies/index.d.ts +1 -1
  10. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +6 -1
  11. package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +27 -0
  12. package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
  13. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +14 -0
  14. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +7 -2
  15. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +8 -3
  16. package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +18 -1
  17. package/dist_ts/proxies/smart-proxy/smart-proxy.js +100 -36
  18. package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +44 -2
  19. package/dist_ts/proxies/smart-proxy/utils/route-validator.js +281 -2
  20. package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +2 -2
  21. package/dist_ts/proxies/smart-proxy/utils/rust-config.js +8 -5
  22. package/package.json +9 -11
  23. package/readme.md +67 -10
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/index.ts +1 -1
  26. package/ts/proxies/index.ts +1 -1
  27. package/ts/proxies/smart-proxy/models/interfaces.ts +7 -1
  28. package/ts/proxies/smart-proxy/models/route-types.ts +28 -1
  29. package/ts/proxies/smart-proxy/models/rust-types.ts +17 -1
  30. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +13 -3
  31. package/ts/proxies/smart-proxy/smart-proxy.ts +109 -38
  32. package/ts/proxies/smart-proxy/utils/route-validator.ts +315 -3
  33. package/ts/proxies/smart-proxy/utils/rust-config.ts +9 -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';
@@ -214,16 +214,14 @@ export class SmartProxy extends plugins.EventEmitter {
214
214
  // Preprocess routes (strip JS functions, convert socket-handler routes)
215
215
  const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
216
216
 
217
- // When certProvisionFunction handles cert provisioning,
218
- // disable Rust's built-in ACME to prevent race condition.
219
- let acmeForRust = this.settings.acme;
220
- if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
221
- acmeForRust = { ...acmeForRust, enabled: false };
222
- 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' });
223
221
  }
224
222
 
225
223
  // Build Rust config
226
- const config = this.buildRustConfig(rustRoutes, acmeForRust);
224
+ const config = this.buildRustConfig(rustRoutes);
227
225
 
228
226
  // Start the Rust proxy
229
227
  await this.bridge.startProxy(config, RouteValidator.getRouteLabels(this.settings.routes));
@@ -389,6 +387,10 @@ export class SmartProxy extends plugins.EventEmitter {
389
387
  // Preprocess for Rust
390
388
  const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);
391
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
+
392
394
  try {
393
395
  if (hasChallengeRoutes) {
394
396
  await this.ensureChallengeProviderRelay();
@@ -407,6 +409,9 @@ export class SmartProxy extends plugins.EventEmitter {
407
409
  this.challengeProviderRelayServer = null;
408
410
  this.challengeRuntimeOptions = undefined;
409
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
+ });
410
415
  throw err;
411
416
  }
412
417
 
@@ -453,13 +458,6 @@ export class SmartProxy extends plugins.EventEmitter {
453
458
  this.challengeRuntimeOptions = undefined;
454
459
  }
455
460
 
456
- // Update NFTables rules
457
- if (this.nftablesManager) {
458
- await this.nftablesManager.cleanup();
459
- this.nftablesManager = null;
460
- }
461
- await this.applyNftablesRules(newRoutes);
462
-
463
461
  // Update stored routes
464
462
  this.settings.routes = newRoutes;
465
463
 
@@ -657,16 +655,57 @@ export class SmartProxy extends plugins.EventEmitter {
657
655
  }
658
656
 
659
657
  /**
660
- * 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.
661
681
  */
662
682
  private async applyNftablesRules(routes: IRouteConfig[]): Promise<void> {
663
- const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
683
+ const nftRoutes = routes.filter(r => r.enabled !== false && r.action.forwardingEngine === 'nftables');
664
684
  if (nftRoutes.length === 0) return;
665
685
 
666
686
  const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
667
687
  const nft = new plugins.smartnftables.SmartNftables({ tableName });
668
- 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
+ }
669
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> {
670
709
  for (const route of nftRoutes) {
671
710
  const routeId = route.name || 'unnamed';
672
711
  const targets = route.action.targets;
@@ -696,16 +735,13 @@ export class SmartProxy extends plugins.EventEmitter {
696
735
  }
697
736
  }
698
737
  }
699
-
700
- this.nftablesManager = nft;
701
- logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
702
738
  }
703
739
 
704
740
  /**
705
741
  * Build the Rust configuration object from TS settings.
706
742
  */
707
- private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
708
- return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions);
743
+ private buildRustConfig(routes: IRustProxyOptions['routes']): IRustProxyOptions {
744
+ return buildRustProxyOptions(this.settings, routes, this.challengeRuntimeOptions);
709
745
  }
710
746
 
711
747
  private hasChallengeRoutes(routes: IRouteConfig[]): boolean {
@@ -985,8 +1021,9 @@ export class SmartProxy extends plugins.EventEmitter {
985
1021
  /**
986
1022
  * Record the validity window of a certificate loaded into Rust.
987
1023
  * Prefers an explicit validUntil; otherwise parses the PEM.
1024
+ * @returns the recorded end of validity, or undefined if it is unknown
988
1025
  */
989
- private recordCertValidity(domain: string, publicKeyPem: string, validUntil?: number): void {
1026
+ private recordCertValidity(domain: string, publicKeyPem: string, validUntil?: number): number | undefined {
990
1027
  let resolved = validUntil;
991
1028
  if (!resolved && publicKeyPem) {
992
1029
  try {
@@ -998,7 +1035,9 @@ export class SmartProxy extends plugins.EventEmitter {
998
1035
  }
999
1036
  if (resolved && Number.isFinite(resolved)) {
1000
1037
  this.certValidity.set(domain, resolved);
1038
+ return resolved;
1001
1039
  }
1040
+ return undefined;
1002
1041
  }
1003
1042
 
1004
1043
  /** Whether the domain has a loaded cert that is not yet inside the renewal window. */
@@ -1129,6 +1168,9 @@ export class SmartProxy extends plugins.EventEmitter {
1129
1168
  };
1130
1169
 
1131
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;
1132
1174
 
1133
1175
  try {
1134
1176
  const result: TSmartProxyCertProvisionObject = await this.withTimeout(
@@ -1146,15 +1188,14 @@ export class SmartProxy extends plugins.EventEmitter {
1146
1188
  }
1147
1189
 
1148
1190
  if (result === 'http01') {
1149
- if (route.name) {
1150
- try {
1151
- await this.bridge.provisionCertificate(route.name);
1152
- logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
1153
- } catch (provisionErr: any) {
1154
- logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
1155
- 'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
1156
- }
1157
- }
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);
1158
1199
  return 'http01';
1159
1200
  }
1160
1201
 
@@ -1194,7 +1235,8 @@ export class SmartProxy extends plugins.EventEmitter {
1194
1235
  }
1195
1236
  return 'unrecognized';
1196
1237
  } catch (err: any) {
1197
- 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' });
1198
1240
 
1199
1241
  // Start the per-domain failure cooldown so subsequent sweeps skip this
1200
1242
  // domain instead of re-running ACME orders back to back.
@@ -1206,11 +1248,16 @@ export class SmartProxy extends plugins.EventEmitter {
1206
1248
  source,
1207
1249
  } satisfies ICertificateFailedEvent);
1208
1250
 
1209
- // Fallback to ACME if enabled and route has a name
1210
- 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) {
1211
1253
  try {
1212
- await this.bridge.provisionCertificate(route.name);
1213
- 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);
1214
1261
  } catch (acmeErr: any) {
1215
1262
  logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
1216
1263
  (this.settings.disableDefaultCert
@@ -1222,6 +1269,30 @@ export class SmartProxy extends plugins.EventEmitter {
1222
1269
  }
1223
1270
  }
1224
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
+
1225
1296
  /**
1226
1297
  * Race a promise against a timeout. Rejects with the given message if the timeout fires first.
1227
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.
@@ -365,16 +663,30 @@ export class RouteValidator {
365
663
  /**
366
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`; and the per-IP limits `maxConnectionsPerIP` and `connectionRateLimitPerMinute`.
666
+ * `securityPolicy`; the per-IP limits `maxConnectionsPerIP` and `connectionRateLimitPerMinute`;
667
+ * and the default backend verification `backendTls`.
369
668
  * Answers every reason, empty when the settings are valid.
370
669
  */
371
670
  public static validateSettings(
372
671
  settings: Pick<
373
672
  ISmartProxyOptions,
374
- 'trustedProxyIPs' | 'defaults' | 'securityPolicy' | 'maxConnectionsPerIP' | 'connectionRateLimitPerMinute'
673
+ | 'trustedProxyIPs'
674
+ | 'defaults'
675
+ | 'securityPolicy'
676
+ | 'maxConnectionsPerIP'
677
+ | 'connectionRateLimitPerMinute'
678
+ | 'backendTls'
375
679
  >,
376
680
  ): string[] {
377
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
+ }
378
690
  // A per-IP limit is a positive integer, or `null` for no limit. 0 is refused rather than read
379
691
  // as "no limit": it would otherwise silently mean the opposite of what it says.
380
692
  for (const field of ['maxConnectionsPerIP', 'connectionRateLimitPerMinute'] as const) {
@@ -115,7 +115,10 @@ export function serializeRouteForRust(route: IRouteConfig): IRustRouteConfig {
115
115
  };
116
116
  }
117
117
 
118
- function serializeAcmeForRust(acme?: IAcmeOptions): IRustAcmeOptions | undefined {
118
+ function serializeAcmeForRust(
119
+ acme: IAcmeOptions | undefined,
120
+ certProvisionFunction: ISmartProxyOptions['certProvisionFunction'],
121
+ ): IRustAcmeOptions | undefined {
119
122
  if (!acme) {
120
123
  return undefined;
121
124
  }
@@ -131,6 +134,9 @@ function serializeAcmeForRust(acme?: IAcmeOptions): IRustAcmeOptions | undefined
131
134
  autoRenew: acme.autoRenew,
132
135
  skipConfiguredCerts: acme.skipConfiguredCerts,
133
136
  renewCheckIntervalHours: acme.renewCheckIntervalHours,
137
+ // A certProvisionFunction owns provisioning and renewal; the engine orders
138
+ // only what it is asked to: 'http01' results and the ACME fallback.
139
+ autoProvision: certProvisionFunction ? false : undefined,
134
140
  };
135
141
  }
136
142
 
@@ -171,16 +177,14 @@ export function buildChallengeOptionsForRust(settings: ISmartProxyOptions): IRus
171
177
  export function buildRustProxyOptions(
172
178
  settings: ISmartProxyOptions,
173
179
  routes: IRustRouteConfig[],
174
- acmeOverride?: IAcmeOptions,
175
180
  challengeOverride?: IRustChallengeOptions,
176
181
  ): IRustProxyOptions {
177
- const acme = acmeOverride !== undefined ? acmeOverride : settings.acme;
178
-
179
182
  return {
180
183
  routes,
181
184
  preserveSourceIp: settings.preserveSourceIP,
182
185
  trustedProxyIPs: settings.trustedProxyIPs,
183
186
  sendProxyProtocol: settings.sendProxyProtocol,
187
+ backendTls: settings.backendTls,
184
188
  defaults: serializeDefaultsForRust(settings.defaults),
185
189
  connectionTimeout: settings.connectionTimeout,
186
190
  initialDataTimeout: settings.initialDataTimeout,
@@ -207,6 +211,6 @@ export function buildRustProxyOptions(
207
211
  metrics: settings.metrics,
208
212
  securityPolicy: settings.securityPolicy,
209
213
  challenge: challengeOverride || buildChallengeOptionsForRust(settings),
210
- acme: serializeAcmeForRust(acme),
214
+ acme: serializeAcmeForRust(settings.acme, settings.certProvisionFunction),
211
215
  };
212
216
  }