338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
/**
|
|
* Socket Handler Functions
|
|
*
|
|
* This module provides pre-built socket handlers for common use cases
|
|
* like echoing, proxying, HTTP responses, and redirects.
|
|
*/
|
|
|
|
import * as plugins from '../../../../plugins.js';
|
|
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
|
|
import { ProtocolDetector } from '../../../../detection/index.js';
|
|
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
|
|
|
|
/**
|
|
* Pre-built socket handlers for common use cases
|
|
*/
|
|
export const SocketHandlers = {
|
|
/**
|
|
* Simple echo server handler
|
|
*/
|
|
echo: (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
socket.write('ECHO SERVER READY\n');
|
|
socket.on('data', data => socket.write(data));
|
|
},
|
|
|
|
/**
|
|
* TCP proxy handler
|
|
*/
|
|
proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const target = plugins.net.connect(targetPort, targetHost);
|
|
socket.pipe(target);
|
|
target.pipe(socket);
|
|
socket.on('close', () => target.destroy());
|
|
target.on('close', () => socket.destroy());
|
|
target.on('error', (err) => {
|
|
console.error('Proxy target error:', err);
|
|
socket.destroy();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Line-based protocol handler
|
|
*/
|
|
lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
let buffer = '';
|
|
socket.on('data', (data) => {
|
|
buffer += data.toString();
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
lines.forEach(line => {
|
|
if (line.trim()) {
|
|
handler(line.trim(), socket);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Simple HTTP response handler (for testing)
|
|
*/
|
|
httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const response = [
|
|
`HTTP/1.1 ${statusCode} ${statusCode === 200 ? 'OK' : 'Error'}`,
|
|
'Content-Type: text/plain',
|
|
`Content-Length: ${body.length}`,
|
|
'Connection: close',
|
|
'',
|
|
body
|
|
].join('\r\n');
|
|
|
|
socket.write(response);
|
|
socket.end();
|
|
},
|
|
|
|
/**
|
|
* Block connection immediately
|
|
*/
|
|
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
|
if (finalMessage) {
|
|
socket.write(finalMessage);
|
|
}
|
|
socket.end();
|
|
},
|
|
|
|
/**
|
|
* HTTP block response
|
|
*/
|
|
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
|
const finalMessage = message || defaultMessage;
|
|
|
|
const response = [
|
|
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
|
'Content-Type: text/plain',
|
|
`Content-Length: ${finalMessage.length}`,
|
|
'Connection: close',
|
|
'',
|
|
finalMessage
|
|
].join('\r\n');
|
|
|
|
socket.write(response);
|
|
socket.end();
|
|
},
|
|
|
|
/**
|
|
* HTTP redirect handler
|
|
* Uses the centralized detection module for HTTP parsing
|
|
*/
|
|
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const tracker = createSocketTracker(socket);
|
|
const connectionId = ProtocolDetector.createConnectionId({
|
|
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
|
});
|
|
|
|
const handleData = async (data: Buffer) => {
|
|
// Use detection module for parsing
|
|
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
|
data,
|
|
connectionId,
|
|
{ extractFullHeaders: false } // We only need method and path
|
|
);
|
|
|
|
if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) {
|
|
const method = detectionResult.connectionInfo.method || 'GET';
|
|
const path = detectionResult.connectionInfo.path || '/';
|
|
|
|
const domain = context.domain || 'localhost';
|
|
const port = context.port;
|
|
|
|
let finalLocation = locationTemplate
|
|
.replace('{domain}', domain)
|
|
.replace('{port}', String(port))
|
|
.replace('{path}', path)
|
|
.replace('{clientIp}', context.clientIp);
|
|
|
|
const message = `Redirecting to ${finalLocation}`;
|
|
const response = [
|
|
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
|
`Location: ${finalLocation}`,
|
|
'Content-Type: text/plain',
|
|
`Content-Length: ${message.length}`,
|
|
'Connection: close',
|
|
'',
|
|
message
|
|
].join('\r\n');
|
|
|
|
socket.write(response);
|
|
} else {
|
|
// Not a valid HTTP request, close connection
|
|
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
}
|
|
|
|
socket.end();
|
|
// Clean up detection state
|
|
ProtocolDetector.cleanupConnections();
|
|
// Clean up all tracked resources
|
|
tracker.cleanup();
|
|
};
|
|
|
|
// Use tracker to manage the listener
|
|
socket.once('data', handleData);
|
|
|
|
tracker.addListener('error', (err) => {
|
|
tracker.safeDestroy(err);
|
|
});
|
|
|
|
tracker.addListener('close', () => {
|
|
tracker.cleanup();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* HTTP server handler for ACME challenges and other HTTP needs
|
|
* Uses the centralized detection module for HTTP parsing
|
|
*/
|
|
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
|
const tracker = createSocketTracker(socket);
|
|
let requestParsed = false;
|
|
let responseTimer: NodeJS.Timeout | null = null;
|
|
const connectionId = ProtocolDetector.createConnectionId({
|
|
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
|
});
|
|
|
|
const processData = async (data: Buffer) => {
|
|
if (requestParsed) return; // Only handle the first request
|
|
|
|
// Use HttpDetector for parsing
|
|
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
|
data,
|
|
connectionId,
|
|
{ extractFullHeaders: true }
|
|
);
|
|
|
|
if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) {
|
|
// Not a complete HTTP request yet
|
|
return;
|
|
}
|
|
|
|
requestParsed = true;
|
|
// Remove data listener after parsing request
|
|
socket.removeListener('data', processData);
|
|
const connInfo = detectionResult.connectionInfo;
|
|
|
|
// Create request object from detection result
|
|
const req = {
|
|
method: connInfo.method || 'GET',
|
|
url: connInfo.path || '/',
|
|
headers: connInfo.headers || {},
|
|
body: detectionResult.remainingBuffer?.toString() || ''
|
|
};
|
|
|
|
// Create response object
|
|
let statusCode = 200;
|
|
const responseHeaders: Record<string, string> = {};
|
|
let ended = false;
|
|
|
|
const res = {
|
|
status: (code: number) => {
|
|
statusCode = code;
|
|
},
|
|
header: (name: string, value: string) => {
|
|
responseHeaders[name] = value;
|
|
},
|
|
send: (data: string) => {
|
|
if (ended) return;
|
|
ended = true;
|
|
|
|
// Clear response timer since we're sending now
|
|
if (responseTimer) {
|
|
clearTimeout(responseTimer);
|
|
responseTimer = null;
|
|
}
|
|
|
|
if (!responseHeaders['content-type']) {
|
|
responseHeaders['content-type'] = 'text/plain';
|
|
}
|
|
responseHeaders['content-length'] = String(data.length);
|
|
responseHeaders['connection'] = 'close';
|
|
|
|
const statusText = statusCode === 200 ? 'OK' :
|
|
statusCode === 404 ? 'Not Found' :
|
|
statusCode === 500 ? 'Internal Server Error' : 'Response';
|
|
|
|
let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`;
|
|
for (const [name, value] of Object.entries(responseHeaders)) {
|
|
response += `${name}: ${value}\r\n`;
|
|
}
|
|
response += '\r\n';
|
|
response += data;
|
|
|
|
socket.write(response);
|
|
socket.end();
|
|
},
|
|
end: () => {
|
|
if (ended) return;
|
|
ended = true;
|
|
socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
|
|
socket.end();
|
|
}
|
|
};
|
|
|
|
try {
|
|
handler(req, res);
|
|
// Ensure response is sent even if handler doesn't call send()
|
|
responseTimer = setTimeout(() => {
|
|
if (!ended) {
|
|
res.send('');
|
|
}
|
|
responseTimer = null;
|
|
}, 1000);
|
|
// Track and unref the timer
|
|
tracker.addTimer(responseTimer);
|
|
} catch (error) {
|
|
if (!ended) {
|
|
res.status(500);
|
|
res.send('Internal Server Error');
|
|
}
|
|
// Use safeDestroy for error cases
|
|
tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error'));
|
|
}
|
|
};
|
|
|
|
// Use tracker to manage listeners
|
|
tracker.addListener('data', processData);
|
|
|
|
tracker.addListener('error', (err) => {
|
|
if (!requestParsed) {
|
|
tracker.safeDestroy(err);
|
|
}
|
|
});
|
|
|
|
tracker.addListener('close', () => {
|
|
// Clear any pending response timer
|
|
if (responseTimer) {
|
|
clearTimeout(responseTimer);
|
|
responseTimer = null;
|
|
}
|
|
// Clean up detection state
|
|
ProtocolDetector.cleanupConnections();
|
|
// Clean up all tracked resources
|
|
tracker.cleanup();
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Create a socket handler route configuration
|
|
* @param domains Domain(s) to match
|
|
* @param ports Port(s) to listen on
|
|
* @param handler Socket handler function
|
|
* @param options Additional route options
|
|
* @returns Route configuration object
|
|
*/
|
|
export function createSocketHandlerRoute(
|
|
domains: string | string[],
|
|
ports: TPortRange,
|
|
handler: (socket: plugins.net.Socket) => void | Promise<void>,
|
|
options: {
|
|
name?: string;
|
|
priority?: number;
|
|
path?: string;
|
|
} = {}
|
|
): IRouteConfig {
|
|
return {
|
|
name: options.name || 'socket-handler-route',
|
|
priority: options.priority !== undefined ? options.priority : 50,
|
|
match: {
|
|
domains,
|
|
ports,
|
|
...(options.path && { path: options.path })
|
|
},
|
|
action: {
|
|
type: 'socket-handler',
|
|
socketHandler: handler
|
|
}
|
|
};
|
|
}
|