915 lines
34 KiB
TypeScript
915 lines
34 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import '../../core/models/socket-augmentation.js';
|
|
import {
|
|
type INetworkProxyOptions,
|
|
type ILogger,
|
|
createLogger,
|
|
type IReverseProxyConfig,
|
|
RouteManager
|
|
} from './models/types.js';
|
|
import { ConnectionPool } from './connection-pool.js';
|
|
import { ProxyRouter } from '../../http/router/index.js';
|
|
import { ContextCreator } from './context-creator.js';
|
|
import { HttpRequestHandler } from './http-request-handler.js';
|
|
import { Http2RequestHandler } from './http2-request-handler.js';
|
|
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
|
import { toBaseContext } from '../../core/models/route-context.js';
|
|
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
|
import { SecurityManager } from './security-manager.js';
|
|
|
|
/**
|
|
* Interface for tracking metrics
|
|
*/
|
|
export interface IMetricsTracker {
|
|
incrementRequestsServed(): void;
|
|
incrementFailedRequests(): void;
|
|
}
|
|
|
|
// Backward compatibility
|
|
export type MetricsTracker = IMetricsTracker;
|
|
|
|
/**
|
|
* Handles HTTP request processing and proxying
|
|
*/
|
|
export class RequestHandler {
|
|
private defaultHeaders: { [key: string]: string } = {};
|
|
private logger: ILogger;
|
|
private metricsTracker: IMetricsTracker | null = null;
|
|
// HTTP/2 client sessions for backend proxying
|
|
private h2Sessions: Map<string, plugins.http2.ClientHttp2Session> = new Map();
|
|
|
|
// Context creator for route contexts
|
|
private contextCreator: ContextCreator = new ContextCreator();
|
|
|
|
// Security manager for IP filtering, rate limiting, etc.
|
|
public securityManager: SecurityManager;
|
|
|
|
constructor(
|
|
private options: INetworkProxyOptions,
|
|
private connectionPool: ConnectionPool,
|
|
private legacyRouter: ProxyRouter, // Legacy router for backward compatibility
|
|
private routeManager?: RouteManager,
|
|
private functionCache?: any, // FunctionCache - using any to avoid circular dependency
|
|
private router?: any // RouteRouter - using any to avoid circular dependency
|
|
) {
|
|
this.logger = createLogger(options.logLevel || 'info');
|
|
this.securityManager = new SecurityManager(this.logger);
|
|
|
|
// Schedule rate limit cleanup every minute
|
|
setInterval(() => {
|
|
this.securityManager.cleanupExpiredRateLimits();
|
|
}, 60000);
|
|
}
|
|
|
|
/**
|
|
* Set the route manager instance
|
|
*/
|
|
public setRouteManager(routeManager: RouteManager): void {
|
|
this.routeManager = routeManager;
|
|
}
|
|
|
|
/**
|
|
* Set the metrics tracker instance
|
|
*/
|
|
public setMetricsTracker(tracker: IMetricsTracker): void {
|
|
this.metricsTracker = tracker;
|
|
}
|
|
|
|
/**
|
|
* Set default headers to be included in all responses
|
|
*/
|
|
public setDefaultHeaders(headers: { [key: string]: string }): void {
|
|
this.defaultHeaders = {
|
|
...this.defaultHeaders,
|
|
...headers
|
|
};
|
|
this.logger.info('Updated default response headers');
|
|
}
|
|
|
|
/**
|
|
* Get all default headers
|
|
*/
|
|
public getDefaultHeaders(): { [key: string]: string } {
|
|
return { ...this.defaultHeaders };
|
|
}
|
|
|
|
/**
|
|
* Apply CORS headers to response if configured
|
|
* Implements Phase 5.5: Context-aware CORS handling
|
|
*
|
|
* @param res The server response to apply headers to
|
|
* @param req The incoming request
|
|
* @param route Optional route config with CORS settings
|
|
*/
|
|
private applyCorsHeaders(
|
|
res: plugins.http.ServerResponse,
|
|
req: plugins.http.IncomingMessage,
|
|
route?: IRouteConfig
|
|
): void {
|
|
// Use route-specific CORS config if available, otherwise use global config
|
|
let corsConfig: any = null;
|
|
|
|
// Route CORS config takes precedence if enabled
|
|
if (route?.headers?.cors?.enabled) {
|
|
corsConfig = route.headers.cors;
|
|
this.logger.debug(`Using route-specific CORS config for ${route.name || 'unnamed route'}`);
|
|
}
|
|
// Fall back to global CORS config if available
|
|
else if (this.options.cors) {
|
|
corsConfig = this.options.cors;
|
|
this.logger.debug('Using global CORS config');
|
|
}
|
|
|
|
// If no CORS config available, skip
|
|
if (!corsConfig) {
|
|
return;
|
|
}
|
|
|
|
// Get origin from request
|
|
const origin = req.headers.origin;
|
|
|
|
// Apply Allow-Origin (with dynamic validation if needed)
|
|
if (corsConfig.allowOrigin) {
|
|
// Handle multiple origins in array format
|
|
if (Array.isArray(corsConfig.allowOrigin)) {
|
|
if (origin && corsConfig.allowOrigin.includes(origin)) {
|
|
// Match found, set specific origin
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin'); // Important for caching
|
|
} else if (corsConfig.allowOrigin.includes('*')) {
|
|
// Wildcard match
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
}
|
|
}
|
|
// Handle single origin or wildcard
|
|
else if (corsConfig.allowOrigin === '*') {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
}
|
|
// Match single origin against request
|
|
else if (origin && corsConfig.allowOrigin === origin) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
}
|
|
// Use template variables if present
|
|
else if (origin && corsConfig.allowOrigin.includes('{')) {
|
|
const resolvedOrigin = TemplateUtils.resolveTemplateVariables(
|
|
corsConfig.allowOrigin,
|
|
{ domain: req.headers.host } as any
|
|
);
|
|
if (resolvedOrigin === origin || resolvedOrigin === '*') {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply other CORS headers
|
|
if (corsConfig.allowMethods) {
|
|
res.setHeader('Access-Control-Allow-Methods', corsConfig.allowMethods);
|
|
}
|
|
|
|
if (corsConfig.allowHeaders) {
|
|
res.setHeader('Access-Control-Allow-Headers', corsConfig.allowHeaders);
|
|
}
|
|
|
|
if (corsConfig.allowCredentials) {
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
}
|
|
|
|
if (corsConfig.exposeHeaders) {
|
|
res.setHeader('Access-Control-Expose-Headers', corsConfig.exposeHeaders);
|
|
}
|
|
|
|
if (corsConfig.maxAge) {
|
|
res.setHeader('Access-Control-Max-Age', corsConfig.maxAge.toString());
|
|
}
|
|
|
|
// Handle CORS preflight requests if enabled (default: true)
|
|
if (req.method === 'OPTIONS' && corsConfig.preflight !== false) {
|
|
res.statusCode = 204; // No content
|
|
res.end();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// First implementation of applyRouteHeaderModifications moved to the second implementation below
|
|
|
|
/**
|
|
* Apply default headers to response
|
|
*/
|
|
private applyDefaultHeaders(res: plugins.http.ServerResponse): void {
|
|
// Apply default headers
|
|
for (const [key, value] of Object.entries(this.defaultHeaders)) {
|
|
if (!res.hasHeader(key)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
}
|
|
|
|
// Add server identifier if not already set
|
|
if (!res.hasHeader('Server')) {
|
|
res.setHeader('Server', 'NetworkProxy');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply URL rewriting based on route configuration
|
|
* Implements Phase 5.2: URL rewriting using route context
|
|
*
|
|
* @param req The request with the URL to rewrite
|
|
* @param route The route configuration containing rewrite rules
|
|
* @param routeContext Context for template variable resolution
|
|
* @returns True if URL was rewritten, false otherwise
|
|
*/
|
|
private applyUrlRewriting(
|
|
req: plugins.http.IncomingMessage,
|
|
route: IRouteConfig,
|
|
routeContext: IHttpRouteContext
|
|
): boolean {
|
|
// Check if route has URL rewriting configuration
|
|
if (!route.action.advanced?.urlRewrite) {
|
|
return false;
|
|
}
|
|
|
|
const rewriteConfig = route.action.advanced.urlRewrite;
|
|
|
|
// Store original URL for logging
|
|
const originalUrl = req.url;
|
|
|
|
if (rewriteConfig.pattern && rewriteConfig.target) {
|
|
try {
|
|
// Create a RegExp from the pattern
|
|
const regex = new RegExp(rewriteConfig.pattern, rewriteConfig.flags || '');
|
|
|
|
// Apply rewriting with template variable resolution
|
|
let target = rewriteConfig.target;
|
|
|
|
// Replace template variables in target with values from context
|
|
target = TemplateUtils.resolveTemplateVariables(target, routeContext);
|
|
|
|
// If onlyRewritePath is set, split URL into path and query parts
|
|
if (rewriteConfig.onlyRewritePath && req.url) {
|
|
const [path, query] = req.url.split('?');
|
|
const rewrittenPath = path.replace(regex, target);
|
|
req.url = query ? `${rewrittenPath}?${query}` : rewrittenPath;
|
|
} else {
|
|
// Perform the replacement on the entire URL
|
|
req.url = req.url?.replace(regex, target);
|
|
}
|
|
|
|
this.logger.debug(`URL rewritten: ${originalUrl} -> ${req.url}`);
|
|
return true;
|
|
} catch (err) {
|
|
this.logger.error(`Error in URL rewriting: ${err}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Apply header modifications from route configuration
|
|
* Implements Phase 5.1: Route-based header manipulation
|
|
*/
|
|
private applyRouteHeaderModifications(
|
|
route: IRouteConfig,
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse
|
|
): void {
|
|
// Check if route has header modifications
|
|
if (!route.headers) {
|
|
return;
|
|
}
|
|
|
|
// Apply request header modifications (these will be sent to the backend)
|
|
if (route.headers.request && req.headers) {
|
|
for (const [key, value] of Object.entries(route.headers.request)) {
|
|
// Skip if header already exists and we're not overriding
|
|
if (req.headers[key.toLowerCase()] && !value.startsWith('!')) {
|
|
continue;
|
|
}
|
|
|
|
// Handle special delete directive (!delete)
|
|
if (value === '!delete') {
|
|
delete req.headers[key.toLowerCase()];
|
|
this.logger.debug(`Deleted request header: ${key}`);
|
|
continue;
|
|
}
|
|
|
|
// Handle forced override (!value)
|
|
let finalValue: string;
|
|
if (value.startsWith('!') && value !== '!delete') {
|
|
// Keep the ! but resolve any templates in the rest
|
|
const templateValue = value.substring(1);
|
|
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, {} as IRouteContext);
|
|
} else {
|
|
// Resolve templates in the entire value
|
|
finalValue = TemplateUtils.resolveTemplateVariables(value, {} as IRouteContext);
|
|
}
|
|
|
|
// Set the header
|
|
req.headers[key.toLowerCase()] = finalValue;
|
|
this.logger.debug(`Modified request header: ${key}=${finalValue}`);
|
|
}
|
|
}
|
|
|
|
// Apply response header modifications (these will be stored for later use)
|
|
if (route.headers.response) {
|
|
for (const [key, value] of Object.entries(route.headers.response)) {
|
|
// Skip if header already exists and we're not overriding
|
|
if (res.hasHeader(key) && !value.startsWith('!')) {
|
|
continue;
|
|
}
|
|
|
|
// Handle special delete directive (!delete)
|
|
if (value === '!delete') {
|
|
res.removeHeader(key);
|
|
this.logger.debug(`Deleted response header: ${key}`);
|
|
continue;
|
|
}
|
|
|
|
// Handle forced override (!value)
|
|
let finalValue: string;
|
|
if (value.startsWith('!') && value !== '!delete') {
|
|
// Keep the ! but resolve any templates in the rest
|
|
const templateValue = value.substring(1);
|
|
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, {} as IRouteContext);
|
|
} else {
|
|
// Resolve templates in the entire value
|
|
finalValue = TemplateUtils.resolveTemplateVariables(value, {} as IRouteContext);
|
|
}
|
|
|
|
// Set the header
|
|
res.setHeader(key, finalValue);
|
|
this.logger.debug(`Modified response header: ${key}=${finalValue}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle an HTTP request
|
|
*/
|
|
public async handleRequest(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse
|
|
): Promise<void> {
|
|
// Record start time for logging
|
|
const startTime = Date.now();
|
|
|
|
// Get route before applying CORS (we might need its settings)
|
|
// Try to find a matching route using RouteManager
|
|
let matchingRoute: IRouteConfig | null = null;
|
|
if (this.routeManager) {
|
|
try {
|
|
// Create a connection ID for this request
|
|
const connectionId = `http-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
|
|
// Create route context for function-based targets
|
|
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
|
connectionId,
|
|
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
|
});
|
|
|
|
matchingRoute = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
|
} catch (err) {
|
|
this.logger.error('Error finding matching route', err);
|
|
}
|
|
}
|
|
|
|
// Apply CORS headers with route-specific settings if available
|
|
this.applyCorsHeaders(res, req, matchingRoute);
|
|
|
|
// If this is an OPTIONS request, the response has already been ended in applyCorsHeaders
|
|
// so we should return early to avoid trying to set more headers
|
|
if (req.method === 'OPTIONS') {
|
|
// Increment metrics for OPTIONS requests too
|
|
if (this.metricsTracker) {
|
|
this.metricsTracker.incrementRequestsServed();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Apply default headers
|
|
this.applyDefaultHeaders(res);
|
|
|
|
// We already have the connection ID and routeContext from CORS handling
|
|
const connectionId = `http-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
|
|
// Create route context for function-based targets (if we don't already have one)
|
|
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
|
connectionId,
|
|
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
|
});
|
|
|
|
// Check security restrictions if we have a matching route
|
|
if (matchingRoute) {
|
|
// Check IP filtering and rate limiting
|
|
if (!this.securityManager.isAllowed(matchingRoute, routeContext)) {
|
|
this.logger.warn(`Access denied for ${routeContext.clientIp} to ${matchingRoute.name || 'unnamed'}`);
|
|
res.statusCode = 403;
|
|
res.end('Forbidden: Access denied by security policy');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
|
|
// Check basic auth
|
|
if (matchingRoute.security?.basicAuth?.enabled) {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
// No auth header provided - send 401 with WWW-Authenticate header
|
|
res.statusCode = 401;
|
|
const realm = matchingRoute.security.basicAuth.realm || 'Protected Area';
|
|
res.setHeader('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`);
|
|
res.end('Authentication Required');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
|
|
// Verify credentials
|
|
try {
|
|
const credentials = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
|
|
const [username, password] = credentials.split(':');
|
|
|
|
if (!this.securityManager.checkBasicAuth(matchingRoute, username, password)) {
|
|
res.statusCode = 401;
|
|
const realm = matchingRoute.security.basicAuth.realm || 'Protected Area';
|
|
res.setHeader('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`);
|
|
res.end('Invalid Credentials');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`Error verifying basic auth: ${err}`);
|
|
res.statusCode = 401;
|
|
res.end('Authentication Error');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check JWT auth
|
|
if (matchingRoute.security?.jwtAuth?.enabled) {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
// No auth header provided - send 401
|
|
res.statusCode = 401;
|
|
res.end('Authentication Required: JWT token missing');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
|
|
// Verify token
|
|
const token = authHeader.substring(7);
|
|
if (!this.securityManager.verifyJwtToken(matchingRoute, token)) {
|
|
res.statusCode = 401;
|
|
res.end('Invalid or Expired JWT');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found a matching route with function-based targets, use it
|
|
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) {
|
|
this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`);
|
|
|
|
// Extract target information, resolving functions if needed
|
|
let targetHost: string | string[];
|
|
let targetPort: number;
|
|
|
|
try {
|
|
// Check function cache for host and resolve or use cached value
|
|
if (typeof matchingRoute.action.target.host === 'function') {
|
|
// Generate a function ID for caching (use route name or ID if available)
|
|
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
|
|
// Check if we have a cached result
|
|
if (this.functionCache) {
|
|
const cachedHost = this.functionCache.getCachedHost(routeContext, functionId);
|
|
if (cachedHost !== undefined) {
|
|
targetHost = cachedHost;
|
|
this.logger.debug(`Using cached host value for ${functionId}`);
|
|
} else {
|
|
// Resolve the function and cache the result
|
|
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext));
|
|
targetHost = resolvedHost;
|
|
|
|
// Cache the result
|
|
this.functionCache.cacheHost(routeContext, functionId, resolvedHost);
|
|
this.logger.debug(`Resolved and cached function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
}
|
|
} else {
|
|
// No cache available, just resolve
|
|
const resolvedHost = matchingRoute.action.target.host(routeContext);
|
|
targetHost = resolvedHost;
|
|
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
}
|
|
} else {
|
|
targetHost = matchingRoute.action.target.host;
|
|
}
|
|
|
|
// Check function cache for port and resolve or use cached value
|
|
if (typeof matchingRoute.action.target.port === 'function') {
|
|
// Generate a function ID for caching
|
|
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
|
|
// Check if we have a cached result
|
|
if (this.functionCache) {
|
|
const cachedPort = this.functionCache.getCachedPort(routeContext, functionId);
|
|
if (cachedPort !== undefined) {
|
|
targetPort = cachedPort;
|
|
this.logger.debug(`Using cached port value for ${functionId}`);
|
|
} else {
|
|
// Resolve the function and cache the result
|
|
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext));
|
|
targetPort = resolvedPort;
|
|
|
|
// Cache the result
|
|
this.functionCache.cachePort(routeContext, functionId, resolvedPort);
|
|
this.logger.debug(`Resolved and cached function-based port to: ${resolvedPort}`);
|
|
}
|
|
} else {
|
|
// No cache available, just resolve
|
|
const resolvedPort = matchingRoute.action.target.port(routeContext);
|
|
targetPort = resolvedPort;
|
|
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
|
|
}
|
|
} else {
|
|
targetPort = matchingRoute.action.target.port;
|
|
}
|
|
|
|
// Select a single host if an array was provided
|
|
const selectedHost = Array.isArray(targetHost)
|
|
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
|
: targetHost;
|
|
|
|
// Create a destination for the connection pool
|
|
const destination = {
|
|
host: selectedHost,
|
|
port: targetPort
|
|
};
|
|
|
|
// Apply URL rewriting if configured
|
|
this.applyUrlRewriting(req, matchingRoute, routeContext);
|
|
|
|
// Apply header modifications if configured
|
|
this.applyRouteHeaderModifications(matchingRoute, req, res);
|
|
|
|
// Continue with handling using the resolved destination
|
|
HttpRequestHandler.handleHttpRequestWithDestination(
|
|
req,
|
|
res,
|
|
destination,
|
|
routeContext,
|
|
startTime,
|
|
this.logger,
|
|
this.metricsTracker,
|
|
matchingRoute // Pass the route config for additional processing
|
|
);
|
|
return;
|
|
} catch (err) {
|
|
this.logger.error(`Error evaluating function-based target: ${err}`);
|
|
res.statusCode = 500;
|
|
res.end('Internal Server Error: Failed to evaluate target functions');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Try modern router first, then fall back to legacy routing if needed
|
|
if (this.router) {
|
|
try {
|
|
// Try to find a matching route using the modern router
|
|
const route = this.router.routeReq(req);
|
|
if (route && route.action.type === 'forward' && route.action.target) {
|
|
// Handle this route similarly to RouteManager logic
|
|
this.logger.debug(`Found matching route via modern router: ${route.name || 'unnamed'}`);
|
|
|
|
// No need to do anything here, we'll continue with legacy routing
|
|
// The routeManager would have already found this route if applicable
|
|
}
|
|
} catch (err) {
|
|
this.logger.error('Error using modern router', err);
|
|
// Continue with legacy routing
|
|
}
|
|
}
|
|
|
|
// Fall back to legacy routing if no matching route found via RouteManager
|
|
let proxyConfig: IReverseProxyConfig | undefined;
|
|
try {
|
|
proxyConfig = this.legacyRouter.routeReq(req);
|
|
} catch (err) {
|
|
this.logger.error('Error routing request with legacy router', err);
|
|
res.statusCode = 500;
|
|
res.end('Internal Server Error');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
if (!proxyConfig) {
|
|
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
|
|
res.statusCode = 404;
|
|
res.end('Not Found: No proxy configuration for this host');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
// Determine protocol to backend (per-domain override or global)
|
|
const backendProto = proxyConfig.backendProtocol || this.options.backendProtocol;
|
|
if (backendProto === 'http2') {
|
|
const destination = this.connectionPool.getNextTarget(
|
|
proxyConfig.destinationIps,
|
|
proxyConfig.destinationPorts[0]
|
|
);
|
|
const key = `${destination.host}:${destination.port}`;
|
|
let session = this.h2Sessions.get(key);
|
|
if (!session || session.closed || (session as any).destroyed) {
|
|
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
|
|
this.h2Sessions.set(key, session);
|
|
session.on('error', () => this.h2Sessions.delete(key));
|
|
session.on('close', () => this.h2Sessions.delete(key));
|
|
}
|
|
// Build headers for HTTP/2 request
|
|
const hdrs: Record<string, any> = {
|
|
':method': req.method,
|
|
':path': req.url,
|
|
':authority': `${destination.host}:${destination.port}`
|
|
};
|
|
for (const [hk, hv] of Object.entries(req.headers)) {
|
|
if (typeof hv === 'string') hdrs[hk] = hv;
|
|
}
|
|
const h2Stream = session.request(hdrs);
|
|
req.pipe(h2Stream);
|
|
h2Stream.on('response', (hdrs2: any) => {
|
|
const status = (hdrs2[':status'] as number) || 502;
|
|
res.statusCode = status;
|
|
// Copy headers from HTTP/2 response to HTTP/1 response
|
|
for (const [hk, hv] of Object.entries(hdrs2)) {
|
|
if (!hk.startsWith(':') && hv != null) {
|
|
res.setHeader(hk, hv as string | string[]);
|
|
}
|
|
}
|
|
h2Stream.pipe(res);
|
|
});
|
|
h2Stream.on('error', (err) => {
|
|
res.statusCode = 502;
|
|
res.end(`Bad Gateway: ${err.message}`);
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle HTTP/2 stream requests with function-based target support
|
|
*/
|
|
public async handleHttp2(stream: plugins.http2.ServerHttp2Stream, headers: plugins.http2.IncomingHttpHeaders): Promise<void> {
|
|
const startTime = Date.now();
|
|
|
|
// Create a connection ID for this HTTP/2 stream
|
|
const connectionId = `http2-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
|
|
// Get client IP and server IP from the socket
|
|
const socket = (stream.session as any)?.socket;
|
|
const clientIp = socket?.remoteAddress?.replace('::ffff:', '') || '0.0.0.0';
|
|
const serverIp = socket?.localAddress?.replace('::ffff:', '') || '0.0.0.0';
|
|
|
|
// Create route context for function-based targets
|
|
const routeContext = this.contextCreator.createHttp2RouteContext(stream, headers, {
|
|
connectionId,
|
|
clientIp,
|
|
serverIp
|
|
});
|
|
|
|
// Try to find a matching route using RouteManager
|
|
let matchingRoute: IRouteConfig | null = null;
|
|
if (this.routeManager) {
|
|
try {
|
|
matchingRoute = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
|
} catch (err) {
|
|
this.logger.error('Error finding matching route for HTTP/2 request', err);
|
|
}
|
|
}
|
|
|
|
// If we found a matching route with function-based targets, use it
|
|
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) {
|
|
this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`);
|
|
|
|
// Extract target information, resolving functions if needed
|
|
let targetHost: string | string[];
|
|
let targetPort: number;
|
|
|
|
try {
|
|
// Check function cache for host and resolve or use cached value
|
|
if (typeof matchingRoute.action.target.host === 'function') {
|
|
// Generate a function ID for caching (use route name or ID if available)
|
|
const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
|
|
// Check if we have a cached result
|
|
if (this.functionCache) {
|
|
const cachedHost = this.functionCache.getCachedHost(routeContext, functionId);
|
|
if (cachedHost !== undefined) {
|
|
targetHost = cachedHost;
|
|
this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`);
|
|
} else {
|
|
// Resolve the function and cache the result
|
|
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext));
|
|
targetHost = resolvedHost;
|
|
|
|
// Cache the result
|
|
this.functionCache.cacheHost(routeContext, functionId, resolvedHost);
|
|
this.logger.debug(`Resolved and cached HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
}
|
|
} else {
|
|
// No cache available, just resolve
|
|
const resolvedHost = matchingRoute.action.target.host(routeContext);
|
|
targetHost = resolvedHost;
|
|
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
}
|
|
} else {
|
|
targetHost = matchingRoute.action.target.host;
|
|
}
|
|
|
|
// Check function cache for port and resolve or use cached value
|
|
if (typeof matchingRoute.action.target.port === 'function') {
|
|
// Generate a function ID for caching
|
|
const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
|
|
// Check if we have a cached result
|
|
if (this.functionCache) {
|
|
const cachedPort = this.functionCache.getCachedPort(routeContext, functionId);
|
|
if (cachedPort !== undefined) {
|
|
targetPort = cachedPort;
|
|
this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`);
|
|
} else {
|
|
// Resolve the function and cache the result
|
|
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext));
|
|
targetPort = resolvedPort;
|
|
|
|
// Cache the result
|
|
this.functionCache.cachePort(routeContext, functionId, resolvedPort);
|
|
this.logger.debug(`Resolved and cached HTTP/2 function-based port to: ${resolvedPort}`);
|
|
}
|
|
} else {
|
|
// No cache available, just resolve
|
|
const resolvedPort = matchingRoute.action.target.port(routeContext);
|
|
targetPort = resolvedPort;
|
|
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
|
|
}
|
|
} else {
|
|
targetPort = matchingRoute.action.target.port;
|
|
}
|
|
|
|
// Select a single host if an array was provided
|
|
const selectedHost = Array.isArray(targetHost)
|
|
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
|
: targetHost;
|
|
|
|
// Create a destination for forwarding
|
|
const destination = {
|
|
host: selectedHost,
|
|
port: targetPort
|
|
};
|
|
|
|
// Handle HTTP/2 stream based on backend protocol
|
|
const backendProtocol = matchingRoute.action.options?.backendProtocol || this.options.backendProtocol;
|
|
|
|
if (backendProtocol === 'http2') {
|
|
// Forward to HTTP/2 backend
|
|
return Http2RequestHandler.handleHttp2WithHttp2Destination(
|
|
stream,
|
|
headers,
|
|
destination,
|
|
routeContext,
|
|
this.h2Sessions,
|
|
this.logger,
|
|
this.metricsTracker
|
|
);
|
|
} else {
|
|
// Forward to HTTP/1.1 backend
|
|
return Http2RequestHandler.handleHttp2WithHttp1Destination(
|
|
stream,
|
|
headers,
|
|
destination,
|
|
routeContext,
|
|
this.logger,
|
|
this.metricsTracker
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`Error evaluating function-based target for HTTP/2: ${err}`);
|
|
stream.respond({ ':status': 500 });
|
|
stream.end('Internal Server Error: Failed to evaluate target functions');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fall back to legacy routing if no matching route found
|
|
const method = headers[':method'] || 'GET';
|
|
const path = headers[':path'] || '/';
|
|
|
|
// If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions
|
|
if (this.options.backendProtocol === 'http2') {
|
|
const authority = headers[':authority'] as string || '';
|
|
const host = authority.split(':')[0];
|
|
const fakeReq: any = {
|
|
headers: { host },
|
|
method: headers[':method'],
|
|
url: headers[':path'],
|
|
socket: (stream.session as any).socket
|
|
};
|
|
// Try modern router first if available
|
|
let route;
|
|
if (this.router) {
|
|
try {
|
|
route = this.router.routeReq(fakeReq);
|
|
if (route && route.action.type === 'forward' && route.action.target) {
|
|
this.logger.debug(`Found matching HTTP/2 route via modern router: ${route.name || 'unnamed'}`);
|
|
// The routeManager would have already found this route if applicable
|
|
}
|
|
} catch (err) {
|
|
this.logger.error('Error using modern router for HTTP/2', err);
|
|
}
|
|
}
|
|
|
|
// Fall back to legacy routing
|
|
const proxyConfig = this.legacyRouter.routeReq(fakeReq);
|
|
if (!proxyConfig) {
|
|
stream.respond({ ':status': 404 });
|
|
stream.end('Not Found');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
const destination = this.connectionPool.getNextTarget(proxyConfig.destinationIps, proxyConfig.destinationPorts[0]);
|
|
|
|
// Use the helper for HTTP/2 to HTTP/2 routing
|
|
return Http2RequestHandler.handleHttp2WithHttp2Destination(
|
|
stream,
|
|
headers,
|
|
destination,
|
|
routeContext,
|
|
this.h2Sessions,
|
|
this.logger,
|
|
this.metricsTracker
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Determine host for routing
|
|
const authority = headers[':authority'] as string || '';
|
|
const host = authority.split(':')[0];
|
|
// Fake request object for routing
|
|
const fakeReq: any = {
|
|
headers: { host },
|
|
method,
|
|
url: path,
|
|
socket: (stream.session as any).socket
|
|
};
|
|
// Try modern router first if available
|
|
if (this.router) {
|
|
try {
|
|
const route = this.router.routeReq(fakeReq);
|
|
if (route && route.action.type === 'forward' && route.action.target) {
|
|
this.logger.debug(`Found matching HTTP/2 route via modern router: ${route.name || 'unnamed'}`);
|
|
// The routeManager would have already found this route if applicable
|
|
}
|
|
} catch (err) {
|
|
this.logger.error('Error using modern router for HTTP/2', err);
|
|
}
|
|
}
|
|
|
|
// Fall back to legacy routing
|
|
const proxyConfig = this.legacyRouter.routeReq(fakeReq as any);
|
|
if (!proxyConfig) {
|
|
stream.respond({ ':status': 404 });
|
|
stream.end('Not Found');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
return;
|
|
}
|
|
|
|
// Select backend target
|
|
const destination = this.connectionPool.getNextTarget(
|
|
proxyConfig.destinationIps,
|
|
proxyConfig.destinationPorts[0]
|
|
);
|
|
|
|
// Use the helper for HTTP/2 to HTTP/1 routing
|
|
return Http2RequestHandler.handleHttp2WithHttp1Destination(
|
|
stream,
|
|
headers,
|
|
destination,
|
|
routeContext,
|
|
this.logger,
|
|
this.metricsTracker
|
|
);
|
|
} catch (err: any) {
|
|
stream.respond({ ':status': 500 });
|
|
stream.end('Internal Server Error');
|
|
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
|
}
|
|
}
|
|
} |