@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.
- package/.smartconfig.json +5 -3
- package/changelog.md +91 -0
- package/dist_rust/rustproxy_linux_amd64_musl +0 -0
- package/dist_rust/rustproxy_linux_amd64_musl.third-party-notices.json +3358 -0
- package/dist_rust/rustproxy_linux_amd64_musl.third-party-notices.txt +44997 -0
- package/dist_rust/rustproxy_linux_amd64_musl.tsrust-build.json +5 -5
- package/dist_rust/rustproxy_linux_arm64_musl +0 -0
- package/dist_rust/rustproxy_linux_arm64_musl.third-party-notices.json +3358 -0
- package/dist_rust/rustproxy_linux_arm64_musl.third-party-notices.txt +44997 -0
- package/dist_rust/rustproxy_linux_arm64_musl.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/proxies/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +16 -3
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +27 -0
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +18 -2
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +7 -2
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +8 -3
- package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +18 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +106 -38
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +46 -3
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +293 -3
- package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +2 -2
- package/dist_ts/proxies/smart-proxy/utils/rust-config.js +9 -5
- package/native-notices/manifest.json +13 -1
- package/native-notices/readme.md +4 -2
- package/native-notices/runtime/libunwind-22.1.2/license.txt +311 -0
- package/package.json +10 -12
- package/readme.md +70 -13
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -1
- package/ts/proxies/index.ts +1 -1
- package/ts/proxies/smart-proxy/models/interfaces.ts +17 -3
- package/ts/proxies/smart-proxy/models/route-types.ts +28 -1
- package/ts/proxies/smart-proxy/models/rust-types.ts +21 -3
- package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +13 -3
- package/ts/proxies/smart-proxy/smart-proxy.ts +115 -40
- package/ts/proxies/smart-proxy/utils/route-validator.ts +329 -4
- 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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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']
|
|
704
|
-
return buildRustProxyOptions(this.settings, routes,
|
|
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):
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1206
|
-
if (this.settings.certProvisionFallbackToAcme !== false &&
|
|
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.
|
|
1209
|
-
logger.log('info', `
|
|
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
|
|
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
|
|
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<
|
|
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,
|