@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
text/typescript
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;
}
}