fix(routing): unify route based architecture
This commit is contained in:
@ -8,6 +8,7 @@ import { CertificateEvents } from '../../certificate/events/certificate-events.j
|
||||
import { buildPort80Handler } from '../../certificate/acme/acme-factory.js';
|
||||
import { subscribeToPort80Handler } from '../../core/utils/event-utils.js';
|
||||
import type { IDomainOptions } from '../../certificate/models/certificate-types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
|
||||
/**
|
||||
* Manages SSL certificates for NetworkProxy including ACME integration
|
||||
@ -91,7 +92,7 @@ export class CertificateManager {
|
||||
public setExternalPort80Handler(handler: Port80Handler): void {
|
||||
if (this.port80Handler && !this.externalPort80Handler) {
|
||||
this.logger.warn('Replacing existing internal Port80Handler with external handler');
|
||||
|
||||
|
||||
// Clean up existing handler if needed
|
||||
if (this.port80Handler !== handler) {
|
||||
// Unregister event handlers to avoid memory leaks
|
||||
@ -101,11 +102,11 @@ export class CertificateManager {
|
||||
this.port80Handler.removeAllListeners(CertificateEvents.CERTIFICATE_EXPIRING);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set the external handler
|
||||
this.port80Handler = handler;
|
||||
this.externalPort80Handler = true;
|
||||
|
||||
|
||||
// Subscribe to Port80Handler events
|
||||
subscribeToPort80Handler(this.port80Handler, {
|
||||
onCertificateIssued: this.handleCertificateIssued.bind(this),
|
||||
@ -115,17 +116,40 @@ export class CertificateManager {
|
||||
this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.logger.info('External Port80Handler connected to CertificateManager');
|
||||
|
||||
|
||||
// Register domains with Port80Handler if we have any certificates cached
|
||||
if (this.certificateCache.size > 0) {
|
||||
const domains = Array.from(this.certificateCache.keys())
|
||||
.filter(domain => !domain.includes('*')); // Skip wildcard domains
|
||||
|
||||
|
||||
this.registerDomainsWithPort80Handler(domains);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route configurations managed by this certificate manager
|
||||
* This method is called when route configurations change
|
||||
*
|
||||
* @param routes Array of route configurations
|
||||
*/
|
||||
public updateRouteConfigs(routes: IRouteConfig[]): void {
|
||||
if (!this.port80Handler) {
|
||||
this.logger.warn('Cannot update routes - Port80Handler is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register domains from routes with Port80Handler
|
||||
this.registerRoutesWithPort80Handler(routes);
|
||||
|
||||
// Process individual routes for certificate requirements
|
||||
for (const route of routes) {
|
||||
this.processRouteForCertificates(route);
|
||||
}
|
||||
|
||||
this.logger.info(`Updated certificate management for ${routes.length} routes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle newly issued or renewed certificates from Port80Handler
|
||||
@ -317,20 +341,21 @@ export class CertificateManager {
|
||||
|
||||
/**
|
||||
* Registers domains with Port80Handler for ACME certificate management
|
||||
* @param domains String array of domains to register
|
||||
*/
|
||||
public registerDomainsWithPort80Handler(domains: string[]): void {
|
||||
if (!this.port80Handler) {
|
||||
this.logger.warn('Port80Handler is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
for (const domain of domains) {
|
||||
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
||||
if (domain.includes('*')) {
|
||||
this.logger.info(`Skipping wildcard domain for ACME: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Skip domains already with certificates if configured to do so
|
||||
if (this.options.acme?.skipConfiguredCerts) {
|
||||
const cachedCert = this.certificateCache.get(domain);
|
||||
@ -339,18 +364,97 @@ export class CertificateManager {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Register the domain for certificate issuance with new domain options format
|
||||
const domainOptions: IDomainOptions = {
|
||||
domainName: domain,
|
||||
sslRedirect: true,
|
||||
acmeMaintenance: true
|
||||
};
|
||||
|
||||
|
||||
this.port80Handler.addDomain(domainOptions);
|
||||
this.logger.info(`Registered domain for ACME certificate issuance: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domains from route configurations and register with Port80Handler
|
||||
* This method enables direct integration with route-based configuration
|
||||
*
|
||||
* @param routes Array of route configurations
|
||||
*/
|
||||
public registerRoutesWithPort80Handler(routes: IRouteConfig[]): void {
|
||||
if (!this.port80Handler) {
|
||||
this.logger.warn('Port80Handler is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domains from route configurations
|
||||
const domains: Set<string> = new Set();
|
||||
|
||||
for (const route of routes) {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip routes without HTTPS termination
|
||||
if (route.action.type !== 'forward' || route.action.tls?.mode !== 'terminate') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract domains from match criteria
|
||||
if (route.match.domains) {
|
||||
if (typeof route.match.domains === 'string') {
|
||||
domains.add(route.match.domains);
|
||||
} else if (Array.isArray(route.match.domains)) {
|
||||
for (const domain of route.match.domains) {
|
||||
domains.add(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register extracted domains
|
||||
this.registerDomainsWithPort80Handler(Array.from(domains));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a route config to determine if it requires automatic certificate provisioning
|
||||
* @param route Route configuration to process
|
||||
*/
|
||||
public processRouteForCertificates(route: IRouteConfig): void {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip routes without HTTPS termination or auto certificate
|
||||
if (route.action.type !== 'forward' ||
|
||||
route.action.tls?.mode !== 'terminate' ||
|
||||
route.action.tls?.certificate !== 'auto') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domains from match criteria
|
||||
const domains: string[] = [];
|
||||
if (route.match.domains) {
|
||||
if (typeof route.match.domains === 'string') {
|
||||
domains.push(route.match.domains);
|
||||
} else if (Array.isArray(route.match.domains)) {
|
||||
domains.push(...route.match.domains);
|
||||
}
|
||||
}
|
||||
|
||||
// Request certificates for the domains
|
||||
for (const domain of domains) {
|
||||
if (!domain.includes('*')) { // Skip wildcard domains
|
||||
this.requestCertificate(domain).catch(err => {
|
||||
this.logger.error(`Error requesting certificate for domain ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize internal Port80Handler
|
||||
|
145
ts/proxies/network-proxy/context-creator.ts
Normal file
145
ts/proxies/network-proxy/context-creator.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import type { IRouteContext, IHttpRouteContext, IHttp2RouteContext } from '../../core/models/route-context.js';
|
||||
|
||||
/**
|
||||
* Context creator for NetworkProxy
|
||||
* Creates route contexts for matching and function evaluation
|
||||
*/
|
||||
export class ContextCreator {
|
||||
/**
|
||||
* Create a route context from HTTP request information
|
||||
*/
|
||||
public createHttpRouteContext(req: any, options: {
|
||||
tlsVersion?: string;
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp: string;
|
||||
}): IHttpRouteContext {
|
||||
// Parse headers
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === 'string') {
|
||||
headers[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
headers[key.toLowerCase()] = value[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse domain from Host header
|
||||
const domain = headers['host']?.split(':')[0] || '';
|
||||
|
||||
// Parse URL
|
||||
const url = new URL(`http://${domain}${req.url || '/'}`);
|
||||
|
||||
return {
|
||||
// Connection basics
|
||||
port: req.socket.localPort || 0,
|
||||
domain,
|
||||
clientIp: options.clientIp,
|
||||
serverIp: options.serverIp,
|
||||
|
||||
// HTTP specifics
|
||||
path: url.pathname,
|
||||
query: url.search ? url.search.substring(1) : '',
|
||||
headers,
|
||||
|
||||
// TLS information
|
||||
isTls: !!req.socket.encrypted,
|
||||
tlsVersion: options.tlsVersion,
|
||||
|
||||
// Request objects
|
||||
req,
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route context from HTTP/2 stream and headers
|
||||
*/
|
||||
public createHttp2RouteContext(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
options: {
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp: string;
|
||||
}
|
||||
): IHttp2RouteContext {
|
||||
// Parse headers, excluding HTTP/2 pseudo-headers
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!key.startsWith(':') && typeof value === 'string') {
|
||||
processedHeaders[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Get domain from :authority pseudo-header
|
||||
const authority = headers[':authority'] as string || '';
|
||||
const domain = authority.split(':')[0];
|
||||
|
||||
// Get path from :path pseudo-header
|
||||
const path = headers[':path'] as string || '/';
|
||||
|
||||
// Parse the path to extract query string
|
||||
const pathParts = path.split('?');
|
||||
const pathname = pathParts[0];
|
||||
const query = pathParts.length > 1 ? pathParts[1] : '';
|
||||
|
||||
// Get the socket from the session
|
||||
const socket = (stream.session as any)?.socket;
|
||||
|
||||
return {
|
||||
// Connection basics
|
||||
port: socket?.localPort || 0,
|
||||
domain,
|
||||
clientIp: options.clientIp,
|
||||
serverIp: options.serverIp,
|
||||
|
||||
// HTTP specifics
|
||||
path: pathname,
|
||||
query,
|
||||
headers: processedHeaders,
|
||||
|
||||
// HTTP/2 specific properties
|
||||
method: headers[':method'] as string,
|
||||
stream,
|
||||
|
||||
// TLS information - HTTP/2 is always on TLS in browsers
|
||||
isTls: true,
|
||||
tlsVersion: socket?.getTLSVersion?.() || 'TLSv1.3',
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic route context from socket information
|
||||
*/
|
||||
public createSocketRouteContext(socket: plugins.net.Socket, options: {
|
||||
domain?: string;
|
||||
tlsVersion?: string;
|
||||
connectionId: string;
|
||||
}): IRouteContext {
|
||||
return {
|
||||
// Connection basics
|
||||
port: socket.localPort || 0,
|
||||
domain: options.domain,
|
||||
clientIp: socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
serverIp: socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
|
||||
// TLS information
|
||||
isTls: options.tlsVersion !== undefined,
|
||||
tlsVersion: options.tlsVersion,
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
}
|
259
ts/proxies/network-proxy/function-cache.ts
Normal file
259
ts/proxies/network-proxy/function-cache.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
|
||||
/**
|
||||
* Interface for cached function result
|
||||
*/
|
||||
interface ICachedResult<T> {
|
||||
value: T;
|
||||
expiry: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function cache for NetworkProxy function-based targets
|
||||
*
|
||||
* This cache improves performance for function-based targets by storing
|
||||
* the results of function evaluations and reusing them for similar contexts.
|
||||
*/
|
||||
export class FunctionCache {
|
||||
// Cache storage
|
||||
private hostCache: Map<string, ICachedResult<string | string[]>> = new Map();
|
||||
private portCache: Map<string, ICachedResult<number>> = new Map();
|
||||
|
||||
// Maximum number of entries to store in each cache
|
||||
private maxCacheSize: number;
|
||||
|
||||
// Default TTL for cache entries in milliseconds (default: 5 seconds)
|
||||
private defaultTtl: number;
|
||||
|
||||
// Logger
|
||||
private logger: ILogger;
|
||||
|
||||
/**
|
||||
* Creates a new function cache
|
||||
*
|
||||
* @param logger Logger for debug output
|
||||
* @param options Cache options
|
||||
*/
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
options: {
|
||||
maxCacheSize?: number;
|
||||
defaultTtl?: number;
|
||||
} = {}
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.maxCacheSize = options.maxCacheSize || 1000;
|
||||
this.defaultTtl = options.defaultTtl || 5000; // 5 seconds default
|
||||
|
||||
// Start the cache cleanup timer
|
||||
setInterval(() => this.cleanupCache(), 30000); // Cleanup every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a hash for a context object
|
||||
* This is used to identify similar contexts for caching
|
||||
*
|
||||
* @param context The route context to hash
|
||||
* @param functionId Identifier for the function (usually route name or ID)
|
||||
* @returns A string hash
|
||||
*/
|
||||
private computeContextHash(context: IRouteContext, functionId: string): string {
|
||||
// Extract relevant properties for the hash
|
||||
const hashBase = {
|
||||
functionId,
|
||||
port: context.port,
|
||||
domain: context.domain,
|
||||
clientIp: context.clientIp,
|
||||
path: context.path,
|
||||
query: context.query,
|
||||
isTls: context.isTls,
|
||||
tlsVersion: context.tlsVersion
|
||||
};
|
||||
|
||||
// Generate a hash string
|
||||
return JSON.stringify(hashBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached host result for a function and context
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @returns Cached host value or undefined if not found
|
||||
*/
|
||||
public getCachedHost(context: IRouteContext, functionId: string): string | string[] | undefined {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const cached = this.hostCache.get(hash);
|
||||
|
||||
// Return if no cached value or expired
|
||||
if (!cached || cached.expiry < Date.now()) {
|
||||
if (cached) {
|
||||
// If expired, remove from cache
|
||||
this.hostCache.delete(hash);
|
||||
this.logger.debug(`Cache miss (expired) for host function: ${functionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Cache miss for host function: ${functionId}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug(`Cache hit for host function: ${functionId}`);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached port result for a function and context
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @returns Cached port value or undefined if not found
|
||||
*/
|
||||
public getCachedPort(context: IRouteContext, functionId: string): number | undefined {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const cached = this.portCache.get(hash);
|
||||
|
||||
// Return if no cached value or expired
|
||||
if (!cached || cached.expiry < Date.now()) {
|
||||
if (cached) {
|
||||
// If expired, remove from cache
|
||||
this.portCache.delete(hash);
|
||||
this.logger.debug(`Cache miss (expired) for port function: ${functionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Cache miss for port function: ${functionId}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug(`Cache hit for port function: ${functionId}`);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a host function result in the cache
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @param value Host value to cache
|
||||
* @param ttl Optional TTL in milliseconds
|
||||
*/
|
||||
public cacheHost(
|
||||
context: IRouteContext,
|
||||
functionId: string,
|
||||
value: string | string[],
|
||||
ttl?: number
|
||||
): void {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const expiry = Date.now() + (ttl || this.defaultTtl);
|
||||
|
||||
// Check if we need to prune the cache before adding
|
||||
if (this.hostCache.size >= this.maxCacheSize) {
|
||||
this.pruneOldestEntries(this.hostCache);
|
||||
}
|
||||
|
||||
// Store the result
|
||||
this.hostCache.set(hash, { value, expiry, hash });
|
||||
this.logger.debug(`Cached host function result for: ${functionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a port function result in the cache
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @param value Port value to cache
|
||||
* @param ttl Optional TTL in milliseconds
|
||||
*/
|
||||
public cachePort(
|
||||
context: IRouteContext,
|
||||
functionId: string,
|
||||
value: number,
|
||||
ttl?: number
|
||||
): void {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const expiry = Date.now() + (ttl || this.defaultTtl);
|
||||
|
||||
// Check if we need to prune the cache before adding
|
||||
if (this.portCache.size >= this.maxCacheSize) {
|
||||
this.pruneOldestEntries(this.portCache);
|
||||
}
|
||||
|
||||
// Store the result
|
||||
this.portCache.set(hash, { value, expiry, hash });
|
||||
this.logger.debug(`Cached port function result for: ${functionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired entries from the cache
|
||||
*/
|
||||
private cleanupCache(): void {
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
|
||||
// Clean up host cache
|
||||
for (const [hash, cached] of this.hostCache.entries()) {
|
||||
if (cached.expiry < now) {
|
||||
this.hostCache.delete(hash);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up port cache
|
||||
for (const [hash, cached] of this.portCache.entries()) {
|
||||
if (cached.expiry < now) {
|
||||
this.portCache.delete(hash);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount > 0) {
|
||||
this.logger.debug(`Cleaned up ${expiredCount} expired cache entries`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune oldest entries from a cache map
|
||||
* Used when the cache exceeds the maximum size
|
||||
*
|
||||
* @param cache The cache map to prune
|
||||
*/
|
||||
private pruneOldestEntries<T>(cache: Map<string, ICachedResult<T>>): void {
|
||||
// Find the oldest entries
|
||||
const now = Date.now();
|
||||
const itemsToRemove = Math.floor(this.maxCacheSize * 0.2); // Remove 20% of the cache
|
||||
|
||||
// Convert to array for sorting
|
||||
const entries = Array.from(cache.entries());
|
||||
|
||||
// Sort by expiry (oldest first)
|
||||
entries.sort((a, b) => a[1].expiry - b[1].expiry);
|
||||
|
||||
// Remove oldest entries
|
||||
const toRemove = entries.slice(0, itemsToRemove);
|
||||
for (const [hash] of toRemove) {
|
||||
cache.delete(hash);
|
||||
}
|
||||
|
||||
this.logger.debug(`Pruned ${toRemove.length} oldest cache entries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache stats
|
||||
*/
|
||||
public getStats(): { hostCacheSize: number; portCacheSize: number } {
|
||||
return {
|
||||
hostCacheSize: this.hostCache.size,
|
||||
portCacheSize: this.portCache.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached entries
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.hostCache.clear();
|
||||
this.portCache.clear();
|
||||
this.logger.info('Function cache cleared');
|
||||
}
|
||||
}
|
330
ts/proxies/network-proxy/http-request-handler.ts
Normal file
330
ts/proxies/network-proxy/http-request-handler.ts
Normal file
@ -0,0 +1,330 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import type { IHttpRouteContext, IRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IMetricsTracker } from './request-handler.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
|
||||
/**
|
||||
* HTTP Request Handler Helper - handles requests with specific destinations
|
||||
* This is a helper class for the main RequestHandler
|
||||
*/
|
||||
export class HttpRequestHandler {
|
||||
/**
|
||||
* Handle HTTP request with a specific destination
|
||||
*/
|
||||
public static async handleHttpRequestWithDestination(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
startTime: number,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null,
|
||||
route?: IRouteConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Apply URL rewriting if route config is provided
|
||||
if (route) {
|
||||
HttpRequestHandler.applyUrlRewriting(req, route, routeContext, logger);
|
||||
HttpRequestHandler.applyRouteHeaderModifications(route, req, res, logger);
|
||||
}
|
||||
|
||||
// Create options for the proxy request
|
||||
const options: plugins.http.RequestOptions = {
|
||||
hostname: destination.host,
|
||||
port: destination.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers }
|
||||
};
|
||||
|
||||
// Optionally rewrite host header to match target
|
||||
if (options.headers && options.headers.host) {
|
||||
// Only apply if host header rewrite is enabled or not explicitly disabled
|
||||
const shouldRewriteHost = route?.action.options?.rewriteHostHeader !== false;
|
||||
if (shouldRewriteHost) {
|
||||
options.headers.host = `${destination.host}:${destination.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Proxying request to ${destination.host}:${destination.port}${req.url}`,
|
||||
{ method: req.method }
|
||||
);
|
||||
|
||||
// Create proxy request
|
||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
||||
// Copy status code
|
||||
res.statusCode = proxyRes.statusCode || 500;
|
||||
|
||||
// Copy headers from proxy response to client response
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value !== undefined) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply response header modifications if route config is provided
|
||||
if (route && route.headers?.response) {
|
||||
HttpRequestHandler.applyResponseHeaderModifications(route, res, logger, routeContext);
|
||||
}
|
||||
|
||||
// Pipe proxy response to client response
|
||||
proxyRes.pipe(res);
|
||||
|
||||
// Increment served requests counter when the response finishes
|
||||
res.on('finish', () => {
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementRequestsServed();
|
||||
}
|
||||
|
||||
// Log the completed request
|
||||
const duration = Date.now() - startTime;
|
||||
logger.debug(
|
||||
`Request completed in ${duration}ms: ${req.method} ${req.url} ${res.statusCode}`,
|
||||
{ duration, statusCode: res.statusCode }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle proxy request errors
|
||||
proxyReq.on('error', (error) => {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(
|
||||
`Proxy error for ${req.method} ${req.url}: ${error.message}`,
|
||||
{ duration, error: error.message }
|
||||
);
|
||||
|
||||
// Increment failed requests counter
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
// Check if headers have already been sent
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 502;
|
||||
res.end(`Bad Gateway: ${error.message}`);
|
||||
} else {
|
||||
// If headers already sent, just close the connection
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe request body to proxy request and handle client-side errors
|
||||
req.pipe(proxyReq);
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('error', (error) => {
|
||||
logger.debug(`Client connection error: ${error.message}`);
|
||||
proxyReq.destroy();
|
||||
|
||||
// Increment failed requests counter on client errors
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle response errors
|
||||
res.on('error', (error) => {
|
||||
logger.debug(`Response error: ${error.message}`);
|
||||
proxyReq.destroy();
|
||||
|
||||
// Increment failed requests counter on response errors
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle any unexpected errors
|
||||
logger.error(
|
||||
`Unexpected error handling request: ${error.message}`,
|
||||
{ error: error.stack }
|
||||
);
|
||||
|
||||
// Increment failed requests counter
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param logger Logger for debugging information
|
||||
* @returns True if URL was rewritten, false otherwise
|
||||
*/
|
||||
private static applyUrlRewriting(
|
||||
req: plugins.http.IncomingMessage,
|
||||
route: IRouteConfig,
|
||||
routeContext: IHttpRouteContext,
|
||||
logger: ILogger
|
||||
): 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 with optional flags
|
||||
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);
|
||||
}
|
||||
|
||||
logger.debug(`URL rewritten: ${originalUrl} -> ${req.url}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Error in URL rewriting: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply header modifications from route configuration to request headers
|
||||
* Implements Phase 5.1: Route-based header manipulation for requests
|
||||
*/
|
||||
private static applyRouteHeaderModifications(
|
||||
route: IRouteConfig,
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
logger: ILogger
|
||||
): 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) {
|
||||
// Create routing context for template resolution
|
||||
const routeContext: IRouteContext = {
|
||||
domain: req.headers.host as string || '',
|
||||
path: req.url || '',
|
||||
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '',
|
||||
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '',
|
||||
port: parseInt(req.socket.localPort?.toString() || '0', 10),
|
||||
isTls: !!req.socket.encrypted,
|
||||
headers: req.headers as Record<string, string>,
|
||||
timestamp: Date.now(),
|
||||
connectionId: `${Date.now()}-${Math.floor(Math.random() * 10000)}`,
|
||||
};
|
||||
|
||||
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()];
|
||||
logger.debug(`Deleted request header: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!')) {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, routeContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
req.headers[key.toLowerCase()] = finalValue;
|
||||
logger.debug(`Modified request header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply header modifications from route configuration to response headers
|
||||
* Implements Phase 5.1: Route-based header manipulation for responses
|
||||
*/
|
||||
private static applyResponseHeaderModifications(
|
||||
route: IRouteConfig,
|
||||
res: plugins.http.ServerResponse,
|
||||
logger: ILogger,
|
||||
routeContext?: IRouteContext
|
||||
): void {
|
||||
// Check if route has response header modifications
|
||||
if (!route.headers?.response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply response header modifications
|
||||
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);
|
||||
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 = routeContext
|
||||
? '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext)
|
||||
: '!' + templateValue;
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = routeContext
|
||||
? TemplateUtils.resolveTemplateVariables(value, routeContext)
|
||||
: value;
|
||||
}
|
||||
|
||||
// Set the header
|
||||
res.setHeader(key, finalValue);
|
||||
logger.debug(`Modified response header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Template resolution is now handled by the TemplateUtils class
|
||||
}
|
255
ts/proxies/network-proxy/http2-request-handler.ts
Normal file
255
ts/proxies/network-proxy/http2-request-handler.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IMetricsTracker } from './request-handler.js';
|
||||
|
||||
/**
|
||||
* HTTP/2 Request Handler Helper - handles HTTP/2 streams with specific destinations
|
||||
* This is a helper class for the main RequestHandler
|
||||
*/
|
||||
export class Http2RequestHandler {
|
||||
/**
|
||||
* Handle HTTP/2 stream with direct HTTP/2 backend
|
||||
*/
|
||||
public static async handleHttp2WithHttp2Destination(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
sessions: Map<string, plugins.http2.ClientHttp2Session>,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null
|
||||
): Promise<void> {
|
||||
const key = `${destination.host}:${destination.port}`;
|
||||
|
||||
// Get or create a client HTTP/2 session
|
||||
let session = sessions.get(key);
|
||||
if (!session || session.closed || (session as any).destroyed) {
|
||||
try {
|
||||
// Connect to the backend HTTP/2 server
|
||||
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
|
||||
sessions.set(key, session);
|
||||
|
||||
// Handle session errors and cleanup
|
||||
session.on('error', (err) => {
|
||||
logger.error(`HTTP/2 session error to ${key}: ${err.message}`);
|
||||
sessions.delete(key);
|
||||
});
|
||||
|
||||
session.on('close', () => {
|
||||
logger.debug(`HTTP/2 session closed to ${key}`);
|
||||
sessions.delete(key);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to establish HTTP/2 session to ${key}: ${err.message}`);
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end('Bad Gateway: Failed to establish connection to backend');
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Build headers for backend HTTP/2 request
|
||||
const h2Headers: Record<string, any> = {
|
||||
':method': headers[':method'],
|
||||
':path': headers[':path'],
|
||||
':authority': `${destination.host}:${destination.port}`
|
||||
};
|
||||
|
||||
// Copy other headers, excluding pseudo-headers
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!key.startsWith(':') && typeof value === 'string') {
|
||||
h2Headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Proxying HTTP/2 request to ${destination.host}:${destination.port}${headers[':path']}`,
|
||||
{ method: headers[':method'] }
|
||||
);
|
||||
|
||||
// Create HTTP/2 request stream to the backend
|
||||
const h2Stream = session.request(h2Headers);
|
||||
|
||||
// Pipe client stream to backend stream
|
||||
stream.pipe(h2Stream);
|
||||
|
||||
// Handle responses from the backend
|
||||
h2Stream.on('response', (responseHeaders) => {
|
||||
// Map status and headers to client response
|
||||
const resp: Record<string, any> = {
|
||||
':status': responseHeaders[':status'] as number
|
||||
};
|
||||
|
||||
// Copy non-pseudo headers
|
||||
for (const [key, value] of Object.entries(responseHeaders)) {
|
||||
if (!key.startsWith(':') && value !== undefined) {
|
||||
resp[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Send headers to client
|
||||
stream.respond(resp);
|
||||
|
||||
// Pipe backend response to client
|
||||
h2Stream.pipe(stream);
|
||||
|
||||
// Track successful requests
|
||||
stream.on('end', () => {
|
||||
if (metricsTracker) metricsTracker.incrementRequestsServed();
|
||||
logger.debug(
|
||||
`HTTP/2 request completed: ${headers[':method']} ${headers[':path']} ${responseHeaders[':status']}`,
|
||||
{ method: headers[':method'], status: responseHeaders[':status'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle backend errors
|
||||
h2Stream.on('error', (err) => {
|
||||
logger.error(`HTTP/2 stream error: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
// Handle client stream errors
|
||||
stream.on('error', (err) => {
|
||||
logger.debug(`Client HTTP/2 stream error: ${err.message}`);
|
||||
h2Stream.destroy();
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error handling HTTP/2 request: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP/2 stream with HTTP/1 backend
|
||||
*/
|
||||
public static async handleHttp2WithHttp1Destination(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Build headers for HTTP/1 proxy request, excluding HTTP/2 pseudo-headers
|
||||
const outboundHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof key === 'string' && typeof value === 'string' && !key.startsWith(':')) {
|
||||
outboundHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Always rewrite host header to match target
|
||||
outboundHeaders.host = `${destination.host}:${destination.port}`;
|
||||
|
||||
logger.debug(
|
||||
`Proxying HTTP/2 request to HTTP/1 backend ${destination.host}:${destination.port}${headers[':path']}`,
|
||||
{ method: headers[':method'] }
|
||||
);
|
||||
|
||||
// Create HTTP/1 proxy request
|
||||
const proxyReq = plugins.http.request(
|
||||
{
|
||||
hostname: destination.host,
|
||||
port: destination.port,
|
||||
path: headers[':path'] as string,
|
||||
method: headers[':method'] as string,
|
||||
headers: outboundHeaders
|
||||
},
|
||||
(proxyRes) => {
|
||||
// Map status and headers back to HTTP/2
|
||||
const responseHeaders: Record<string, number | string | string[]> = {
|
||||
':status': proxyRes.statusCode || 500
|
||||
};
|
||||
|
||||
// Copy headers from HTTP/1 response to HTTP/2 response
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value !== undefined) {
|
||||
responseHeaders[key] = value as string | string[];
|
||||
}
|
||||
}
|
||||
|
||||
// Send headers to client
|
||||
stream.respond(responseHeaders);
|
||||
|
||||
// Pipe HTTP/1 response to HTTP/2 stream
|
||||
proxyRes.pipe(stream);
|
||||
|
||||
// Clean up when client disconnects
|
||||
stream.on('close', () => proxyReq.destroy());
|
||||
stream.on('error', () => proxyReq.destroy());
|
||||
|
||||
// Track successful requests
|
||||
stream.on('end', () => {
|
||||
if (metricsTracker) metricsTracker.incrementRequestsServed();
|
||||
logger.debug(
|
||||
`HTTP/2 to HTTP/1 request completed: ${headers[':method']} ${headers[':path']} ${proxyRes.statusCode}`,
|
||||
{ method: headers[':method'], status: proxyRes.statusCode }
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Handle proxy request errors
|
||||
proxyReq.on('error', (err) => {
|
||||
logger.error(`HTTP/1 proxy error: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
// Pipe client stream to proxy request
|
||||
stream.pipe(proxyReq);
|
||||
|
||||
// Handle client stream errors
|
||||
stream.on('error', (err) => {
|
||||
logger.debug(`Client HTTP/2 stream error: ${err.message}`);
|
||||
proxyReq.destroy();
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error handling HTTP/2 to HTTP/1 request: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js';
|
||||
import type { IRouteConfig } from '../../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../../core/models/route-context.js';
|
||||
|
||||
/**
|
||||
* Configuration options for NetworkProxy
|
||||
@ -24,8 +26,15 @@ export interface INetworkProxyOptions {
|
||||
// Protocol to use when proxying to backends: HTTP/1.x or HTTP/2
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
|
||||
// Function cache options
|
||||
functionCacheSize?: number; // Maximum number of cached function results (default: 1000)
|
||||
functionCacheTtl?: number; // Time to live for cached function results in ms (default: 5000)
|
||||
|
||||
// ACME certificate management options
|
||||
acme?: IAcmeOptions;
|
||||
|
||||
// Direct route configurations
|
||||
routes?: IRouteConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,20 +47,39 @@ export interface ICertificateEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for reverse proxy configuration
|
||||
* @deprecated Use IRouteConfig instead. This interface will be removed in a future release.
|
||||
*
|
||||
* IMPORTANT: This is a legacy interface maintained only for backward compatibility.
|
||||
* New code should use IRouteConfig for all configuration purposes.
|
||||
*
|
||||
* @see IRouteConfig for the modern, recommended configuration format
|
||||
*/
|
||||
export interface IReverseProxyConfig {
|
||||
/** Target hostnames/IPs to proxy requests to */
|
||||
destinationIps: string[];
|
||||
|
||||
/** Target ports to proxy requests to */
|
||||
destinationPorts: number[];
|
||||
|
||||
/** Hostname to match for routing */
|
||||
hostName: string;
|
||||
|
||||
/** SSL private key for this host (PEM format) */
|
||||
privateKey: string;
|
||||
|
||||
/** SSL public key/certificate for this host (PEM format) */
|
||||
publicKey: string;
|
||||
|
||||
/** Basic authentication configuration */
|
||||
authentication?: {
|
||||
type: 'Basic';
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
|
||||
/** Whether to rewrite the Host header to match the target */
|
||||
rewriteHostHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Protocol to use when proxying to this backend: 'http1' or 'http2'.
|
||||
* Overrides the global backendProtocol option if set.
|
||||
@ -59,6 +87,231 @@ export interface IReverseProxyConfig {
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a legacy IReverseProxyConfig to the modern IRouteConfig format
|
||||
*
|
||||
* @deprecated This function is maintained for backward compatibility.
|
||||
* New code should create IRouteConfig objects directly.
|
||||
*
|
||||
* @param legacyConfig The legacy configuration to convert
|
||||
* @param proxyPort The port the proxy listens on
|
||||
* @returns A modern route configuration equivalent to the legacy config
|
||||
*/
|
||||
export function convertLegacyConfigToRouteConfig(
|
||||
legacyConfig: IReverseProxyConfig,
|
||||
proxyPort: number
|
||||
): IRouteConfig {
|
||||
// Create basic route configuration
|
||||
const routeConfig: IRouteConfig = {
|
||||
// Match properties
|
||||
match: {
|
||||
ports: proxyPort,
|
||||
domains: legacyConfig.hostName
|
||||
},
|
||||
|
||||
// Action properties
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: legacyConfig.destinationIps,
|
||||
port: legacyConfig.destinationPorts[0]
|
||||
},
|
||||
|
||||
// TLS mode is always 'terminate' for legacy configs
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
key: legacyConfig.privateKey,
|
||||
cert: legacyConfig.publicKey
|
||||
}
|
||||
},
|
||||
|
||||
// Advanced options
|
||||
advanced: {
|
||||
// Rewrite host header if specified
|
||||
headers: legacyConfig.rewriteHostHeader ? { 'host': '{domain}' } : {}
|
||||
}
|
||||
},
|
||||
|
||||
// Metadata
|
||||
name: `Legacy Config - ${legacyConfig.hostName}`,
|
||||
priority: 0, // Default priority
|
||||
enabled: true
|
||||
};
|
||||
|
||||
// Add authentication if present
|
||||
if (legacyConfig.authentication) {
|
||||
routeConfig.action.security = {
|
||||
authentication: {
|
||||
type: 'basic',
|
||||
credentials: [{
|
||||
username: legacyConfig.authentication.user,
|
||||
password: legacyConfig.authentication.pass
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add backend protocol if specified
|
||||
if (legacyConfig.backendProtocol) {
|
||||
if (!routeConfig.action.options) {
|
||||
routeConfig.action.options = {};
|
||||
}
|
||||
routeConfig.action.options.backendProtocol = legacyConfig.backendProtocol;
|
||||
}
|
||||
|
||||
return routeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route manager for NetworkProxy
|
||||
* Handles route matching and configuration
|
||||
*/
|
||||
export class RouteManager {
|
||||
private routes: IRouteConfig[] = [];
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the routes configuration
|
||||
*/
|
||||
public updateRoutes(routes: IRouteConfig[]): void {
|
||||
// Sort routes by priority (higher first)
|
||||
this.routes = [...routes].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
this.logger.info(`Updated RouteManager with ${this.routes.length} routes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes
|
||||
*/
|
||||
public getRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first matching route for a context
|
||||
*/
|
||||
public findMatchingRoute(context: IRouteContext): IRouteConfig | null {
|
||||
for (const route of this.routes) {
|
||||
if (this.matchesRoute(route, context)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches the given context
|
||||
*/
|
||||
private matchesRoute(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check domain match if specified
|
||||
if (route.match.domains && context.domain) {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
if (!domains.some(domainPattern => this.matchDomain(domainPattern, context.domain!))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path match if specified
|
||||
if (route.match.path && context.path) {
|
||||
if (!this.matchPath(route.match.path, context.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check client IP match if specified
|
||||
if (route.match.clientIp && context.clientIp) {
|
||||
if (!route.match.clientIp.some(ip => this.matchIp(ip, context.clientIp))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check TLS version match if specified
|
||||
if (route.match.tlsVersion && context.tlsVersion) {
|
||||
if (!route.match.tlsVersion.includes(context.tlsVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All criteria matched
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
*/
|
||||
private matchDomain(pattern: string, domain: string): boolean {
|
||||
if (pattern === domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*');
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(domain);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
*/
|
||||
private matchPath(pattern: string, path: string): boolean {
|
||||
if (pattern === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against an IP
|
||||
*/
|
||||
private matchIp(pattern: string, ip: string): boolean {
|
||||
if (pattern === ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*');
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(ip);
|
||||
}
|
||||
|
||||
// TODO: Implement CIDR matching
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for connection tracking in the pool
|
||||
*/
|
||||
|
@ -1,18 +1,25 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
createLogger
|
||||
createLogger,
|
||||
RouteManager,
|
||||
convertLegacyConfigToRouteConfig
|
||||
} from './models/types.js';
|
||||
import type {
|
||||
INetworkProxyOptions,
|
||||
ILogger,
|
||||
IReverseProxyConfig
|
||||
} from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
import { createBaseRouteContext } from '../../core/models/route-context.js';
|
||||
import { CertificateManager } from './certificate-manager.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||
import { WebSocketHandler } from './websocket-handler.js';
|
||||
import { ProxyRouter } from '../../http/router/index.js';
|
||||
import { RouteRouter } from '../../http/router/route-router.js';
|
||||
import { Port80Handler } from '../../http/port80/port80-handler.js';
|
||||
import { FunctionCache } from './function-cache.js';
|
||||
|
||||
/**
|
||||
* NetworkProxy provides a reverse proxy with TLS termination, WebSocket support,
|
||||
@ -25,17 +32,20 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
}
|
||||
// Configuration
|
||||
public options: INetworkProxyOptions;
|
||||
public proxyConfigs: IReverseProxyConfig[] = [];
|
||||
|
||||
public routes: IRouteConfig[] = [];
|
||||
|
||||
// Server instances (HTTP/2 with HTTP/1 fallback)
|
||||
public httpsServer: any;
|
||||
|
||||
|
||||
// Core components
|
||||
private certificateManager: CertificateManager;
|
||||
private connectionPool: ConnectionPool;
|
||||
private requestHandler: RequestHandler;
|
||||
private webSocketHandler: WebSocketHandler;
|
||||
private router = new ProxyRouter();
|
||||
private legacyRouter = new ProxyRouter(); // Legacy router for backward compatibility
|
||||
private router = new RouteRouter(); // New modern router
|
||||
private routeManager: RouteManager;
|
||||
private functionCache: FunctionCache;
|
||||
|
||||
// State tracking
|
||||
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
||||
@ -94,15 +104,41 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
|
||||
// Initialize logger
|
||||
this.logger = createLogger(this.options.logLevel);
|
||||
|
||||
// Initialize components
|
||||
|
||||
// Initialize route manager
|
||||
this.routeManager = new RouteManager(this.logger);
|
||||
|
||||
// Initialize function cache
|
||||
this.functionCache = new FunctionCache(this.logger, {
|
||||
maxCacheSize: this.options.functionCacheSize || 1000,
|
||||
defaultTtl: this.options.functionCacheTtl || 5000
|
||||
});
|
||||
|
||||
// Initialize other components
|
||||
this.certificateManager = new CertificateManager(this.options);
|
||||
this.connectionPool = new ConnectionPool(this.options);
|
||||
this.requestHandler = new RequestHandler(this.options, this.connectionPool, this.router);
|
||||
this.webSocketHandler = new WebSocketHandler(this.options, this.connectionPool, this.router);
|
||||
|
||||
this.requestHandler = new RequestHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.legacyRouter, // Still use legacy router for backward compatibility
|
||||
this.routeManager,
|
||||
this.functionCache,
|
||||
this.router // Pass the new modern router as well
|
||||
);
|
||||
this.webSocketHandler = new WebSocketHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.legacyRouter,
|
||||
this.routes // Pass current routes to WebSocketHandler
|
||||
);
|
||||
|
||||
// Connect request handler to this metrics tracker
|
||||
this.requestHandler.setMetricsTracker(this);
|
||||
|
||||
// Initialize with any provided routes
|
||||
if (this.options.routes && this.options.routes.length > 0) {
|
||||
this.updateRouteConfigs(this.options.routes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,7 +207,8 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
connectionPoolSize: this.connectionPool.getPoolStatus(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections
|
||||
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections,
|
||||
functionCache: this.functionCache.getStats()
|
||||
};
|
||||
}
|
||||
|
||||
@ -325,58 +362,159 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates proxy configurations
|
||||
* Updates the route configurations - this is the primary method for configuring NetworkProxy
|
||||
* @param routes The new route configurations to use
|
||||
*/
|
||||
public async updateProxyConfigs(
|
||||
proxyConfigsArg: IReverseProxyConfig[]
|
||||
): Promise<void> {
|
||||
this.logger.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 {
|
||||
// Update certificate in cache
|
||||
this.certificateManager.updateCertificateCache(
|
||||
config.hostName,
|
||||
config.publicKey,
|
||||
config.privateKey
|
||||
);
|
||||
|
||||
this.activeContexts.add(config.hostName);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to add SSL context for ${config.hostName}`, error);
|
||||
public async updateRouteConfigs(routes: IRouteConfig[]): Promise<void> {
|
||||
this.logger.info(`Updating route configurations (${routes.length} routes)`);
|
||||
|
||||
// Update routes in RouteManager, modern router, WebSocketHandler, and SecurityManager
|
||||
this.routeManager.updateRoutes(routes);
|
||||
this.router.setRoutes(routes);
|
||||
this.webSocketHandler.setRoutes(routes);
|
||||
this.requestHandler.securityManager.setRoutes(routes);
|
||||
this.routes = routes;
|
||||
|
||||
// Directly update the certificate manager with the new routes
|
||||
// This will extract domains and handle certificate provisioning
|
||||
this.certificateManager.updateRouteConfigs(routes);
|
||||
|
||||
// Collect all domains and certificates for configuration
|
||||
const currentHostnames = new Set<string>();
|
||||
const certificateUpdates = new Map<string, { cert: string, key: string }>();
|
||||
|
||||
// Process each route to extract domain and certificate information
|
||||
for (const route of routes) {
|
||||
// Skip non-forward routes or routes without domains
|
||||
if (route.action.type !== 'forward' || !route.match.domains) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get domains from route
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Process each domain
|
||||
for (const domain of domains) {
|
||||
// Skip wildcard domains for direct host configuration
|
||||
if (domain.includes('*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentHostnames.add(domain);
|
||||
|
||||
// Check if we have a static certificate for this domain
|
||||
if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') {
|
||||
certificateUpdates.set(domain, {
|
||||
cert: route.action.tls.certificate.cert,
|
||||
key: route.action.tls.certificate.key
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update certificate cache with any static certificates
|
||||
for (const [domain, certData] of certificateUpdates.entries()) {
|
||||
try {
|
||||
this.certificateManager.updateCertificateCache(
|
||||
domain,
|
||||
certData.cert,
|
||||
certData.key
|
||||
);
|
||||
|
||||
this.activeContexts.add(domain);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to add SSL context for ${domain}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up removed contexts
|
||||
for (const hostname of this.activeContexts) {
|
||||
if (!currentHostNames.has(hostname)) {
|
||||
if (!currentHostnames.has(hostname)) {
|
||||
this.logger.info(`Hostname ${hostname} removed from configuration`);
|
||||
this.activeContexts.delete(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
// Register domains with Port80Handler if available
|
||||
const domainsForACME = Array.from(currentHostNames)
|
||||
.filter(domain => !domain.includes('*')); // Skip wildcard domains
|
||||
|
||||
this.certificateManager.registerDomainsWithPort80Handler(domainsForACME);
|
||||
|
||||
// Create legacy proxy configs for the router
|
||||
// This is only needed for backward compatibility with ProxyRouter
|
||||
// and will be removed in the future
|
||||
const legacyConfigs: IReverseProxyConfig[] = [];
|
||||
|
||||
for (const domain of currentHostnames) {
|
||||
// Find route for this domain
|
||||
const route = routes.find(r => {
|
||||
const domains = Array.isArray(r.match.domains) ? r.match.domains : [r.match.domains];
|
||||
return domains.includes(domain);
|
||||
});
|
||||
|
||||
if (!route || route.action.type !== 'forward' || !route.action.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip routes with function-based targets - we'll handle them during request processing
|
||||
if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') {
|
||||
this.logger.info(`Domain ${domain} uses function-based targets - will be handled at request time`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract static target information
|
||||
const targetHosts = Array.isArray(route.action.target.host)
|
||||
? route.action.target.host
|
||||
: [route.action.target.host];
|
||||
|
||||
const targetPort = route.action.target.port;
|
||||
|
||||
// Get certificate information
|
||||
const certData = certificateUpdates.get(domain);
|
||||
const defaultCerts = this.certificateManager.getDefaultCertificates();
|
||||
|
||||
legacyConfigs.push({
|
||||
hostName: domain,
|
||||
destinationIps: targetHosts,
|
||||
destinationPorts: [targetPort],
|
||||
privateKey: certData?.key || defaultCerts.key,
|
||||
publicKey: certData?.cert || defaultCerts.cert
|
||||
});
|
||||
}
|
||||
|
||||
// Update the router with legacy configs
|
||||
// Handle both old and new router interfaces
|
||||
if (typeof this.router.setRoutes === 'function') {
|
||||
this.router.setRoutes(routes);
|
||||
} else if (typeof this.router.setNewProxyConfigs === 'function') {
|
||||
this.router.setNewProxyConfigs(legacyConfigs);
|
||||
} else {
|
||||
this.logger.warn('Router has no recognized configuration method');
|
||||
}
|
||||
|
||||
this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use updateRouteConfigs instead
|
||||
* Legacy method for updating proxy configurations using IReverseProxyConfig
|
||||
* This method is maintained for backward compatibility
|
||||
*/
|
||||
public async updateProxyConfigs(
|
||||
proxyConfigsArg: IReverseProxyConfig[]
|
||||
): Promise<void> {
|
||||
this.logger.info(`Converting ${proxyConfigsArg.length} legacy configs to route configs`);
|
||||
|
||||
// Convert legacy configs to route configs
|
||||
const routes: IRouteConfig[] = proxyConfigsArg.map(config =>
|
||||
convertLegacyConfigToRouteConfig(config, this.options.port)
|
||||
);
|
||||
|
||||
// Use the primary method
|
||||
return this.updateRouteConfigs(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use route-based configuration instead
|
||||
* Converts SmartProxy domain configurations to NetworkProxy configs
|
||||
* @param domainConfigs SmartProxy domain configs
|
||||
* @param sslKeyPair Default SSL key pair to use if not specified
|
||||
* @returns Array of NetworkProxy configs
|
||||
* This method is maintained for backward compatibility
|
||||
*/
|
||||
public convertSmartProxyConfigs(
|
||||
domainConfigs: Array<{
|
||||
@ -386,13 +524,15 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
}>,
|
||||
sslKeyPair?: { key: string; cert: string }
|
||||
): IReverseProxyConfig[] {
|
||||
this.logger.warn('convertSmartProxyConfigs is deprecated - use route-based configuration instead');
|
||||
|
||||
const proxyConfigs: IReverseProxyConfig[] = [];
|
||||
|
||||
|
||||
// Use default certificates if not provided
|
||||
const defaultCerts = this.certificateManager.getDefaultCertificates();
|
||||
const sslKey = sslKeyPair?.key || defaultCerts.key;
|
||||
const sslCert = sslKeyPair?.cert || defaultCerts.cert;
|
||||
|
||||
|
||||
for (const domainConfig of domainConfigs) {
|
||||
// Each domain in the domains array gets its own config
|
||||
for (const domain of domainConfig.domains) {
|
||||
@ -400,7 +540,7 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
proxyConfigs.push({
|
||||
hostName: domain,
|
||||
destinationIps: domainConfig.targetIPs || ['localhost'],
|
||||
@ -410,7 +550,7 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.logger.info(`Converted ${domainConfigs.length} SmartProxy configs to ${proxyConfigs.length} NetworkProxy configs`);
|
||||
return proxyConfigs;
|
||||
}
|
||||
@ -474,11 +614,90 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
public async requestCertificate(domain: string): Promise<boolean> {
|
||||
return this.certificateManager.requestCertificate(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update certificate for a domain
|
||||
*
|
||||
* This method allows direct updates of certificates from external sources
|
||||
* like Port80Handler or custom certificate providers.
|
||||
*
|
||||
* @param domain The domain to update certificate for
|
||||
* @param certificate The new certificate (public key)
|
||||
* @param privateKey The new private key
|
||||
* @param expiryDate Optional expiry date
|
||||
*/
|
||||
public updateCertificate(
|
||||
domain: string,
|
||||
certificate: string,
|
||||
privateKey: string,
|
||||
expiryDate?: Date
|
||||
): void {
|
||||
this.logger.info(`Updating certificate for ${domain}`);
|
||||
this.certificateManager.updateCertificateCache(domain, certificate, privateKey, expiryDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all proxy configurations currently in use
|
||||
* Gets all route configurations currently in use
|
||||
*/
|
||||
public getRouteConfigs(): IRouteConfig[] {
|
||||
return this.routeManager.getRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getRouteConfigs instead
|
||||
* Gets all proxy configurations currently in use in the legacy format
|
||||
* This method is maintained for backward compatibility
|
||||
*/
|
||||
public getProxyConfigs(): IReverseProxyConfig[] {
|
||||
return [...this.proxyConfigs];
|
||||
this.logger.warn('getProxyConfigs is deprecated - use getRouteConfigs instead');
|
||||
|
||||
// Create legacy proxy configs from our route configurations
|
||||
const legacyConfigs: IReverseProxyConfig[] = [];
|
||||
const currentRoutes = this.routeManager.getRoutes();
|
||||
|
||||
for (const route of currentRoutes) {
|
||||
// Skip non-forward routes or routes without domains
|
||||
if (route.action.type !== 'forward' || !route.match.domains || !route.action.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip routes with function-based targets
|
||||
if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get domains
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains.filter(d => !d.includes('*'))
|
||||
: route.match.domains.includes('*') ? [] : [route.match.domains];
|
||||
|
||||
// Get certificate
|
||||
let privateKey = '';
|
||||
let publicKey = '';
|
||||
|
||||
if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') {
|
||||
privateKey = route.action.tls.certificate.key;
|
||||
publicKey = route.action.tls.certificate.cert;
|
||||
} else {
|
||||
const defaultCerts = this.certificateManager.getDefaultCertificates();
|
||||
privateKey = defaultCerts.key;
|
||||
publicKey = defaultCerts.cert;
|
||||
}
|
||||
|
||||
// Create legacy config for each domain
|
||||
for (const domain of domains) {
|
||||
legacyConfigs.push({
|
||||
hostName: domain,
|
||||
destinationIps: Array.isArray(route.action.target.host)
|
||||
? route.action.target.host
|
||||
: [route.action.target.host],
|
||||
destinationPorts: [route.action.target.port],
|
||||
privateKey,
|
||||
publicKey
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return legacyConfigs;
|
||||
}
|
||||
}
|
@ -1,7 +1,22 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { type INetworkProxyOptions, type ILogger, createLogger, type IReverseProxyConfig } from './models/types.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
|
||||
@ -24,12 +39,34 @@ export class RequestHandler {
|
||||
// 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 router: ProxyRouter
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -59,39 +96,104 @@ export class RequestHandler {
|
||||
|
||||
/**
|
||||
* 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
|
||||
res: plugins.http.ServerResponse,
|
||||
req: plugins.http.IncomingMessage,
|
||||
route?: IRouteConfig
|
||||
): void {
|
||||
if (!this.options.cors) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Apply CORS headers
|
||||
if (this.options.cors.allowOrigin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', this.options.cors.allowOrigin);
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.cors.allowMethods) {
|
||||
res.setHeader('Access-Control-Allow-Methods', this.options.cors.allowMethods);
|
||||
|
||||
// Apply other CORS headers
|
||||
if (corsConfig.allowMethods) {
|
||||
res.setHeader('Access-Control-Allow-Methods', corsConfig.allowMethods);
|
||||
}
|
||||
|
||||
if (this.options.cors.allowHeaders) {
|
||||
res.setHeader('Access-Control-Allow-Headers', this.options.cors.allowHeaders);
|
||||
|
||||
if (corsConfig.allowHeaders) {
|
||||
res.setHeader('Access-Control-Allow-Headers', corsConfig.allowHeaders);
|
||||
}
|
||||
|
||||
if (this.options.cors.maxAge) {
|
||||
res.setHeader('Access-Control-Max-Age', this.options.cors.maxAge.toString());
|
||||
|
||||
if (corsConfig.allowCredentials) {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
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
|
||||
@ -103,12 +205,147 @@ export class RequestHandler {
|
||||
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
|
||||
@ -119,10 +356,32 @@ export class RequestHandler {
|
||||
): Promise<void> {
|
||||
// Record start time for logging
|
||||
const startTime = Date.now();
|
||||
|
||||
// Apply CORS headers if configured
|
||||
this.applyCorsHeaders(res, req);
|
||||
|
||||
|
||||
// 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') {
|
||||
@ -132,16 +391,220 @@ export class RequestHandler {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Apply default headers
|
||||
this.applyDefaultHeaders(res);
|
||||
|
||||
// Determine routing configuration
|
||||
|
||||
// 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.router.routeReq(req);
|
||||
proxyConfig = this.legacyRouter.routeReq(req);
|
||||
} catch (err) {
|
||||
this.logger.error('Error routing request', 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();
|
||||
@ -345,18 +808,180 @@ export class RequestHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP/2 stream requests by proxying to HTTP/1 backends
|
||||
* Handle HTTP/2 stream requests with function-based target support
|
||||
*/
|
||||
public async handleHttp2(stream: any, headers: any): Promise<void> {
|
||||
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 };
|
||||
const proxyConfig = this.router.routeReq(fakeReq);
|
||||
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');
|
||||
@ -364,96 +989,67 @@ export class RequestHandler {
|
||||
return;
|
||||
}
|
||||
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 backend HTTP/2 request
|
||||
const h2Headers: Record<string, any> = {
|
||||
':method': headers[':method'],
|
||||
':path': headers[':path'],
|
||||
':authority': `${destination.host}:${destination.port}`
|
||||
};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (!k.startsWith(':') && typeof v === 'string') {
|
||||
h2Headers[k] = v;
|
||||
}
|
||||
}
|
||||
const h2Stream2 = session.request(h2Headers);
|
||||
stream.pipe(h2Stream2);
|
||||
h2Stream2.on('response', (hdrs: any) => {
|
||||
// Map status and headers to client
|
||||
const resp: Record<string, any> = { ':status': hdrs[':status'] as number };
|
||||
for (const [hk, hv] of Object.entries(hdrs)) {
|
||||
if (!hk.startsWith(':') && hv) resp[hk] = hv;
|
||||
}
|
||||
stream.respond(resp);
|
||||
h2Stream2.pipe(stream);
|
||||
});
|
||||
h2Stream2.on('error', (err) => {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
return;
|
||||
|
||||
// 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 };
|
||||
const proxyConfig = this.router.routeReq(fakeReq as any);
|
||||
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]
|
||||
);
|
||||
// Build headers for HTTP/1 proxy
|
||||
const outboundHeaders: Record<string,string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof key === 'string' && typeof value === 'string' && !key.startsWith(':')) {
|
||||
outboundHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
if (outboundHeaders.host && (proxyConfig as IReverseProxyConfig).rewriteHostHeader) {
|
||||
outboundHeaders.host = `${destination.host}:${destination.port}`;
|
||||
}
|
||||
// Create HTTP/1 proxy request
|
||||
const proxyReq = plugins.http.request(
|
||||
{ hostname: destination.host, port: destination.port, path, method, headers: outboundHeaders },
|
||||
(proxyRes) => {
|
||||
// Map status and headers back to HTTP/2
|
||||
const responseHeaders: Record<string, number|string|string[]> = {};
|
||||
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
||||
if (v !== undefined) {
|
||||
responseHeaders[k] = v as string | string[];
|
||||
}
|
||||
}
|
||||
stream.respond({ ':status': proxyRes.statusCode || 500, ...responseHeaders });
|
||||
proxyRes.pipe(stream);
|
||||
stream.on('close', () => proxyReq.destroy());
|
||||
stream.on('error', () => proxyReq.destroy());
|
||||
if (this.metricsTracker) stream.on('end', () => this.metricsTracker.incrementRequestsServed());
|
||||
}
|
||||
|
||||
// Use the helper for HTTP/2 to HTTP/1 routing
|
||||
return Http2RequestHandler.handleHttp2WithHttp1Destination(
|
||||
stream,
|
||||
headers,
|
||||
destination,
|
||||
routeContext,
|
||||
this.logger,
|
||||
this.metricsTracker
|
||||
);
|
||||
proxyReq.on('error', (err) => {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
// Pipe client stream to backend
|
||||
stream.pipe(proxyReq);
|
||||
} catch (err: any) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
|
298
ts/proxies/network-proxy/security-manager.ts
Normal file
298
ts/proxies/network-proxy/security-manager.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
|
||||
/**
|
||||
* Manages security features for the NetworkProxy
|
||||
* Implements Phase 5.4: Security features like IP filtering and rate limiting
|
||||
*/
|
||||
export class SecurityManager {
|
||||
// Cache IP filtering results to avoid constant regex matching
|
||||
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
||||
|
||||
// Store rate limits per route and key
|
||||
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
|
||||
|
||||
constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {}
|
||||
|
||||
/**
|
||||
* Update the routes configuration
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
// Reset caches when routes change
|
||||
this.ipFilterCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client is allowed to access a specific route
|
||||
*
|
||||
* @param route The route to check access for
|
||||
* @param context The route context with client information
|
||||
* @returns True if access is allowed, false otherwise
|
||||
*/
|
||||
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
// --- IP filtering ---
|
||||
if (!this.isIpAllowed(route, context.clientIp)) {
|
||||
this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || route.id || 'unnamed'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Rate limiting ---
|
||||
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
||||
this.logger.debug(`Rate limit exceeded for route ${route.name || route.id || 'unnamed'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Basic Auth (handled at HTTP level) ---
|
||||
// Basic auth is not checked here as it requires HTTP headers
|
||||
// and is handled in the RequestHandler
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is allowed based on route security settings
|
||||
*/
|
||||
private isIpAllowed(route: IRouteConfig, clientIp: string): boolean {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
const routeId = route.id || route.name || 'unnamed';
|
||||
|
||||
// Check cache first
|
||||
if (!this.ipFilterCache.has(routeId)) {
|
||||
this.ipFilterCache.set(routeId, new Map());
|
||||
}
|
||||
|
||||
const routeCache = this.ipFilterCache.get(routeId)!;
|
||||
if (routeCache.has(clientIp)) {
|
||||
return routeCache.get(clientIp)!;
|
||||
}
|
||||
|
||||
let allowed = true;
|
||||
|
||||
// Check block list first (deny has priority over allow)
|
||||
if (route.security.ipBlockList && route.security.ipBlockList.length > 0) {
|
||||
if (this.ipMatchesPattern(clientIp, route.security.ipBlockList)) {
|
||||
allowed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check allow list (overrides block list if specified)
|
||||
if (route.security.ipAllowList && route.security.ipAllowList.length > 0) {
|
||||
// If allow list is specified, IP must match an entry to be allowed
|
||||
allowed = this.ipMatchesPattern(clientIp, route.security.ipAllowList);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
routeCache.set(clientIp, allowed);
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP matches any pattern in the list
|
||||
*/
|
||||
private ipMatchesPattern(ip: string, patterns: string[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
// CIDR notation
|
||||
if (pattern.includes('/')) {
|
||||
if (this.ipMatchesCidr(ip, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Wildcard notation
|
||||
else if (pattern.includes('*')) {
|
||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Exact match
|
||||
else if (pattern === ip) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP matches CIDR notation
|
||||
* Very basic implementation - for production use, consider a dedicated IP library
|
||||
*/
|
||||
private ipMatchesCidr(ip: string, cidr: string): boolean {
|
||||
try {
|
||||
const [subnet, bits] = cidr.split('/');
|
||||
const mask = parseInt(bits, 10);
|
||||
|
||||
// Convert IP to numeric format
|
||||
const ipParts = ip.split('.').map(part => parseInt(part, 10));
|
||||
const subnetParts = subnet.split('.').map(part => parseInt(part, 10));
|
||||
|
||||
// Calculate the numeric IP and subnet
|
||||
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
||||
|
||||
// Calculate the mask
|
||||
const maskNum = ~((1 << (32 - mask)) - 1);
|
||||
|
||||
// Check if IP is in subnet
|
||||
return (ipNum & maskNum) === (subnetNum & maskNum);
|
||||
} catch (e) {
|
||||
this.logger.error(`Invalid CIDR notation: ${cidr}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is within rate limit
|
||||
*/
|
||||
private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
if (!route.security?.rateLimit?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rateLimit = route.security.rateLimit;
|
||||
const routeId = route.id || route.name || 'unnamed';
|
||||
|
||||
// Determine rate limit key (by IP, path, or header)
|
||||
let key = context.clientIp; // Default to IP
|
||||
|
||||
if (rateLimit.keyBy === 'path' && context.path) {
|
||||
key = `${context.clientIp}:${context.path}`;
|
||||
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
||||
const headerValue = context.headers[rateLimit.headerName.toLowerCase()];
|
||||
if (headerValue) {
|
||||
key = `${context.clientIp}:${headerValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create rate limit tracking for this route
|
||||
if (!this.rateLimits.has(routeId)) {
|
||||
this.rateLimits.set(routeId, new Map());
|
||||
}
|
||||
|
||||
const routeLimits = this.rateLimits.get(routeId)!;
|
||||
const now = Date.now();
|
||||
|
||||
// Get or create rate limit tracking for this key
|
||||
let limit = routeLimits.get(key);
|
||||
if (!limit || limit.expiry < now) {
|
||||
// Create new rate limit or reset expired one
|
||||
limit = {
|
||||
count: 1,
|
||||
expiry: now + (rateLimit.window * 1000)
|
||||
};
|
||||
routeLimits.set(key, limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment the counter
|
||||
limit.count++;
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
return limit.count <= rateLimit.maxRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired rate limits
|
||||
* Should be called periodically to prevent memory leaks
|
||||
*/
|
||||
public cleanupExpiredRateLimits(): void {
|
||||
const now = Date.now();
|
||||
for (const [routeId, routeLimits] of this.rateLimits.entries()) {
|
||||
let removed = 0;
|
||||
for (const [key, limit] of routeLimits.entries()) {
|
||||
if (limit.expiry < now) {
|
||||
routeLimits.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check basic auth credentials
|
||||
*
|
||||
* @param route The route to check auth for
|
||||
* @param username The provided username
|
||||
* @param password The provided password
|
||||
* @returns True if credentials are valid, false otherwise
|
||||
*/
|
||||
public checkBasicAuth(route: IRouteConfig, username: string, password: string): boolean {
|
||||
if (!route.security?.basicAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const basicAuth = route.security.basicAuth;
|
||||
|
||||
// Check credentials against configured users
|
||||
for (const user of basicAuth.users) {
|
||||
if (user.username === username && user.password === password) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*
|
||||
* @param route The route to verify the token for
|
||||
* @param token The JWT token to verify
|
||||
* @returns True if the token is valid, false otherwise
|
||||
*/
|
||||
public verifyJwtToken(route: IRouteConfig, token: string): boolean {
|
||||
if (!route.security?.jwtAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// This is a simplified version - in production you'd use a proper JWT library
|
||||
const jwtAuth = route.security.jwtAuth;
|
||||
|
||||
// Verify structure
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check issuer
|
||||
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check audience
|
||||
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In a real implementation, you'd also verify the signature
|
||||
// using the secret and algorithm specified in jwtAuth
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error verifying JWT: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,15 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import { type INetworkProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger, type IReverseProxyConfig } from './models/types.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { ProxyRouter } from '../../http/router/index.js';
|
||||
import { ProxyRouter, RouteRouter } from '../../http/router/index.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import { toBaseContext } from '../../core/models/route-context.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
import { getMessageSize, toBuffer } from '../../core/utils/websocket-utils.js';
|
||||
|
||||
/**
|
||||
* Handles WebSocket connections and proxying
|
||||
@ -10,13 +18,40 @@ export class WebSocketHandler {
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private wsServer: plugins.ws.WebSocketServer | null = null;
|
||||
private logger: ILogger;
|
||||
private contextCreator: ContextCreator = new ContextCreator();
|
||||
private routeRouter: RouteRouter | null = null;
|
||||
private securityManager: SecurityManager;
|
||||
|
||||
constructor(
|
||||
private options: INetworkProxyOptions,
|
||||
private connectionPool: ConnectionPool,
|
||||
private router: ProxyRouter
|
||||
private legacyRouter: ProxyRouter, // Legacy router for backward compatibility
|
||||
private routes: IRouteConfig[] = [] // Routes for modern router
|
||||
) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
this.securityManager = new SecurityManager(this.logger, routes);
|
||||
|
||||
// Initialize modern router if we have routes
|
||||
if (routes.length > 0) {
|
||||
this.routeRouter = new RouteRouter(routes, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the route configurations
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
|
||||
// Initialize or update the route router
|
||||
if (!this.routeRouter) {
|
||||
this.routeRouter = new RouteRouter(routes, this.logger);
|
||||
} else {
|
||||
this.routeRouter.setRoutes(routes);
|
||||
}
|
||||
|
||||
// Update the security manager
|
||||
this.securityManager.setRoutes(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,51 +126,200 @@ export class WebSocketHandler {
|
||||
wsIncoming.lastPong = Date.now();
|
||||
});
|
||||
|
||||
// Find target configuration based on request
|
||||
const proxyConfig = this.router.routeReq(req);
|
||||
|
||||
if (!proxyConfig) {
|
||||
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);
|
||||
wsIncoming.close(1008, 'No proxy configuration for this host');
|
||||
return;
|
||||
// Create a context for routing
|
||||
const connectionId = `ws-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
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
|
||||
});
|
||||
|
||||
// Try modern router first if available
|
||||
let route: IRouteConfig | undefined;
|
||||
if (this.routeRouter) {
|
||||
route = this.routeRouter.routeReq(req);
|
||||
}
|
||||
|
||||
// Define destination variables
|
||||
let destination: { host: string; port: number };
|
||||
|
||||
// If we found a route with the modern router, use it
|
||||
if (route && route.action.type === 'forward' && route.action.target) {
|
||||
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
|
||||
|
||||
// Check if WebSockets are enabled for this route
|
||||
if (route.action.websocket?.enabled === false) {
|
||||
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
|
||||
wsIncoming.close(1003, 'WebSockets not supported for this route');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check security restrictions if configured to authenticate WebSocket requests
|
||||
if (route.action.websocket?.authenticateRequest !== false && route.security) {
|
||||
if (!this.securityManager.isAllowed(route, toBaseContext(routeContext))) {
|
||||
this.logger.warn(`WebSocket connection denied by security policy for ${routeContext.clientIp}`);
|
||||
wsIncoming.close(1008, 'Access denied by security policy');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check origin restrictions if configured
|
||||
const origin = req.headers.origin;
|
||||
if (origin && route.action.websocket?.allowedOrigins && route.action.websocket.allowedOrigins.length > 0) {
|
||||
const isAllowed = route.action.websocket.allowedOrigins.some(allowedOrigin => {
|
||||
// Handle wildcards and template variables
|
||||
if (allowedOrigin.includes('*') || allowedOrigin.includes('{')) {
|
||||
const pattern = allowedOrigin.replace(/\*/g, '.*');
|
||||
const resolvedPattern = TemplateUtils.resolveTemplateVariables(pattern, routeContext);
|
||||
const regex = new RegExp(`^${resolvedPattern}$`);
|
||||
return regex.test(origin);
|
||||
}
|
||||
return allowedOrigin === origin;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
this.logger.warn(`WebSocket origin ${origin} not allowed for route: ${route.name || 'unnamed'}`);
|
||||
wsIncoming.close(1008, 'Origin not allowed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract target information, resolving functions if needed
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// Resolve host if it's a function
|
||||
if (typeof route.action.target.host === 'function') {
|
||||
const resolvedHost = route.action.target.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
} else {
|
||||
targetHost = route.action.target.host;
|
||||
}
|
||||
|
||||
// Resolve port if it's a function
|
||||
if (typeof route.action.target.port === 'function') {
|
||||
targetPort = route.action.target.port(toBaseContext(routeContext));
|
||||
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
|
||||
} else {
|
||||
targetPort = route.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 WebSocket connection
|
||||
destination = {
|
||||
host: selectedHost,
|
||||
port: targetPort
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Error evaluating function-based target for WebSocket: ${err}`);
|
||||
wsIncoming.close(1011, 'Internal server error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fall back to legacy routing if no matching route found via modern router
|
||||
const proxyConfig = this.legacyRouter.routeReq(req);
|
||||
|
||||
if (!proxyConfig) {
|
||||
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);
|
||||
wsIncoming.close(1008, 'No proxy configuration for this host');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get destination target using round-robin if multiple targets
|
||||
destination = this.connectionPool.getNextTarget(
|
||||
proxyConfig.destinationIps,
|
||||
proxyConfig.destinationPorts[0]
|
||||
);
|
||||
}
|
||||
|
||||
// Get destination target using round-robin if multiple targets
|
||||
const destination = this.connectionPool.getNextTarget(
|
||||
proxyConfig.destinationIps,
|
||||
proxyConfig.destinationPorts[0]
|
||||
);
|
||||
|
||||
// Build target URL
|
||||
// Build target URL with potential path rewriting
|
||||
const protocol = (req.socket as any).encrypted ? 'wss' : 'ws';
|
||||
const targetUrl = `${protocol}://${destination.host}:${destination.port}${req.url}`;
|
||||
|
||||
let targetPath = req.url || '/';
|
||||
|
||||
// Apply path rewriting if configured
|
||||
if (route?.action.websocket?.rewritePath) {
|
||||
const originalPath = targetPath;
|
||||
targetPath = TemplateUtils.resolveTemplateVariables(
|
||||
route.action.websocket.rewritePath,
|
||||
{...routeContext, path: targetPath}
|
||||
);
|
||||
this.logger.debug(`WebSocket path rewritten: ${originalPath} -> ${targetPath}`);
|
||||
}
|
||||
|
||||
const targetUrl = `${protocol}://${destination.host}:${destination.port}${targetPath}`;
|
||||
|
||||
this.logger.debug(`WebSocket connection from ${req.socket.remoteAddress} to ${targetUrl}`);
|
||||
|
||||
|
||||
// Create headers for outgoing WebSocket connection
|
||||
const headers: { [key: string]: string } = {};
|
||||
|
||||
|
||||
// Copy relevant headers from incoming request
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value && typeof value === 'string' &&
|
||||
key.toLowerCase() !== 'connection' &&
|
||||
if (value && typeof value === 'string' &&
|
||||
key.toLowerCase() !== 'connection' &&
|
||||
key.toLowerCase() !== 'upgrade' &&
|
||||
key.toLowerCase() !== 'sec-websocket-key' &&
|
||||
key.toLowerCase() !== 'sec-websocket-version') {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Override host header if needed
|
||||
if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) {
|
||||
headers['host'] = `${destination.host}:${destination.port}`;
|
||||
|
||||
// Always rewrite host header for WebSockets for consistency
|
||||
headers['host'] = `${destination.host}:${destination.port}`;
|
||||
|
||||
// Add custom headers from route configuration
|
||||
if (route?.action.websocket?.customHeaders) {
|
||||
for (const [key, value] of Object.entries(route.action.websocket.customHeaders)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (headers[key.toLowerCase()] && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
delete headers[key.toLowerCase()];
|
||||
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, routeContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, routeContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
headers[key.toLowerCase()] = finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Create outgoing WebSocket connection
|
||||
const wsOutgoing = new plugins.wsDefault(targetUrl, {
|
||||
// Create WebSocket connection options
|
||||
const wsOptions: any = {
|
||||
headers: headers,
|
||||
followRedirects: true
|
||||
});
|
||||
};
|
||||
|
||||
// Add subprotocols if configured
|
||||
if (route?.action.websocket?.subprotocols && route.action.websocket.subprotocols.length > 0) {
|
||||
wsOptions.protocols = route.action.websocket.subprotocols;
|
||||
} else if (req.headers['sec-websocket-protocol']) {
|
||||
// Pass through client requested protocols
|
||||
wsOptions.protocols = req.headers['sec-websocket-protocol'].split(',').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Create outgoing WebSocket connection
|
||||
const wsOutgoing = new plugins.wsDefault(targetUrl, wsOptions);
|
||||
|
||||
// Handle connection errors
|
||||
wsOutgoing.on('error', (err) => {
|
||||
@ -147,35 +331,94 @@ export class WebSocketHandler {
|
||||
|
||||
// Handle outgoing connection open
|
||||
wsOutgoing.on('open', () => {
|
||||
// Set up custom ping interval if configured
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
if (route?.action.websocket?.pingInterval && route.action.websocket.pingInterval > 0) {
|
||||
pingInterval = setInterval(() => {
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.ping();
|
||||
this.logger.debug(`Sent WebSocket ping to client for route: ${route.name || 'unnamed'}`);
|
||||
}
|
||||
}, route.action.websocket.pingInterval);
|
||||
|
||||
// Don't keep process alive just for pings
|
||||
if (pingInterval.unref) pingInterval.unref();
|
||||
}
|
||||
|
||||
// Set up custom ping timeout if configured
|
||||
let pingTimeout: NodeJS.Timeout | null = null;
|
||||
const pingTimeoutMs = route?.action.websocket?.pingTimeout || 60000; // Default 60s
|
||||
|
||||
// Define timeout function for cleaner code
|
||||
const resetPingTimeout = () => {
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
this.logger.debug(`WebSocket ping timeout for client connection on route: ${route?.name || 'unnamed'}`);
|
||||
wsIncoming.terminate();
|
||||
}, pingTimeoutMs);
|
||||
|
||||
// Don't keep process alive just for timeouts
|
||||
if (pingTimeout.unref) pingTimeout.unref();
|
||||
};
|
||||
|
||||
// Reset timeout on pong
|
||||
wsIncoming.on('pong', () => {
|
||||
wsIncoming.isAlive = true;
|
||||
wsIncoming.lastPong = Date.now();
|
||||
resetPingTimeout();
|
||||
});
|
||||
|
||||
// Initial ping timeout
|
||||
resetPingTimeout();
|
||||
|
||||
// Handle potential message size limits
|
||||
const maxSize = route?.action.websocket?.maxPayloadSize || 0;
|
||||
|
||||
// Forward incoming messages to outgoing connection
|
||||
wsIncoming.on('message', (data, isBinary) => {
|
||||
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
||||
// Check message size if limit is set
|
||||
const messageSize = getMessageSize(data);
|
||||
if (maxSize > 0 && messageSize > maxSize) {
|
||||
this.logger.warn(`WebSocket message exceeds max size (${messageSize} > ${maxSize})`);
|
||||
wsIncoming.close(1009, 'Message too big');
|
||||
return;
|
||||
}
|
||||
|
||||
wsOutgoing.send(data, { binary: isBinary });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Forward outgoing messages to incoming connection
|
||||
wsOutgoing.on('message', (data, isBinary) => {
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.send(data, { binary: isBinary });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Handle closing of connections
|
||||
wsIncoming.on('close', (code, reason) => {
|
||||
this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`);
|
||||
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
||||
wsOutgoing.close(code, reason);
|
||||
}
|
||||
|
||||
// Clean up timers
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
});
|
||||
|
||||
|
||||
wsOutgoing.on('close', (code, reason) => {
|
||||
this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`);
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.close(code, reason);
|
||||
}
|
||||
|
||||
// Clean up timers
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
});
|
||||
|
||||
|
||||
this.logger.debug(`WebSocket connection established: ${req.headers.host} -> ${destination.host}:${destination.port}`);
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user