@push.rocks/smartproxy 27.12.7 → 27.13.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/changelog.md +12 -7
- package/dist_rust/rustproxy_linux_amd64 +0 -0
- package/dist_rust/rustproxy_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/plugins.d.ts +4 -2
- package/dist_ts/plugins.js +5 -3
- package/dist_ts/proxies/smart-proxy/challenge-provider-relay-server.d.ts +31 -0
- package/dist_ts/proxies/smart-proxy/challenge-provider-relay-server.js +297 -0
- package/dist_ts/proxies/smart-proxy/models/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +14 -2
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +11 -0
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +5 -3
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +2 -1
- package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +4 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +19 -0
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +339 -89
- package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +3 -0
- package/dist_ts/proxies/smart-proxy/utils/route-validator.js +81 -1
- package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +3 -2
- package/dist_ts/proxies/smart-proxy/utils/rust-config.js +20 -4
- package/package.json +2 -1
- package/readme.hints.md +2 -2
- package/readme.md +87 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -1
- package/ts/plugins.ts +4 -1
- package/ts/proxies/smart-proxy/challenge-provider-relay-server.ts +338 -0
- package/ts/proxies/smart-proxy/models/index.ts +1 -1
- package/ts/proxies/smart-proxy/models/interfaces.ts +17 -2
- package/ts/proxies/smart-proxy/models/route-types.ts +17 -0
- package/ts/proxies/smart-proxy/models/rust-types.ts +8 -3
- package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +6 -0
- package/ts/proxies/smart-proxy/smart-proxy.ts +400 -94
- package/ts/proxies/smart-proxy/utils/route-validator.ts +84 -1
- package/ts/proxies/smart-proxy/utils/rust-config.ts +22 -2
|
@@ -6,6 +6,7 @@ import { RustProxyBridge } from './rust-proxy-bridge.js';
|
|
|
6
6
|
import { RoutePreprocessor } from './route-preprocessor.js';
|
|
7
7
|
import { SocketHandlerServer } from './socket-handler-server.js';
|
|
8
8
|
import { DatagramHandlerServer } from './datagram-handler-server.js';
|
|
9
|
+
import { ChallengeProviderRelayServer } from './challenge-provider-relay-server.js';
|
|
9
10
|
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
|
10
11
|
|
|
11
12
|
// Route management
|
|
@@ -20,7 +21,9 @@ import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
|
|
20
21
|
import type { ISmartProxyOptions, ISmartProxySecurityPolicy, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent, ISmartProxyCertProvisionCertificate, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions } from './models/interfaces.js';
|
|
21
22
|
import type { IRouteConfig } from './models/route-types.js';
|
|
22
23
|
import type { IMetrics } from './models/metrics-types.js';
|
|
23
|
-
import type { IRustCertificateStatus, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';
|
|
24
|
+
import type { IRustCertificateStatus, IRustChallengeOptions, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';
|
|
25
|
+
|
|
26
|
+
type TChallengeProvider = plugins.smartchallenge.IChallengeProvider;
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
|
@@ -40,6 +43,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
40
43
|
private preprocessor: RoutePreprocessor;
|
|
41
44
|
private socketHandlerServer: SocketHandlerServer | null = null;
|
|
42
45
|
private datagramHandlerServer: DatagramHandlerServer | null = null;
|
|
46
|
+
private challengeProviderRelayServer: ChallengeProviderRelayServer | null = null;
|
|
47
|
+
private challengeProviders = new Map<string, TChallengeProvider>();
|
|
48
|
+
private challengeRuntimeOptions?: IRustChallengeOptions;
|
|
43
49
|
private metricsAdapter: RustMetricsAdapter;
|
|
44
50
|
private nftablesManager: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null;
|
|
45
51
|
private routeUpdateLock: Mutex;
|
|
@@ -116,119 +122,153 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
116
122
|
this.routeUpdateLock = new Mutex();
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Register a runtime challenge provider family. Routes reference providerId + challengeType;
|
|
127
|
+
* deployment wiring and provider secrets stay outside route configs.
|
|
128
|
+
*/
|
|
129
|
+
public registerChallengeProvider(providerId: string, provider: TChallengeProvider): void {
|
|
130
|
+
if (!providerId || typeof providerId !== 'string') {
|
|
131
|
+
throw new Error('Challenge providerId must be a non-empty string');
|
|
132
|
+
}
|
|
133
|
+
this.challengeProviders.set(providerId, provider);
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/**
|
|
120
137
|
* Start the proxy.
|
|
121
138
|
* Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning.
|
|
122
139
|
*/
|
|
123
140
|
public async start(): Promise<void> {
|
|
124
|
-
|
|
125
|
-
const spawned = await this.bridge.spawn();
|
|
126
|
-
if (!spawned) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
|
129
|
-
'or build locally with: pnpm build'
|
|
130
|
-
);
|
|
131
|
-
}
|
|
141
|
+
await this.validateChallengeRoutes(this.settings.routes);
|
|
132
142
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
let didSpawn = false;
|
|
144
|
+
try {
|
|
145
|
+
// Spawn Rust binary
|
|
146
|
+
const spawned = await this.bridge.spawn();
|
|
147
|
+
if (!spawned) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
|
150
|
+
'or build locally with: pnpm build'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
didSpawn = true;
|
|
140
154
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
(
|
|
144
|
-
(
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
// Handle unexpected exit (only emits error if not intentionally stopping)
|
|
156
|
+
this.bridge.removeAllListeners('exit');
|
|
157
|
+
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
|
158
|
+
if (this.stopping) return;
|
|
159
|
+
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
|
|
160
|
+
this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
|
|
161
|
+
});
|
|
147
162
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
163
|
+
const hasChallengeRoutes = this.hasChallengeRoutes(this.settings.routes);
|
|
164
|
+
if (hasChallengeRoutes) {
|
|
165
|
+
await this.ensureChallengeProviderRelay();
|
|
166
|
+
}
|
|
153
167
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
|
|
161
|
-
await this.datagramHandlerServer.start();
|
|
162
|
-
}
|
|
168
|
+
// Check if any routes need TS-side handling (socket handlers, dynamic functions)
|
|
169
|
+
const hasHandlerRoutes = this.settings.routes.some(
|
|
170
|
+
(r) =>
|
|
171
|
+
(r.action.type === 'socket-handler' && r.action.socketHandler) ||
|
|
172
|
+
r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
|
|
173
|
+
);
|
|
163
174
|
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
// Start socket handler relay server (but don't tell Rust yet - proxy not started)
|
|
176
|
+
if (hasHandlerRoutes) {
|
|
177
|
+
this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
|
|
178
|
+
await this.socketHandlerServer.start();
|
|
179
|
+
}
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
// Check if any routes need datagram handler relay (UDP socket-handler routes)
|
|
182
|
+
const hasDatagramHandlers = this.settings.routes.some(
|
|
183
|
+
(r) => r.action.type === 'socket-handler' && r.action.datagramHandler
|
|
184
|
+
);
|
|
185
|
+
if (hasDatagramHandlers) {
|
|
186
|
+
const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
|
|
187
|
+
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
|
|
188
|
+
await this.datagramHandlerServer.start();
|
|
189
|
+
}
|
|
174
190
|
|
|
175
|
-
|
|
176
|
-
|
|
191
|
+
// Preprocess routes (strip JS functions, convert socket-handler routes)
|
|
192
|
+
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
|
|
177
193
|
|
|
178
|
-
|
|
179
|
-
|
|
194
|
+
// When certProvisionFunction handles cert provisioning,
|
|
195
|
+
// disable Rust's built-in ACME to prevent race condition.
|
|
196
|
+
let acmeForRust = this.settings.acme;
|
|
197
|
+
if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
|
|
198
|
+
acmeForRust = { ...acmeForRust, enabled: false };
|
|
199
|
+
logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
|
|
200
|
+
}
|
|
180
201
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
|
|
184
|
-
}
|
|
202
|
+
// Build Rust config
|
|
203
|
+
const config = this.buildRustConfig(rustRoutes, acmeForRust);
|
|
185
204
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
|
|
189
|
-
}
|
|
205
|
+
// Start the Rust proxy
|
|
206
|
+
await this.bridge.startProxy(config);
|
|
190
207
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const defaultCert = generateDefaultCertificate();
|
|
195
|
-
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
|
|
196
|
-
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
|
|
197
|
-
} catch (err: any) {
|
|
198
|
-
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
|
|
208
|
+
// Now that Rust proxy is running, configure socket handler relay
|
|
209
|
+
if (this.socketHandlerServer) {
|
|
210
|
+
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
|
|
199
211
|
}
|
|
200
|
-
}
|
|
201
212
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
// Configure challenge provider relay. The path is also present in startup config,
|
|
214
|
+
// but this hot setter keeps runtime changes explicit and mirrors other relays.
|
|
215
|
+
if (this.challengeProviderRelayServer) {
|
|
216
|
+
await this.bridge.setChallengeProviderRelay(
|
|
217
|
+
this.challengeProviderRelayServer.getSocketPath(),
|
|
218
|
+
this.challengeRuntimeOptions,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Configure datagram handler relay
|
|
223
|
+
if (this.datagramHandlerServer) {
|
|
224
|
+
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Load default self-signed fallback certificate (domain: '*')
|
|
228
|
+
if (!this.settings.disableDefaultCert) {
|
|
229
|
+
try {
|
|
230
|
+
const defaultCert = generateDefaultCertificate();
|
|
231
|
+
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
|
|
232
|
+
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
|
|
233
|
+
} catch (err) {
|
|
234
|
+
logger.log('warn', `Failed to generate default certificate: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Load consumer-stored certificates
|
|
239
|
+
const preloadedDomains = new Set<string>();
|
|
240
|
+
if (this.settings.certStore) {
|
|
241
|
+
try {
|
|
242
|
+
const stored = await this.settings.certStore.loadAll();
|
|
243
|
+
for (const entry of stored) {
|
|
244
|
+
await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
|
|
245
|
+
preloadedDomains.add(entry.domain);
|
|
246
|
+
}
|
|
247
|
+
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger.log('warn', `Failed to load certificates from consumer store: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
210
250
|
}
|
|
211
|
-
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
|
|
212
|
-
} catch (err: any) {
|
|
213
|
-
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
|
|
214
251
|
}
|
|
215
|
-
}
|
|
216
252
|
|
|
217
|
-
|
|
218
|
-
|
|
253
|
+
// Apply NFTables rules for routes using nftables forwarding engine
|
|
254
|
+
await this.applyNftablesRules(this.settings.routes);
|
|
219
255
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
256
|
+
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
|
|
257
|
+
// running and accepting connections, so metrics should be available immediately.
|
|
258
|
+
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
|
|
259
|
+
// not block metrics collection.
|
|
260
|
+
this.metricsAdapter.startPolling();
|
|
225
261
|
|
|
226
|
-
|
|
262
|
+
logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
|
|
227
263
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
// Fire-and-forget cert provisioning — Rust engine is already running and serving traffic.
|
|
265
|
+
// Events (certificate-issued / certificate-failed) fire independently per domain.
|
|
266
|
+
this.certProvisionPromise = this.provisionCertificatesViaCallback(preloadedDomains)
|
|
267
|
+
.catch((err) => logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' }));
|
|
268
|
+
} catch (err) {
|
|
269
|
+
await this.cleanupRuntimeResourcesAfterStartFailure(didSpawn);
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
/**
|
|
@@ -276,6 +316,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
276
316
|
this.datagramHandlerServer = null;
|
|
277
317
|
}
|
|
278
318
|
|
|
319
|
+
// Stop challenge provider relay
|
|
320
|
+
if (this.challengeProviderRelayServer) {
|
|
321
|
+
await this.challengeProviderRelayServer.stop();
|
|
322
|
+
this.challengeProviderRelayServer = null;
|
|
323
|
+
}
|
|
324
|
+
this.challengeRuntimeOptions = undefined;
|
|
325
|
+
|
|
279
326
|
logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
|
|
280
327
|
}
|
|
281
328
|
|
|
@@ -284,18 +331,41 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
284
331
|
*/
|
|
285
332
|
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
|
|
286
333
|
await this.routeUpdateLock.runExclusive(async () => {
|
|
287
|
-
|
|
334
|
+
await this.validateChallengeRoutes(newRoutes);
|
|
335
|
+
|
|
336
|
+
// Validate before starting relays so failed route updates do not leak runtime resources.
|
|
288
337
|
const validation = RouteValidator.validateRoutes(newRoutes);
|
|
289
338
|
if (!validation.valid) {
|
|
290
339
|
RouteValidator.logValidationErrors(validation.errors);
|
|
291
340
|
throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
|
|
292
341
|
}
|
|
293
342
|
|
|
343
|
+
const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes);
|
|
344
|
+
const challengeRelayWasStarted = Boolean(this.challengeProviderRelayServer);
|
|
345
|
+
|
|
294
346
|
// Preprocess for Rust
|
|
295
347
|
const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);
|
|
296
348
|
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
try {
|
|
350
|
+
if (hasChallengeRoutes) {
|
|
351
|
+
await this.ensureChallengeProviderRelay();
|
|
352
|
+
await this.bridge.setChallengeProviderRelay(
|
|
353
|
+
this.challengeProviderRelayServer!.getSocketPath(),
|
|
354
|
+
this.challengeRuntimeOptions,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Send to Rust
|
|
359
|
+
await this.bridge.updateRoutes(rustRoutes);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) {
|
|
362
|
+
await this.bridge.setChallengeProviderRelay('').catch(() => undefined);
|
|
363
|
+
await this.challengeProviderRelayServer.stop();
|
|
364
|
+
this.challengeProviderRelayServer = null;
|
|
365
|
+
this.challengeRuntimeOptions = undefined;
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
299
369
|
|
|
300
370
|
// Update local route manager
|
|
301
371
|
this.routeManager.updateRoutes(newRoutes);
|
|
@@ -331,6 +401,15 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
331
401
|
this.datagramHandlerServer = null;
|
|
332
402
|
}
|
|
333
403
|
|
|
404
|
+
if (!hasChallengeRoutes && this.challengeProviderRelayServer) {
|
|
405
|
+
await this.bridge.setChallengeProviderRelay('').catch((err) => {
|
|
406
|
+
logger.log('warn', `Failed to clear challenge provider relay in Rust: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
407
|
+
});
|
|
408
|
+
await this.challengeProviderRelayServer.stop();
|
|
409
|
+
this.challengeProviderRelayServer = null;
|
|
410
|
+
this.challengeRuntimeOptions = undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
334
413
|
// Update NFTables rules
|
|
335
414
|
if (this.nftablesManager) {
|
|
336
415
|
await this.nftablesManager.cleanup();
|
|
@@ -456,6 +535,45 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
456
535
|
|
|
457
536
|
// --- Private helpers ---
|
|
458
537
|
|
|
538
|
+
private async cleanupRuntimeResourcesAfterStartFailure(didSpawn: boolean): Promise<void> {
|
|
539
|
+
this.metricsAdapter.stopPolling();
|
|
540
|
+
|
|
541
|
+
if (this.nftablesManager) {
|
|
542
|
+
await this.nftablesManager.cleanup().catch((err) => {
|
|
543
|
+
logger.log('warn', `Failed to clean NFTables after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
544
|
+
});
|
|
545
|
+
this.nftablesManager = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (this.socketHandlerServer) {
|
|
549
|
+
await this.socketHandlerServer.stop().catch((err) => {
|
|
550
|
+
logger.log('warn', `Failed to stop socket handler relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
551
|
+
});
|
|
552
|
+
this.socketHandlerServer = null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (this.datagramHandlerServer) {
|
|
556
|
+
await this.datagramHandlerServer.stop().catch((err) => {
|
|
557
|
+
logger.log('warn', `Failed to stop datagram handler relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
558
|
+
});
|
|
559
|
+
this.datagramHandlerServer = null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (this.challengeProviderRelayServer) {
|
|
563
|
+
await this.challengeProviderRelayServer.stop().catch((err) => {
|
|
564
|
+
logger.log('warn', `Failed to stop challenge provider relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
|
|
565
|
+
});
|
|
566
|
+
this.challengeProviderRelayServer = null;
|
|
567
|
+
this.challengeRuntimeOptions = undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (didSpawn) {
|
|
571
|
+
this.bridge.removeAllListeners('exit');
|
|
572
|
+
await this.bridge.stopProxy().catch(() => undefined);
|
|
573
|
+
this.bridge.kill();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
459
577
|
/**
|
|
460
578
|
* Apply NFTables rules for routes using the nftables forwarding engine.
|
|
461
579
|
*/
|
|
@@ -505,7 +623,195 @@ export class SmartProxy extends plugins.EventEmitter {
|
|
|
505
623
|
* Build the Rust configuration object from TS settings.
|
|
506
624
|
*/
|
|
507
625
|
private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
|
|
508
|
-
return buildRustProxyOptions(this.settings, routes, acmeOverride);
|
|
626
|
+
return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private hasChallengeRoutes(routes: IRouteConfig[]): boolean {
|
|
630
|
+
return routes.some((route) => Boolean(route.security?.challenge));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private async ensureChallengeProviderRelay(): Promise<void> {
|
|
634
|
+
const runtimeOptions = this.settings.challenge || {};
|
|
635
|
+
if (!this.challengeProviderRelayServer) {
|
|
636
|
+
this.challengeProviderRelayServer = new ChallengeProviderRelayServer(this.challengeProviders, {
|
|
637
|
+
providerTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
|
|
638
|
+
});
|
|
639
|
+
await this.challengeProviderRelayServer.start();
|
|
640
|
+
}
|
|
641
|
+
if (!this.challengeRuntimeOptions) {
|
|
642
|
+
this.challengeRuntimeOptions = {
|
|
643
|
+
cookieSigningKey: runtimeOptions.cookieSigningKey || plugins.crypto.randomBytes(32).toString('base64url'),
|
|
644
|
+
pendingCookieName: runtimeOptions.pendingCookieName || '__smartproxy_challenge_pending',
|
|
645
|
+
clearanceCookieName: runtimeOptions.clearanceCookieName || '__smartproxy_clearance',
|
|
646
|
+
reservedPathPrefix: runtimeOptions.reservedPathPrefix || '/.well-known/smartproxy-challenge',
|
|
647
|
+
relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
|
|
648
|
+
relayTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
|
|
649
|
+
pendingTtlSeconds: runtimeOptions.pendingTtlSeconds ?? 300,
|
|
650
|
+
};
|
|
651
|
+
} else {
|
|
652
|
+
this.challengeRuntimeOptions = {
|
|
653
|
+
...this.challengeRuntimeOptions,
|
|
654
|
+
relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private async validateChallengeRoutes(routes: IRouteConfig[]): Promise<void> {
|
|
660
|
+
const manifestCache = new Map<string, plugins.smartchallenge.IChallengeProviderManifest>();
|
|
661
|
+
const errors: string[] = [];
|
|
662
|
+
|
|
663
|
+
for (const route of routes) {
|
|
664
|
+
const challenge = route.security?.challenge;
|
|
665
|
+
if (!challenge) continue;
|
|
666
|
+
const routeName = route.name || route.id || 'unnamed route';
|
|
667
|
+
|
|
668
|
+
if (!challenge.providerId || typeof challenge.providerId !== 'string') {
|
|
669
|
+
errors.push(`${routeName}: challenge.providerId must be a non-empty string`);
|
|
670
|
+
}
|
|
671
|
+
if (!challenge.challengeType || typeof challenge.challengeType !== 'string') {
|
|
672
|
+
errors.push(`${routeName}: challenge.challengeType must be a non-empty string`);
|
|
673
|
+
}
|
|
674
|
+
if (!route.name && !route.id) {
|
|
675
|
+
errors.push(`${routeName}: challenge routes must set a stable route name or id`);
|
|
676
|
+
}
|
|
677
|
+
this.validateChallengeIntentShape(challenge, errors, routeName);
|
|
678
|
+
if (route.action.type !== 'forward') {
|
|
679
|
+
errors.push(`${routeName}: challenge routes must use forward actions`);
|
|
680
|
+
}
|
|
681
|
+
if (route.action.forwardingEngine === 'nftables') {
|
|
682
|
+
errors.push(`${routeName}: challenge routes cannot use nftables forwarding`);
|
|
683
|
+
}
|
|
684
|
+
if (route.action.tls?.mode === 'passthrough') {
|
|
685
|
+
errors.push(`${routeName}: challenge routes cannot use TLS passthrough`);
|
|
686
|
+
}
|
|
687
|
+
for (const target of route.action.targets || []) {
|
|
688
|
+
if (typeof target.host === 'function' || typeof target.port === 'function') {
|
|
689
|
+
errors.push(`${routeName}: challenge routes cannot use dynamic target host or port functions`);
|
|
690
|
+
}
|
|
691
|
+
if (target.tls?.mode === 'passthrough') {
|
|
692
|
+
errors.push(`${routeName}: challenge routes cannot use target TLS passthrough`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (route.match.transport === 'udp' || route.match.transport === 'all') {
|
|
696
|
+
errors.push(`${routeName}: challenge routes currently require HTTP-visible TCP handling`);
|
|
697
|
+
}
|
|
698
|
+
if (route.match.protocol !== 'http') {
|
|
699
|
+
errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`);
|
|
700
|
+
}
|
|
701
|
+
this.collectForbiddenChallengeKeys(challenge, `security.challenge`, errors, routeName);
|
|
702
|
+
|
|
703
|
+
const provider = this.challengeProviders.get(challenge.providerId);
|
|
704
|
+
if (!provider) {
|
|
705
|
+
errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
let manifest = manifestCache.get(challenge.providerId);
|
|
709
|
+
if (!manifest) {
|
|
710
|
+
manifest = await provider.getManifest();
|
|
711
|
+
manifestCache.set(challenge.providerId, manifest);
|
|
712
|
+
}
|
|
713
|
+
if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
|
|
714
|
+
errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (errors.length > 0) {
|
|
719
|
+
throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private validateChallengeIntentShape(challengeArg: unknown, errors: string[], routeName: string): void {
|
|
724
|
+
if (!this.isPlainRecord(challengeArg)) {
|
|
725
|
+
errors.push(`${routeName}: security.challenge must be an object`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
this.validateAllowedChallengeKeys(
|
|
730
|
+
challengeArg,
|
|
731
|
+
'security.challenge',
|
|
732
|
+
new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']),
|
|
733
|
+
errors,
|
|
734
|
+
routeName,
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
const settings = challengeArg.settings;
|
|
738
|
+
if (settings !== undefined && !this.isPlainRecord(settings)) {
|
|
739
|
+
errors.push(`${routeName}: security.challenge.settings must be an object when set`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const applyTo = challengeArg.applyTo;
|
|
743
|
+
if (applyTo !== undefined) {
|
|
744
|
+
if (!this.isPlainRecord(applyTo)) {
|
|
745
|
+
errors.push(`${routeName}: security.challenge.applyTo must be an object when set`);
|
|
746
|
+
} else {
|
|
747
|
+
this.validateAllowedChallengeKeys(
|
|
748
|
+
applyTo,
|
|
749
|
+
'security.challenge.applyTo',
|
|
750
|
+
new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']),
|
|
751
|
+
errors,
|
|
752
|
+
routeName,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const clearance = challengeArg.clearance;
|
|
758
|
+
if (clearance !== undefined) {
|
|
759
|
+
if (!this.isPlainRecord(clearance)) {
|
|
760
|
+
errors.push(`${routeName}: security.challenge.clearance must be an object when set`);
|
|
761
|
+
} else {
|
|
762
|
+
this.validateAllowedChallengeKeys(
|
|
763
|
+
clearance,
|
|
764
|
+
'security.challenge.clearance',
|
|
765
|
+
new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']),
|
|
766
|
+
errors,
|
|
767
|
+
routeName,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private validateAllowedChallengeKeys(
|
|
774
|
+
valueArg: Record<string, unknown>,
|
|
775
|
+
pathArg: string,
|
|
776
|
+
allowedKeysArg: Set<string>,
|
|
777
|
+
errors: string[],
|
|
778
|
+
routeName: string,
|
|
779
|
+
): void {
|
|
780
|
+
for (const key of Object.keys(valueArg)) {
|
|
781
|
+
if (!allowedKeysArg.has(key)) {
|
|
782
|
+
errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private isPlainRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
|
788
|
+
return Boolean(valueArg)
|
|
789
|
+
&& typeof valueArg === 'object'
|
|
790
|
+
&& !Array.isArray(valueArg);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[], routeName: string): void {
|
|
794
|
+
if (!value || typeof value !== 'object') return;
|
|
795
|
+
const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
|
|
796
|
+
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
|
797
|
+
const childPath = `${path}.${key}`;
|
|
798
|
+
if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
|
|
799
|
+
errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
|
|
800
|
+
}
|
|
801
|
+
if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
|
|
802
|
+
errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
|
|
803
|
+
}
|
|
804
|
+
if (nestedValue && typeof nestedValue === 'object') {
|
|
805
|
+
this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private looksLikeRuntimeChallengeValue(valueArg: string): boolean {
|
|
811
|
+
return /^https?:\/\//i.test(valueArg)
|
|
812
|
+
|| /^wss?:\/\//i.test(valueArg)
|
|
813
|
+
|| /(?:^|\b)(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\b|$)/.test(valueArg)
|
|
814
|
+
|| /^[a-z0-9.-]+:\d+$/i.test(valueArg);
|
|
509
815
|
}
|
|
510
816
|
|
|
511
817
|
/**
|