@push.rocks/smartproxy 27.12.8 → 27.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/changelog.md +16 -1
  2. package/dist_rust/rustproxy_linux_amd64 +0 -0
  3. package/dist_rust/rustproxy_linux_arm64 +0 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/index.d.ts +1 -1
  6. package/dist_ts/plugins.d.ts +4 -2
  7. package/dist_ts/plugins.js +5 -3
  8. package/dist_ts/proxies/smart-proxy/challenge-provider-relay-server.d.ts +31 -0
  9. package/dist_ts/proxies/smart-proxy/challenge-provider-relay-server.js +297 -0
  10. package/dist_ts/proxies/smart-proxy/models/index.d.ts +1 -1
  11. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +13 -0
  12. package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +2 -0
  13. package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
  14. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +4 -1
  15. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +2 -1
  16. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +4 -1
  17. package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +19 -0
  18. package/dist_ts/proxies/smart-proxy/smart-proxy.js +344 -89
  19. package/dist_ts/proxies/smart-proxy/utils/rust-config.d.ts +3 -2
  20. package/dist_ts/proxies/smart-proxy/utils/rust-config.js +19 -2
  21. package/package.json +2 -1
  22. package/readme.md +64 -1
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/index.ts +1 -1
  25. package/ts/plugins.ts +4 -1
  26. package/ts/proxies/smart-proxy/challenge-provider-relay-server.ts +338 -0
  27. package/ts/proxies/smart-proxy/models/index.ts +1 -1
  28. package/ts/proxies/smart-proxy/models/interfaces.ts +16 -0
  29. package/ts/proxies/smart-proxy/models/route-types.ts +3 -0
  30. package/ts/proxies/smart-proxy/models/rust-types.ts +7 -1
  31. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +6 -0
  32. package/ts/proxies/smart-proxy/smart-proxy.ts +405 -94
  33. package/ts/proxies/smart-proxy/utils/rust-config.ts +21 -0
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
2
2
  import { logger } from '../../core/utils/logger.js';
