844 lines
27 KiB
TypeScript
844 lines
27 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { ProxyRouter } from './classes.router.js';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
export interface INetworkProxyOptions {
|
|
port: number;
|
|
maxConnections?: number;
|
|
keepAliveTimeout?: number;
|
|
headersTimeout?: number;
|
|
logLevel?: 'error' | 'warn' | 'info' | 'debug';
|
|
cors?: {
|
|
allowOrigin?: string;
|
|
allowMethods?: string;
|
|
allowHeaders?: string;
|
|
maxAge?: number;
|
|
};
|
|
}
|
|
|
|
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
|
lastPong: number;
|
|
isAlive: boolean;
|
|
}
|
|
|
|
export class NetworkProxy {
|
|
// Configuration
|
|
public options: INetworkProxyOptions;
|
|
public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
|
public defaultHeaders: { [key: string]: string } = {};
|
|
|
|
// Server instances
|
|
public httpsServer: plugins.https.Server;
|
|
public wsServer: plugins.ws.WebSocketServer;
|
|
|
|
// State tracking
|
|
public router = new ProxyRouter();
|
|
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
|
public activeContexts: Set<string> = new Set();
|
|
public connectedClients: number = 0;
|
|
public startTime: number = 0;
|
|
public requestsServed: number = 0;
|
|
public failedRequests: number = 0;
|
|
|
|
// Timers and intervals
|
|
private heartbeatInterval: NodeJS.Timeout;
|
|
private metricsInterval: NodeJS.Timeout;
|
|
|
|
// Certificates
|
|
private defaultCertificates: { key: string; cert: string };
|
|
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
|
|
|
/**
|
|
* Creates a new NetworkProxy instance
|
|
*/
|
|
constructor(optionsArg: INetworkProxyOptions) {
|
|
// Set default options
|
|
this.options = {
|
|
port: optionsArg.port,
|
|
maxConnections: optionsArg.maxConnections || 10000,
|
|
keepAliveTimeout: optionsArg.keepAliveTimeout || 120000, // 2 minutes
|
|
headersTimeout: optionsArg.headersTimeout || 60000, // 1 minute
|
|
logLevel: optionsArg.logLevel || 'info',
|
|
cors: optionsArg.cors || {
|
|
allowOrigin: '*',
|
|
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
|
|
allowHeaders: 'Content-Type, Authorization',
|
|
maxAge: 86400
|
|
}
|
|
};
|
|
|
|
this.loadDefaultCertificates();
|
|
}
|
|
|
|
/**
|
|
* Loads default certificates from the filesystem
|
|
*/
|
|
private loadDefaultCertificates(): void {
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
|
|
|
try {
|
|
this.defaultCertificates = {
|
|
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
|
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
|
};
|
|
this.log('info', 'Default certificates loaded successfully');
|
|
} catch (error) {
|
|
this.log('error', 'Error loading default certificates', error);
|
|
|
|
// Generate self-signed fallback certificates
|
|
try {
|
|
// This is a placeholder for actual certificate generation code
|
|
// In a real implementation, you would use a library like selfsigned to generate certs
|
|
this.defaultCertificates = {
|
|
key: "FALLBACK_KEY_CONTENT",
|
|
cert: "FALLBACK_CERT_CONTENT"
|
|
};
|
|
this.log('warn', 'Using fallback self-signed certificates');
|
|
} catch (fallbackError) {
|
|
this.log('error', 'Failed to generate fallback certificates', fallbackError);
|
|
throw new Error('Could not load or generate SSL certificates');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts the proxy server
|
|
*/
|
|
public async start(): Promise<void> {
|
|
this.startTime = Date.now();
|
|
|
|
// Create the HTTPS server
|
|
this.httpsServer = plugins.https.createServer(
|
|
{
|
|
key: this.defaultCertificates.key,
|
|
cert: this.defaultCertificates.cert
|
|
},
|
|
(req, res) => this.handleRequest(req, res)
|
|
);
|
|
|
|
// Configure server timeouts
|
|
this.httpsServer.keepAliveTimeout = this.options.keepAliveTimeout;
|
|
this.httpsServer.headersTimeout = this.options.headersTimeout;
|
|
|
|
// Setup connection tracking
|
|
this.setupConnectionTracking();
|
|
|
|
// Setup WebSocket support
|
|
this.setupWebsocketSupport();
|
|
|
|
// Start metrics collection
|
|
this.setupMetricsCollection();
|
|
|
|
// Start the server
|
|
return new Promise((resolve) => {
|
|
this.httpsServer.listen(this.options.port, () => {
|
|
this.log('info', `NetworkProxy started on port ${this.options.port}`);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets up tracking of TCP connections
|
|
*/
|
|
private setupConnectionTracking(): void {
|
|
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
|
// Check if max connections reached
|
|
if (this.socketMap.getArray().length >= this.options.maxConnections) {
|
|
this.log('warn', `Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
|
|
connection.destroy();
|
|
return;
|
|
}
|
|
|
|
// Add connection to tracking
|
|
this.socketMap.add(connection);
|
|
this.connectedClients = this.socketMap.getArray().length;
|
|
this.log('debug', `New connection. Currently ${this.connectedClients} active connections`);
|
|
|
|
// Setup connection cleanup handlers
|
|
const cleanupConnection = () => {
|
|
if (this.socketMap.checkForObject(connection)) {
|
|
this.socketMap.remove(connection);
|
|
this.connectedClients = this.socketMap.getArray().length;
|
|
this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
|
|
}
|
|
};
|
|
|
|
connection.on('close', cleanupConnection);
|
|
connection.on('error', (err) => {
|
|
this.log('debug', 'Connection error', err);
|
|
cleanupConnection();
|
|
});
|
|
connection.on('end', cleanupConnection);
|
|
connection.on('timeout', () => {
|
|
this.log('debug', 'Connection timeout');
|
|
cleanupConnection();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets up WebSocket support
|
|
*/
|
|
private setupWebsocketSupport(): void {
|
|
// Create WebSocket server
|
|
this.wsServer = new plugins.ws.WebSocketServer({
|
|
server: this.httpsServer,
|
|
// Add WebSocket specific timeout
|
|
clientTracking: true
|
|
});
|
|
|
|
// Handle WebSocket connections
|
|
this.wsServer.on('connection', (wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
|
this.handleWebSocketConnection(wsIncoming, reqArg);
|
|
});
|
|
|
|
// Set up the heartbeat interval (check every 30 seconds, terminate after 2 minutes of inactivity)
|
|
this.heartbeatInterval = setInterval(() => {
|
|
if (this.wsServer.clients.size === 0) {
|
|
return; // Skip if no active connections
|
|
}
|
|
|
|
this.log('debug', `WebSocket heartbeat check for ${this.wsServer.clients.size} clients`);
|
|
this.wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
|
const wsWithHeartbeat = ws as IWebSocketWithHeartbeat;
|
|
|
|
if (wsWithHeartbeat.isAlive === false) {
|
|
this.log('debug', 'Terminating inactive WebSocket connection');
|
|
return wsWithHeartbeat.terminate();
|
|
}
|
|
|
|
wsWithHeartbeat.isAlive = false;
|
|
wsWithHeartbeat.ping();
|
|
});
|
|
}, 30000);
|
|
}
|
|
|
|
/**
|
|
* Sets up metrics collection
|
|
*/
|
|
private setupMetricsCollection(): void {
|
|
this.metricsInterval = setInterval(() => {
|
|
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
|
const metrics = {
|
|
uptime,
|
|
activeConnections: this.connectedClients,
|
|
totalRequests: this.requestsServed,
|
|
failedRequests: this.failedRequests,
|
|
activeWebSockets: this.wsServer?.clients.size || 0,
|
|
memoryUsage: process.memoryUsage(),
|
|
activeContexts: Array.from(this.activeContexts)
|
|
};
|
|
|
|
this.log('debug', 'Proxy metrics', metrics);
|
|
}, 60000); // Log metrics every minute
|
|
}
|
|
|
|
/**
|
|
* Handles an incoming WebSocket connection
|
|
*/
|
|
private handleWebSocketConnection(wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage): void {
|
|
const wsPath = reqArg.url;
|
|
const wsHost = reqArg.headers.host;
|
|
|
|
this.log('info', `WebSocket connection for ${wsHost}${wsPath}`);
|
|
|
|
// Setup heartbeat tracking
|
|
wsIncoming.isAlive = true;
|
|
wsIncoming.lastPong = Date.now();
|
|
wsIncoming.on('pong', () => {
|
|
wsIncoming.isAlive = true;
|
|
wsIncoming.lastPong = Date.now();
|
|
});
|
|
|
|
// Get the destination configuration
|
|
const wsDestinationConfig = this.router.routeReq(reqArg);
|
|
if (!wsDestinationConfig) {
|
|
this.log('warn', `No route found for WebSocket ${wsHost}${wsPath}`);
|
|
wsIncoming.terminate();
|
|
return;
|
|
}
|
|
|
|
// Check authentication if required
|
|
if (wsDestinationConfig.authentication) {
|
|
try {
|
|
if (!this.authenticateRequest(reqArg, wsDestinationConfig)) {
|
|
this.log('warn', `WebSocket authentication failed for ${wsHost}${wsPath}`);
|
|
wsIncoming.terminate();
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.log('error', 'WebSocket authentication error', error);
|
|
wsIncoming.terminate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Setup outgoing WebSocket connection
|
|
let wsOutgoing: plugins.wsDefault;
|
|
const outGoingDeferred = plugins.smartpromise.defer();
|
|
|
|
try {
|
|
const wsTarget = `ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`;
|
|
this.log('debug', `Proxying WebSocket to ${wsTarget}`);
|
|
|
|
wsOutgoing = new plugins.wsDefault(wsTarget);
|
|
|
|
wsOutgoing.on('open', () => {
|
|
this.log('debug', 'Outgoing WebSocket connection established');
|
|
outGoingDeferred.resolve();
|
|
});
|
|
|
|
wsOutgoing.on('error', (error) => {
|
|
this.log('error', 'Outgoing WebSocket error', error);
|
|
outGoingDeferred.reject(error);
|
|
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
wsIncoming.terminate();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
this.log('error', 'Failed to create outgoing WebSocket connection', err);
|
|
wsIncoming.terminate();
|
|
return;
|
|
}
|
|
|
|
// Handle message forwarding from client to backend
|
|
wsIncoming.on('message', async (message, isBinary) => {
|
|
try {
|
|
// Wait for outgoing connection to be ready
|
|
await outGoingDeferred.promise;
|
|
|
|
// Only forward if both connections are still open
|
|
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
|
wsOutgoing.send(message, { binary: isBinary });
|
|
}
|
|
} catch (error) {
|
|
this.log('error', 'Error forwarding WebSocket message to backend', error);
|
|
}
|
|
});
|
|
|
|
// Handle message forwarding from backend to client
|
|
wsOutgoing.on('message', (message, isBinary) => {
|
|
try {
|
|
// Only forward if the incoming connection is still open
|
|
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
wsIncoming.send(message, { binary: isBinary });
|
|
}
|
|
} catch (error) {
|
|
this.log('error', 'Error forwarding WebSocket message to client', error);
|
|
}
|
|
});
|
|
|
|
// Clean up connections when either side closes
|
|
wsIncoming.on('close', (code, reason) => {
|
|
this.log('debug', `Incoming WebSocket closed: ${code} - ${reason}`);
|
|
if (wsOutgoing && wsOutgoing.readyState !== wsOutgoing.CLOSED) {
|
|
try {
|
|
// Validate close code (must be 1000-4999) or use 1000 as default
|
|
const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
|
|
wsOutgoing.close(validCode, reason.toString() || '');
|
|
} catch (error) {
|
|
this.log('error', 'Error closing outgoing WebSocket', error);
|
|
wsOutgoing.terminate();
|
|
}
|
|
}
|
|
});
|
|
|
|
wsOutgoing.on('close', (code, reason) => {
|
|
this.log('debug', `Outgoing WebSocket closed: ${code} - ${reason}`);
|
|
if (wsIncoming && wsIncoming.readyState !== wsIncoming.CLOSED) {
|
|
try {
|
|
// Validate close code (must be 1000-4999) or use 1000 as default
|
|
const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
|
|
wsIncoming.close(validCode, reason.toString() || '');
|
|
} catch (error) {
|
|
this.log('error', 'Error closing incoming WebSocket', error);
|
|
wsIncoming.terminate();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles an HTTP/HTTPS request
|
|
*/
|
|
private async handleRequest(
|
|
originRequest: plugins.http.IncomingMessage,
|
|
originResponse: plugins.http.ServerResponse
|
|
): Promise<void> {
|
|
this.requestsServed++;
|
|
const startTime = Date.now();
|
|
const reqId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
|
|
try {
|
|
const reqPath = plugins.url.parse(originRequest.url).path;
|
|
this.log('info', `[${reqId}] ${originRequest.method} ${originRequest.headers.host}${reqPath}`);
|
|
|
|
// Handle preflight OPTIONS requests for CORS
|
|
if (originRequest.method === 'OPTIONS' && this.options.cors) {
|
|
this.handleCorsRequest(originRequest, originResponse);
|
|
return;
|
|
}
|
|
|
|
// Get destination configuration
|
|
const destinationConfig = this.router.routeReq(originRequest);
|
|
if (!destinationConfig) {
|
|
this.log('warn', `[${reqId}] No route found for ${originRequest.headers.host}`);
|
|
this.sendErrorResponse(originResponse, 404, 'Not Found: No matching route');
|
|
this.failedRequests++;
|
|
return;
|
|
}
|
|
|
|
// Handle authentication if configured
|
|
if (destinationConfig.authentication) {
|
|
try {
|
|
if (!this.authenticateRequest(originRequest, destinationConfig)) {
|
|
this.sendErrorResponse(originResponse, 401, 'Unauthorized', {
|
|
'WWW-Authenticate': 'Basic realm="Access to the proxy site", charset="UTF-8"'
|
|
});
|
|
this.failedRequests++;
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.log('error', `[${reqId}] Authentication error`, error);
|
|
this.sendErrorResponse(originResponse, 500, 'Internal Server Error: Authentication failed');
|
|
this.failedRequests++;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Construct destination URL
|
|
const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
|
this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
|
|
|
|
// Forward the request
|
|
await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
|
|
|
|
const processingTime = Date.now() - startTime;
|
|
this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
|
|
} catch (error) {
|
|
this.log('error', `[${reqId}] Unhandled error in request handler`, error);
|
|
try {
|
|
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Server error');
|
|
} catch (responseError) {
|
|
this.log('error', `[${reqId}] Failed to send error response`, responseError);
|
|
}
|
|
this.failedRequests++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles a CORS preflight request
|
|
*/
|
|
private handleCorsRequest(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse
|
|
): void {
|
|
const cors = this.options.cors;
|
|
|
|
// Set CORS headers
|
|
res.setHeader('Access-Control-Allow-Origin', cors.allowOrigin);
|
|
res.setHeader('Access-Control-Allow-Methods', cors.allowMethods);
|
|
res.setHeader('Access-Control-Allow-Headers', cors.allowHeaders);
|
|
res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
|
|
|
|
// Handle preflight request
|
|
res.statusCode = 204;
|
|
res.end();
|
|
|
|
// Count this as a request served
|
|
this.requestsServed++;
|
|
}
|
|
|
|
/**
|
|
* Authenticates a request against the destination config
|
|
*/
|
|
private authenticateRequest(
|
|
req: plugins.http.IncomingMessage,
|
|
config: plugins.tsclass.network.IReverseProxyConfig
|
|
): boolean {
|
|
const authInfo = config.authentication;
|
|
if (!authInfo) {
|
|
return true; // No authentication required
|
|
}
|
|
|
|
switch (authInfo.type) {
|
|
case 'Basic': {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.includes('Basic ')) {
|
|
return false;
|
|
}
|
|
|
|
const authStringBase64 = authHeader.replace('Basic ', '');
|
|
const authString: string = plugins.smartstring.base64.decode(authStringBase64);
|
|
const [user, pass] = authString.split(':');
|
|
|
|
// Use constant-time comparison to prevent timing attacks
|
|
const userMatch = user === authInfo.user;
|
|
const passMatch = pass === authInfo.pass;
|
|
|
|
return userMatch && passMatch;
|
|
}
|
|
default:
|
|
throw new Error(`Unsupported authentication method: ${authInfo.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forwards a request to the destination
|
|
*/
|
|
private async forwardRequest(
|
|
reqId: string,
|
|
originRequest: plugins.http.IncomingMessage,
|
|
originResponse: plugins.http.ServerResponse,
|
|
destinationUrl: string
|
|
): Promise<void> {
|
|
try {
|
|
const proxyRequest = await plugins.smartrequest.request(
|
|
destinationUrl,
|
|
{
|
|
method: originRequest.method,
|
|
headers: this.prepareForwardHeaders(originRequest),
|
|
keepAlive: true,
|
|
timeout: 30000 // 30 second timeout
|
|
},
|
|
true, // streaming
|
|
(proxyRequestStream) => this.setupRequestStreaming(originRequest, proxyRequestStream)
|
|
);
|
|
|
|
// Handle the response
|
|
this.processProxyResponse(reqId, originResponse, proxyRequest);
|
|
} catch (error) {
|
|
this.log('error', `[${reqId}] Error forwarding request`, error);
|
|
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
|
|
throw error; // Let the main handler catch this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepares headers to forward to the backend
|
|
*/
|
|
private prepareForwardHeaders(req: plugins.http.IncomingMessage): plugins.http.OutgoingHttpHeaders {
|
|
const safeHeaders = { ...req.headers };
|
|
|
|
// Add forwarding headers
|
|
safeHeaders['X-Forwarded-Host'] = req.headers.host;
|
|
safeHeaders['X-Forwarded-Proto'] = 'https';
|
|
safeHeaders['X-Forwarded-For'] = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
|
|
|
// Add proxy-specific headers
|
|
safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
|
|
|
|
// Remove sensitive headers we don't want to forward
|
|
const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
|
|
for (const header of sensitiveHeaders) {
|
|
delete safeHeaders[header];
|
|
}
|
|
|
|
return safeHeaders;
|
|
}
|
|
|
|
/**
|
|
* Sets up request streaming for the proxy
|
|
*/
|
|
private setupRequestStreaming(
|
|
originRequest: plugins.http.IncomingMessage,
|
|
proxyRequest: plugins.http.ClientRequest
|
|
): void {
|
|
// Forward request body data
|
|
originRequest.on('data', (chunk) => {
|
|
proxyRequest.write(chunk);
|
|
});
|
|
|
|
// End the request when done
|
|
originRequest.on('end', () => {
|
|
proxyRequest.end();
|
|
});
|
|
|
|
// Handle request errors
|
|
originRequest.on('error', (error) => {
|
|
this.log('error', 'Error in client request stream', error);
|
|
proxyRequest.destroy(error);
|
|
});
|
|
|
|
// Handle client abort/timeout
|
|
originRequest.on('close', () => {
|
|
if (!originRequest.complete) {
|
|
this.log('debug', 'Client closed connection before request completed');
|
|
proxyRequest.destroy();
|
|
}
|
|
});
|
|
|
|
originRequest.on('timeout', () => {
|
|
this.log('debug', 'Client request timeout');
|
|
proxyRequest.destroy(new Error('Client request timeout'));
|
|
});
|
|
|
|
// Handle proxy request errors
|
|
proxyRequest.on('error', (error) => {
|
|
this.log('error', 'Error in outgoing proxy request', error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Processes a proxy response
|
|
*/
|
|
private processProxyResponse(
|
|
reqId: string,
|
|
originResponse: plugins.http.ServerResponse,
|
|
proxyResponse: plugins.http.IncomingMessage
|
|
): void {
|
|
this.log('debug', `[${reqId}] Received upstream response: ${proxyResponse.statusCode}`);
|
|
|
|
// Set status code
|
|
originResponse.statusCode = proxyResponse.statusCode;
|
|
|
|
// Add default headers
|
|
for (const [headerName, headerValue] of Object.entries(this.defaultHeaders)) {
|
|
originResponse.setHeader(headerName, headerValue);
|
|
}
|
|
|
|
// Add CORS headers if enabled
|
|
if (this.options.cors) {
|
|
originResponse.setHeader('Access-Control-Allow-Origin', this.options.cors.allowOrigin);
|
|
}
|
|
|
|
// Copy response headers
|
|
for (const [headerName, headerValue] of Object.entries(proxyResponse.headers)) {
|
|
// Skip hop-by-hop headers
|
|
const hopByHopHeaders = ['connection', 'keep-alive', 'transfer-encoding', 'te',
|
|
'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'];
|
|
if (!hopByHopHeaders.includes(headerName.toLowerCase())) {
|
|
originResponse.setHeader(headerName, headerValue);
|
|
}
|
|
}
|
|
|
|
// Stream response body
|
|
proxyResponse.on('data', (chunk) => {
|
|
const canContinue = originResponse.write(chunk);
|
|
|
|
// Apply backpressure if needed
|
|
if (!canContinue) {
|
|
proxyResponse.pause();
|
|
originResponse.once('drain', () => {
|
|
proxyResponse.resume();
|
|
});
|
|
}
|
|
});
|
|
|
|
// End the response when done
|
|
proxyResponse.on('end', () => {
|
|
originResponse.end();
|
|
});
|
|
|
|
// Handle response errors
|
|
proxyResponse.on('error', (error) => {
|
|
this.log('error', `[${reqId}] Error in proxy response stream`, error);
|
|
originResponse.destroy(error);
|
|
});
|
|
|
|
originResponse.on('error', (error) => {
|
|
this.log('error', `[${reqId}] Error in client response stream`, error);
|
|
proxyResponse.destroy();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sends an error response to the client
|
|
*/
|
|
private sendErrorResponse(
|
|
res: plugins.http.ServerResponse,
|
|
statusCode: number = 500,
|
|
message: string = 'Internal Server Error',
|
|
headers: plugins.http.OutgoingHttpHeaders = {}
|
|
): void {
|
|
try {
|
|
// If headers already sent, just end the response
|
|
if (res.headersSent) {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Add default headers
|
|
for (const [key, value] of Object.entries(this.defaultHeaders)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
|
|
// Add provided headers
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
|
|
// Send error response
|
|
res.writeHead(statusCode, message);
|
|
|
|
// Send error body as JSON for API clients
|
|
if (res.getHeader('Content-Type') === 'application/json') {
|
|
res.end(JSON.stringify({ error: { status: statusCode, message } }));
|
|
} else {
|
|
// Send as plain text
|
|
res.end(message);
|
|
}
|
|
} catch (error) {
|
|
this.log('error', 'Error sending error response', error);
|
|
try {
|
|
res.destroy();
|
|
} catch (destroyError) {
|
|
// Last resort - nothing more we can do
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates proxy configurations
|
|
*/
|
|
public async updateProxyConfigs(
|
|
proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]
|
|
): Promise<void> {
|
|
this.log('info', `Updating proxy configurations (${proxyConfigsArg.length} configs)`);
|
|
|
|
// Update internal configs
|
|
this.proxyConfigs = proxyConfigsArg;
|
|
this.router.setNewProxyConfigs(proxyConfigsArg);
|
|
|
|
// Collect all hostnames for cleanup later
|
|
const currentHostNames = new Set<string>();
|
|
|
|
// Add/update SSL contexts for each host
|
|
for (const config of proxyConfigsArg) {
|
|
currentHostNames.add(config.hostName);
|
|
|
|
try {
|
|
// Check if we need to update the cert
|
|
const currentCert = this.certificateCache.get(config.hostName);
|
|
const shouldUpdate = !currentCert ||
|
|
currentCert.key !== config.privateKey ||
|
|
currentCert.cert !== config.publicKey;
|
|
|
|
if (shouldUpdate) {
|
|
this.log('debug', `Updating SSL context for ${config.hostName}`);
|
|
|
|
// Update the HTTPS server context
|
|
this.httpsServer.addContext(config.hostName, {
|
|
key: config.privateKey,
|
|
cert: config.publicKey
|
|
});
|
|
|
|
// Update the cache
|
|
this.certificateCache.set(config.hostName, {
|
|
key: config.privateKey,
|
|
cert: config.publicKey
|
|
});
|
|
|
|
this.activeContexts.add(config.hostName);
|
|
}
|
|
} catch (error) {
|
|
this.log('error', `Failed to add SSL context for ${config.hostName}`, error);
|
|
}
|
|
}
|
|
|
|
// Clean up removed contexts
|
|
// Note: Node.js doesn't officially support removing contexts
|
|
// This would require server restart in production
|
|
for (const hostname of this.activeContexts) {
|
|
if (!currentHostNames.has(hostname)) {
|
|
this.log('info', `Hostname ${hostname} removed from configuration`);
|
|
this.activeContexts.delete(hostname);
|
|
this.certificateCache.delete(hostname);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds default headers to be included in all responses
|
|
*/
|
|
public async addDefaultHeaders(headersArg: { [key: string]: string }): Promise<void> {
|
|
this.log('info', 'Adding default headers', headersArg);
|
|
this.defaultHeaders = {
|
|
...this.defaultHeaders,
|
|
...headersArg
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stops the proxy server
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
this.log('info', 'Stopping NetworkProxy server');
|
|
|
|
// Clear intervals
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
}
|
|
|
|
if (this.metricsInterval) {
|
|
clearInterval(this.metricsInterval);
|
|
}
|
|
|
|
// Close WebSocket server if exists
|
|
if (this.wsServer) {
|
|
for (const client of this.wsServer.clients) {
|
|
try {
|
|
client.terminate();
|
|
} catch (error) {
|
|
this.log('error', 'Error terminating WebSocket client', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close all tracked sockets
|
|
for (const socket of this.socketMap.getArray()) {
|
|
try {
|
|
socket.destroy();
|
|
} catch (error) {
|
|
this.log('error', 'Error destroying socket', error);
|
|
}
|
|
}
|
|
|
|
// Close the HTTPS server
|
|
return new Promise((resolve) => {
|
|
this.httpsServer.close(() => {
|
|
this.log('info', 'NetworkProxy server stopped successfully');
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Logs a message according to the configured log level
|
|
*/
|
|
private log(level: 'error' | 'warn' | 'info' | 'debug', message: string, data?: any): void {
|
|
const logLevels = {
|
|
error: 0,
|
|
warn: 1,
|
|
info: 2,
|
|
debug: 3
|
|
};
|
|
|
|
// Skip if log level is higher than configured
|
|
if (logLevels[level] > logLevels[this.options.logLevel]) {
|
|
return;
|
|
}
|
|
|
|
const timestamp = new Date().toISOString();
|
|
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
|
|
|
switch (level) {
|
|
case 'error':
|
|
console.error(`${prefix} ${message}`, data || '');
|
|
break;
|
|
case 'warn':
|
|
console.warn(`${prefix} ${message}`, data || '');
|
|
break;
|
|
case 'info':
|
|
console.log(`${prefix} ${message}`, data || '');
|
|
break;
|
|
case 'debug':
|
|
console.log(`${prefix} ${message}`, data || '');
|
|
break;
|
|
}
|
|
}
|
|
} |