UNPKG

@push.rocks/smartproxy

Version:

A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.

339 lines (301 loc) 12.4 kB
import * as plugins from '../../plugins.js'; import { logger } from '../../core/utils/logger.js'; type TChallengeRelayOperation = 'assess' | 'render' | 'verify'; interface IChallengeRelayRequest { id?: string; operation: TChallengeRelayOperation; providerId: string; request: unknown; } interface IChallengeRelayResponse { id?: string; success: boolean; result?: unknown; error?: string; } const maxChallengeRelayMessageBytes = 1024 * 1024; const maxChallengeRelayIdBytes = 256; export interface IChallengeProviderRelayServerOptions { providerTimeoutMs?: number; maxActiveProviderOperations?: number; maxUnsettledProviderOperations?: number; } export class ChallengeProviderRelayServer { private providerTimeoutMs: number; private maxActiveProviderOperations: number; private maxUnsettledProviderOperations: number; private activeProviderOperations = 0; private unsettledProviderOperations = 0; private server: plugins.net.Server | null = null; private socketPath: string; private activeSockets = new Set<plugins.net.Socket>(); private startPromise?: Promise<void>; constructor( private providers: Map<string, plugins.smartchallenge.IChallengeProvider>, optionsArg: IChallengeProviderRelayServerOptions = {}, ) { this.socketPath = `/tmp/smartproxy-challenge-relay-${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}.sock`; this.providerTimeoutMs = Math.max(1, optionsArg.providerTimeoutMs ?? 5_000); this.maxActiveProviderOperations = Math.max(1, optionsArg.maxActiveProviderOperations ?? 1024); this.maxUnsettledProviderOperations = Math.max(1, optionsArg.maxUnsettledProviderOperations ?? this.maxActiveProviderOperations); } public getSocketPath(): string { return this.socketPath; } public async start(): Promise<void> { if (this.server) return; if (this.startPromise) return this.startPromise; this.startPromise = this.startInternal(); try { await this.startPromise; } finally { this.startPromise = undefined; } } private async startInternal(): Promise<void> { try { await plugins.fs.promises.unlink(this.socketPath); } catch { // Ignore stale socket cleanup failures for missing files. } const server = plugins.net.createServer((socket) => { this.activeSockets.add(socket); socket.once('close', () => this.activeSockets.delete(socket)); this.handleConnection(socket); }); server.on('error', (err) => { logger.log('error', `ChallengeProviderRelayServer error: ${err.message}`, { component: 'challenge-provider-relay-server' }); }); await new Promise<void>((resolve, reject) => { const handleError = (err: Error) => reject(err); server.once('error', handleError); server.listen(this.socketPath, () => { server.off('error', handleError); this.server = server; logger.log('info', `ChallengeProviderRelayServer listening on ${this.socketPath}`, { component: 'challenge-provider-relay-server' }); resolve(); }); }).catch((err) => { server.close(); throw err; }); } public async stop(): Promise<void> { if (this.startPromise && !this.server) { await this.startPromise.catch(() => undefined); } for (const socket of this.activeSockets) { socket.destroy(); } this.activeSockets.clear(); const server = this.server; if (!server) return; this.server = null; await new Promise<void>((resolve) => { server.close(() => { plugins.fs.unlink(this.socketPath, () => resolve()); }); }); } private handleConnection(socket: plugins.net.Socket): void { let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0); socket.setTimeout(Math.max(10_000, this.providerTimeoutMs + 1_000)); socket.on('timeout', () => socket.destroy()); socket.on('error', (err) => { logger.log('warn', `Challenge relay socket error: ${err.message}`, { component: 'challenge-provider-relay-server' }); }); socket.on('data', (chunk: Buffer) => { buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk], buffer.length + chunk.length); if (buffer.length > maxChallengeRelayMessageBytes) { socket.removeAllListeners('data'); this.writeResponse(socket, { success: false, error: 'Challenge relay request exceeds maximum size' }); buffer = Buffer.alloc(0); socket.end(); return; } const newlineIndex = buffer.indexOf(0x0a); if (newlineIndex === -1) return; const line = buffer.subarray(0, newlineIndex).toString('utf8'); socket.removeAllListeners('data'); this.dispatchLine(line) .then((response) => this.writeResponse(socket, response)) .catch((err) => this.writeResponse(socket, { success: false, error: (err as Error).message })) .finally(() => socket.end()); }); } private async dispatchLine(lineArg: string): Promise<IChallengeRelayResponse> { let request: IChallengeRelayRequest; try { request = JSON.parse(lineArg) as IChallengeRelayRequest; } catch { return { success: false, error: 'Invalid challenge relay JSON' }; } const provider = this.providers.get(request.providerId); if (!provider) { return { id: request.id, success: false, error: `Challenge provider '${request.providerId}' is not registered`, }; } switch (request.operation) { case 'assess': return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.assess(request.request as plugins.smartchallenge.IChallengeAssessRequest), 'assess') }; case 'render': return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.render(request.request as plugins.smartchallenge.IChallengeRenderRequest), 'render') }; case 'verify': return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.verify(request.request as plugins.smartchallenge.IChallengeVerifyRequest), 'verify') }; default: const operationValue = (request as { operation?: unknown }).operation; return { id: request.id, success: false, error: `Unsupported challenge relay operation '${String(operationValue)}'` }; } } private async withProviderTimeout<T>(operationArg: () => Promise<T>, operationNameArg: TChallengeRelayOperation): Promise<T> { if (this.activeProviderOperations >= this.maxActiveProviderOperations) { throw new Error(`Challenge provider ${operationNameArg} rejected because too many operations are active`); } if (this.unsettledProviderOperations >= this.maxUnsettledProviderOperations) { throw new Error(`Challenge provider ${operationNameArg} rejected because too many timed-out operations are still settling`); } let timer: ReturnType<typeof setTimeout> | undefined; let activeReleased = false; let unsettledReleased = false; const releaseActive = () => { if (!activeReleased) { activeReleased = true; this.activeProviderOperations -= 1; } }; const releaseUnsettled = () => { if (!unsettledReleased) { unsettledReleased = true; this.unsettledProviderOperations -= 1; } }; this.activeProviderOperations += 1; this.unsettledProviderOperations += 1; const providerPromise = Promise.resolve().then(operationArg); const trackedProviderPromise = providerPromise.then( (result) => { releaseActive(); releaseUnsettled(); return result; }, (err) => { releaseActive(); releaseUnsettled(); throw err; }, ); trackedProviderPromise.catch(() => undefined); try { return await Promise.race([ trackedProviderPromise, new Promise<T>((_resolve, reject) => { timer = setTimeout( () => { releaseActive(); reject(new Error(`Challenge provider ${operationNameArg} timed out after ${this.providerTimeoutMs}ms`)); }, this.providerTimeoutMs, ); }), ]); } finally { if (timer) clearTimeout(timer); } } private writeResponse(socket: plugins.net.Socket, responseArg: IChallengeRelayResponse): void { let response = responseArg; if (this.estimateJsonByteLength(response, maxChallengeRelayMessageBytes) === undefined) { response = this.createErrorResponse(responseArg.id, 'Challenge relay response exceeds maximum size'); } let serializedResponse: string; try { serializedResponse = JSON.stringify(response); } catch { serializedResponse = JSON.stringify(this.createErrorResponse(responseArg.id, 'Challenge relay response is not serializable')); } if (Buffer.byteLength(serializedResponse, 'utf8') > maxChallengeRelayMessageBytes) { serializedResponse = JSON.stringify({ success: false, error: 'Challenge relay response exceeds maximum size', }); } socket.write(`${serializedResponse}\n`); } private createErrorResponse(idArg: string | undefined, errorArg: string): IChallengeRelayResponse { const response: IChallengeRelayResponse = { success: false, error: errorArg, }; if (idArg && Buffer.byteLength(idArg, 'utf8') <= maxChallengeRelayIdBytes) { response.id = idArg; } return response; } private estimateJsonByteLength( valueArg: unknown, maxBytesArg: number, seenArg = new Set<object>(), ): number | undefined { const bytes = this.estimateJsonByteLengthInternal(valueArg, maxBytesArg, seenArg); return bytes !== undefined && bytes <= maxBytesArg ? bytes : undefined; } private estimateJsonByteLengthInternal( valueArg: unknown, maxBytesArg: number, seenArg: Set<object>, ): number | undefined { if (valueArg === null) return 4; if (typeof valueArg === 'string') return this.estimateJsonStringByteLength(valueArg, maxBytesArg); if (typeof valueArg === 'number') return Number.isFinite(valueArg) ? String(valueArg).length : 4; if (typeof valueArg === 'boolean') return valueArg ? 4 : 5; if (typeof valueArg === 'undefined') return 4; if (typeof valueArg === 'bigint' || typeof valueArg === 'symbol' || typeof valueArg === 'function') return undefined; if (typeof valueArg !== 'object') return undefined; if (seenArg.has(valueArg)) return undefined; seenArg.add(valueArg); try { let totalBytes = 2; if (Array.isArray(valueArg)) { for (let i = 0; i < valueArg.length; i++) { const childBytes = this.estimateJsonByteLengthInternal(valueArg[i], maxBytesArg, seenArg); if (childBytes === undefined) return undefined; totalBytes += childBytes + (i > 0 ? 1 : 0); if (totalBytes > maxBytesArg) return undefined; } return totalBytes; } let propertyCount = 0; for (const [key, value] of Object.entries(valueArg as Record<string, unknown>)) { if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') continue; const keyBytes = this.estimateJsonStringByteLength(key, maxBytesArg); const valueBytes = this.estimateJsonByteLengthInternal(value, maxBytesArg, seenArg); if (keyBytes === undefined || valueBytes === undefined) return undefined; totalBytes += keyBytes + valueBytes + 1 + (propertyCount > 0 ? 1 : 0); propertyCount += 1; if (totalBytes > maxBytesArg) return undefined; } return totalBytes; } finally { seenArg.delete(valueArg); } } private estimateJsonStringByteLength(valueArg: string, maxBytesArg: number): number | undefined { let bytes = Buffer.byteLength(valueArg, 'utf8') + 2; if (bytes > maxBytesArg) return undefined; for (let i = 0; i < valueArg.length; i++) { const code = valueArg.charCodeAt(i); if (code === 0x22 || code === 0x5c) { bytes += 1; } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { bytes += 1; } else if (code < 0x20) { bytes += 5; } if (bytes > maxBytesArg) return undefined; } return bytes; } }