3
3
  import type {
4
4
  IRustCertificateStatus,
5
+ IRustChallengeOptions,
5
6
  IRustMetricsSnapshot,
6
7
  IRustProxyOptions,
7
8
  IRustRouteConfig,
@@ -25,6 +26,7 @@ type TSmartProxyCommands = {
25
26
  getCertificateStatus: { params: { routeName: string }; result: IRustCertificateStatus | null };
26
27
  getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
27
28
  setSocketHandlerRelay: { params: { socketPath: string }; result: void };
29
+ setChallengeProviderRelay: { params: { socketPath: string; options?: IRustChallengeOptions }; result: void };
28
30
  addListeningPort: { params: { port: number }; result: void };
29
31
  removeListeningPort: { params: { port: number }; result: void };
30
32
  loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
@@ -181,6 +183,10 @@ export class RustProxyBridge extends plugins.EventEmitter {
181
183
  await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
182
184
  }
183
185
 
186
+ public async setChallengeProviderRelay(socketPath: string, options?: IRustChallengeOptions): Promise<void> {
187
+ await this.bridge.sendCommand('setChallengeProviderRelay', { socketPath, options });
188
+ }
189
+
184
190
  public async addListeningPort(port: number): Promise<void> {
185
191
  await this.bridge.sendCommand('addListeningPort', { port });
186
192
  }
@@ -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
- // Spawn Rust binary
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
- // Handle unexpected exit (only emits error if not intentionally stopping)
134
- this.bridge.removeAllListeners('exit');
135
- this.bridge.on('exit', (code: number | null, signal: string | null) => {
136
- if (this.stopping) return;
137
- logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
138
- this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
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
- // Check if any routes need TS-side handling (socket handlers, dynamic functions)
142
- const hasHandlerRoutes = this.settings.routes.some(
143
- (r) =>
144
- (r.action.type === 'socket-handler' && r.action.socketHandler) ||
145
- r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
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
- // Start socket handler relay server (but don't tell Rust yet - proxy not started)
149
- if (hasHandlerRoutes) {
150
- this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
151
- await this.socketHandlerServer.start();
152
- }
163
+ const hasChallengeRoutes = this.hasChallengeRoutes(this.settings.routes);
164
+ if (hasChallengeRoutes) {
165
+ await this.ensureChallengeProviderRelay();
166
+ }
153
167
 
154
- // Check if any routes need datagram handler relay (UDP socket-handler routes)
155
- const hasDatagramHandlers = this.settings.routes.some(
156
- (r) => r.action.type === 'socket-handler' && r.action.datagramHandler
157
- );
158
- if (hasDatagramHandlers) {
159
- const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
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
- // Preprocess routes (strip JS functions, convert socket-handler routes)
165
- const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
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
- // When certProvisionFunction handles cert provisioning,
168
- // disable Rust's built-in ACME to prevent race condition.
169
- let acmeForRust = this.settings.acme;
170
- if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
171
- acmeForRust = { ...acmeForRust, enabled: false };
172
- logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
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
- // Build Rust config
176
- const config = this.buildRustConfig(rustRoutes, acmeForRust);
191
+ // Preprocess routes (strip JS functions, convert socket-handler routes)
192
+ const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
177
193
 
178
- // Start the Rust proxy
179
- await this.bridge.startProxy(config);
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
- // Now that Rust proxy is running, configure socket handler relay
182
- if (this.socketHandlerServer) {
183
- await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
184
- }
202
+ // Build Rust config
203
+ const config = this.buildRustConfig(rustRoutes, acmeForRust);
185
204
 
186
- // Configure datagram handler relay
187
- if (this.datagramHandlerServer) {
188
- await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
189
- }
205
+ // Start the Rust proxy
206
+ await this.bridge.startProxy(config);
190
207
 
191
- // Load default self-signed fallback certificate (domain: '*')
192
- if (!this.settings.disableDefaultCert) {
193
- try {
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
- // Load consumer-stored certificates
203
- const preloadedDomains = new Set<string>();
204
- if (this.settings.certStore) {
205
- try {
206
- const stored = await this.settings.certStore.loadAll();
207
- for (const entry of stored) {
208
- await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
209
- preloadedDomains.add(entry.domain);
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
- // Apply NFTables rules for routes using nftables forwarding engine
218
- await this.applyNftablesRules(this.settings.routes);
253
+ // Apply NFTables rules for routes using nftables forwarding engine
254
+ await this.applyNftablesRules(this.settings.routes);
219
255
 
220
- // Start metrics polling BEFORE cert provisioning — the Rust engine is already
221
- // running and accepting connections, so metrics should be available immediately.
222
- // Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
223
- // not block metrics collection.
224
- this.metricsAdapter.startPolling();
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
- logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
262
+ logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
227
263
 
228
- // Fire-and-forget cert provisioning — Rust engine is already running and serving traffic.
229
- // Events (certificate-issued / certificate-failed) fire independently per domain.
230
- this.certProvisionPromise = this.provisionCertificatesViaCallback(preloadedDomains)
231
- .catch((err) => logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' }));
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
- // Validate
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
- // Send to Rust
298
- await this.bridge.updateRoutes(rustRoutes);
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,200 @@ 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
+ const hasHttp3Transport = route.match.transport === 'all'
696
+ && route.action.udp?.quic?.enableHttp3 === true;
697
+ if (route.match.transport === 'udp') {
698
+ errors.push(`${routeName}: challenge routes cannot use pure UDP transport`);
699
+ }
700
+ if (route.match.transport === 'all' && !hasHttp3Transport) {
701
+ errors.push(`${routeName}: challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
702
+ }
703
+ if (route.match.protocol !== 'http') {
704
+ errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`);
705
+ }
706
+ this.collectForbiddenChallengeKeys(challenge, `security.challenge`, errors, routeName);
707
+
708
+ const provider = this.challengeProviders.get(challenge.providerId);
709
+ if (!provider) {
710
+ errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
711
+ continue;
712
+ }
713
+ let manifest = manifestCache.get(challenge.providerId);
714
+ if (!manifest) {
715
+ manifest = await provider.getManifest();
716
+ manifestCache.set(challenge.providerId, manifest);
717
+ }
718
+ if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
719
+ errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
720
+ }
721
+ }
722
+
723
+ if (errors.length > 0) {
724
+ throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
725
+ }
726
+ }
727
+
728
+ private validateChallengeIntentShape(challengeArg: unknown, errors: string[], routeName: string): void {
729
+ if (!this.isPlainRecord(challengeArg)) {
730
+ errors.push(`${routeName}: security.challenge must be an object`);
731
+ return;
732
+ }
733
+
734
+ this.validateAllowedChallengeKeys(
735
+ challengeArg,
736
+ 'security.challenge',
737
+ new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']),
738
+ errors,
739
+ routeName,
740
+ );
741
+
742
+ const settings = challengeArg.settings;
743
+ if (settings !== undefined && !this.isPlainRecord(settings)) {
744
+ errors.push(`${routeName}: security.challenge.settings must be an object when set`);
745
+ }
746
+
747
+ const applyTo = challengeArg.applyTo;
748
+ if (applyTo !== undefined) {
749
+ if (!this.isPlainRecord(applyTo)) {
750
+ errors.push(`${routeName}: security.challenge.applyTo must be an object when set`);
751
+ } else {
752
+ this.validateAllowedChallengeKeys(
753
+ applyTo,
754
+ 'security.challenge.applyTo',
755
+ new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']),
756
+ errors,
757
+ routeName,
758
+ );
759
+ }
760
+ }
761
+
762
+ const clearance = challengeArg.clearance;
763
+ if (clearance !== undefined) {
764
+ if (!this.isPlainRecord(clearance)) {
765
+ errors.push(`${routeName}: security.challenge.clearance must be an object when set`);
766
+ } else {
767
+ this.validateAllowedChallengeKeys(
768
+ clearance,
769
+ 'security.challenge.clearance',
770
+ new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']),
771
+ errors,
772
+ routeName,
773
+ );
774
+ }
775
+ }
776
+ }
777
+
778
+ private validateAllowedChallengeKeys(
779
+ valueArg: Record<string, unknown>,
780
+ pathArg: string,
781
+ allowedKeysArg: Set<string>,
782
+ errors: string[],
783
+ routeName: string,
784
+ ): void {
785
+ for (const key of Object.keys(valueArg)) {
786
+ if (!allowedKeysArg.has(key)) {
787
+ errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
788
+ }
789
+ }
790
+ }
791
+
792
+ private isPlainRecord(valueArg: unknown): valueArg is Record<string, unknown> {
793
+ return Boolean(valueArg)
794
+ && typeof valueArg === 'object'
795
+ && !Array.isArray(valueArg);
796
+ }
797
+
798
+ private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[], routeName: string): void {
799
+ if (!value || typeof value !== 'object') return;
800
+ const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
801
+ for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
802
+ const childPath = `${path}.${key}`;
803
+ if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
804
+ errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
805
+ }
806
+ if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
807
+ errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
808
+ }
809
+ if (nestedValue && typeof nestedValue === 'object') {
810
+ this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName);
811
+ }
812
+ }
813
+ }
814
+
815
+ private looksLikeRuntimeChallengeValue(valueArg: string): boolean {
816
+ return /^https?:\/\//i.test(valueArg)
817
+ || /^wss?:\/\//i.test(valueArg)
818
+ || /(?:^|\b)(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\b|$)/.test(valueArg)
819
+ || /^[a-z0-9.-]+:\d+$/i.test(valueArg);
509
820
  }
510
821
 
511
822
  /**
@@ -4,6 +4,7 @@ import type {
4
4
  IRustAcmeOptions,
5
5
  IRustDefaultConfig,
6
6
  IRustProxyOptions,
7
+ IRustChallengeOptions,
7
8
  IRustRouteAction,
8
9
  IRustRouteConfig,
9
10
  IRustRouteMatch,
@@ -146,10 +147,29 @@ function serializeDefaultsForRust(defaults?: ISmartProxyOptions['defaults']): IR
146
147
  };
147
148
  }
148
149
 
150
+ export function buildChallengeOptionsForRust(settings: ISmartProxyOptions): IRustChallengeOptions | undefined {
151
+ const hasChallengeRoutes = settings.routes.some((route) => Boolean(route.security?.challenge));
152
+ if (!hasChallengeRoutes) {
153
+ return undefined;
154
+ }
155
+
156
+ const runtimeOptions = settings.challenge || {};
157
+ return {
158
+ cookieSigningKey: runtimeOptions.cookieSigningKey || '',
159
+ pendingCookieName: runtimeOptions.pendingCookieName || '__smartproxy_challenge_pending',
160
+ clearanceCookieName: runtimeOptions.clearanceCookieName || '__smartproxy_clearance',
161
+ reservedPathPrefix: runtimeOptions.reservedPathPrefix || '/.well-known/smartproxy-challenge',
162
+ relaySocketPath: runtimeOptions.relaySocketPath || '',
163
+ relayTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
164
+ pendingTtlSeconds: runtimeOptions.pendingTtlSeconds ?? 300,
165
+ };
166
+ }
167
+
149
168
  export function buildRustProxyOptions(
150
169
  settings: ISmartProxyOptions,
151
170
  routes: IRustRouteConfig[],
152
171
  acmeOverride?: IAcmeOptions,
172
+ challengeOverride?: IRustChallengeOptions,
153
173
  ): IRustProxyOptions {
154
174
  const acme = acmeOverride !== undefined ? acmeOverride : settings.acme;
155
175
 
@@ -182,6 +202,7 @@ export function buildRustProxyOptions(
182
202
  extendedKeepAliveLifetime: settings.extendedKeepAliveLifetime,
183
203
  metrics: settings.metrics,
184
204
  securityPolicy: settings.securityPolicy,
205
+ challenge: challengeOverride || buildChallengeOptionsForRust(settings),
185
206
  acme: serializeAcmeForRust(acme),
186
207
  };
187
208
  }