@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.
- package/.smartconfig.json +5 -3
- package/changelog.md +73 -0
- package/dist_rust/rustproxy_linux_amd64_musl +0 -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.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 +6 -1
- 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 +14 -0
- 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 +100 -36
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +44 -2
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +281 -2
- package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +2 -2
- package/dist_ts/proxies/smart-proxy/utils/rust-config.js +8 -5
- package/package.json +9 -11
- package/readme.md +67 -10
- 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 +7 -1
- package/ts/proxies/smart-proxy/models/route-types.ts +28 -1
- package/ts/proxies/smart-proxy/models/rust-types.ts +17 -1
- package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +13 -3
- package/ts/proxies/smart-proxy/smart-proxy.ts +109 -38
- package/ts/proxies/smart-proxy/utils/route-validator.ts +315 -3
- 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,
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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']
|
|
708
|
-
return buildRustProxyOptions(this.settings, routes,
|
|
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):
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1210
|
-
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) {
|
|
1211
1253
|
try {
|
|
1212
|
-
await this.
|
|
1213
|
-
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);
|
|
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`;
|
|
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
|
-
|
|
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(
|
|
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
|
}
|