smartproxy/ts/routing/router/http-router.ts
Philipp Kunz 54ffbadb86 feat(routing): Implement unified routing and matching system
- Introduced a centralized routing module with comprehensive matchers for domains, headers, IPs, and paths.
- Added DomainMatcher for domain pattern matching with support for wildcards and specificity calculation.
- Implemented HeaderMatcher for HTTP header matching, including exact matches and pattern support.
- Developed IpMatcher for IP address matching, supporting CIDR notation, ranges, and wildcards.
- Created PathMatcher for path matching with parameter extraction and wildcard support.
- Established RouteSpecificity class to calculate and compare route specificity scores.
- Enhanced HttpRouter to utilize the new matching system, supporting both modern and legacy route configurations.
- Added detailed logging and error handling for routing operations.
2025-06-02 03:57:52 +00:00

414 lines
12 KiB
TypeScript

import * as plugins from '../../plugins.js';
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
import type { IReverseProxyConfig } from '../../proxies/http-proxy/models/types.js';
import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js';
/**
* Interface for router result with additional metadata
*/
export interface RouterResult {
route: IRouteConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
/**
* Legacy interface for backward compatibility
*/
export interface LegacyRouterResult {
config: IReverseProxyConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
/**
* Logger interface for HttpRouter
*/
export interface ILogger {
debug?: (message: string, data?: any) => void;
info: (message: string, data?: any) => void;
warn: (message: string, data?: any) => void;
error: (message: string, data?: any) => void;
}
/**
* Unified HTTP Router for reverse proxy requests
*
* Supports both modern IRouteConfig and legacy IReverseProxyConfig formats
*
* Domain matching patterns:
* - Exact matches: "example.com"
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
* - Default fallback: "*" (matches any unmatched domain)
*
* Path pattern matching:
* - Exact path: "/api/users"
* - Wildcard paths: "/api/*"
* - Path parameters: "/users/:id/profile"
*/
export class HttpRouter {
// Store routes sorted by priority
private routes: IRouteConfig[] = [];
// Default route to use when no match is found (optional)
private defaultRoute?: IRouteConfig;
// Logger interface
private logger: ILogger;
constructor(
routes?: IRouteConfig[],
logger?: ILogger
) {
this.logger = logger || {
error: console.error.bind(console),
warn: console.warn.bind(console),
info: console.info.bind(console),
debug: console.debug?.bind(console)
};
if (routes) {
this.setRoutes(routes);
}
}
/**
* Sets a new set of routes
* @param routes Array of route configurations
*/
public setRoutes(routes: IRouteConfig[]): void {
this.routes = [...routes];
// Sort routes by priority (higher priority first)
this.routes.sort((a, b) => {
const priorityA = a.priority ?? 0;
const priorityB = b.priority ?? 0;
return priorityB - priorityA;
});
// Find default route if any (route with "*" as domain)
this.defaultRoute = this.routes.find(route => {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: route.match.domains ? [route.match.domains] : [];
return domains.includes('*');
});
const uniqueDomains = this.getHostnames();
this.logger.info(`HttpRouter initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`);
}
/**
* Routes a request based on hostname and path
* @param req The incoming HTTP request
* @returns The matching route or undefined if no match found
*/
public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined {
const result = this.routeReqWithDetails(req);
return result ? result.route : undefined;
}
/**
* Routes a request with detailed matching information
* @param req The incoming HTTP request
* @returns Detailed routing result including matched route and path information
*/
public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined {
// Extract and validate host header
const originalHost = req.headers.host;
if (!originalHost) {
this.logger.error('No host header found in request');
return this.defaultRoute ? { route: this.defaultRoute } : undefined;
}
// Parse URL for path matching
const parsedUrl = plugins.url.parse(req.url || '/');
const urlPath = parsedUrl.pathname || '/';
// Extract hostname without port
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
// Find matching route
const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath);
if (matchingRoute) {
return matchingRoute;
}
// Fall back to default route if available
if (this.defaultRoute) {
this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`);
return { route: this.defaultRoute };
}
this.logger.error(`No route found for host: ${hostWithoutPort}`);
return undefined;
}
/**
* Find the best matching route for a given hostname and path
*/
private findMatchingRoute(hostname: string, path: string): RouterResult | undefined {
// Try each route in priority order
for (const route of this.routes) {
// Skip disabled routes
if (route.enabled === false) {
continue;
}
// Check domain match
if (route.match.domains) {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Check if any domain pattern matches
const domainMatches = domains.some(domain =>
DomainMatcher.match(domain, hostname)
);
if (!domainMatches) {
continue;
}
}
// Check path match if specified
if (route.match.path) {
const pathResult = PathMatcher.match(route.match.path, path);
if (pathResult.matches) {
return {
route,
pathMatch: path,
pathParams: pathResult.params,
pathRemainder: pathResult.pathRemainder
};
}
} else {
// No path specified, so domain match is sufficient
return { route };
}
}
return undefined;
}
/**
* Gets all currently active route configurations
* @returns Array of all active routes
*/
public getRoutes(): IRouteConfig[] {
return [...this.routes];
}
/**
* Gets all hostnames that this router is configured to handle
* @returns Array of unique hostnames
*/
public getHostnames(): string[] {
const hostnames = new Set<string>();
for (const route of this.routes) {
if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const domain of domains) {
if (domain !== '*') {
hostnames.add(domain.toLowerCase());
}
}
}
return Array.from(hostnames);
}
/**
* Adds a single new route configuration
* @param route The route configuration to add
*/
public addRoute(route: IRouteConfig): void {
this.routes.push(route);
// Re-sort routes by priority
this.routes.sort((a, b) => {
const priorityA = a.priority ?? 0;
const priorityB = b.priority ?? 0;
return priorityB - priorityA;
});
}
/**
* Removes routes by domain pattern
* @param domain The domain pattern to remove routes for
* @returns Boolean indicating whether any routes were removed
*/
public removeRoutesByDomain(domain: string): boolean {
const initialCount = this.routes.length;
// Filter out routes that match the domain
this.routes = this.routes.filter(route => {
if (!route.match.domains) return true;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
return !domains.includes(domain);
});
return this.routes.length !== initialCount;
}
/**
* Remove a specific route by reference
* @param route The route to remove
* @returns Boolean indicating if the route was found and removed
*/
public removeRoute(route: IRouteConfig): boolean {
const index = this.routes.indexOf(route);
if (index !== -1) {
this.routes.splice(index, 1);
return true;
}
return false;
}
// ===== LEGACY COMPATIBILITY METHODS =====
/**
* Legacy method that returns IReverseProxyConfig for backward compatibility
* @param req The incoming HTTP request
* @returns The matching proxy config in legacy format or undefined
*/
public routeReqLegacy(req: plugins.http.IncomingMessage): IReverseProxyConfig | undefined {
const result = this.routeReqWithDetails(req);
if (!result) return undefined;
return this.convertRouteToLegacy(result.route);
}
/**
* Legacy method for backward compatibility with ProxyRouter
* Converts IReverseProxyConfig to IRouteConfig and sets routes
*
* @param configs Array of legacy proxy configurations
*/
public setNewProxyConfigs(configs: IReverseProxyConfig[]): void {
const routes = configs.map(config => this.convertLegacyConfig(config));
this.setRoutes(routes);
}
/**
* Legacy method for backward compatibility
* Gets all proxy configs by converting routes back to legacy format
*/
public getProxyConfigs(): IReverseProxyConfig[] {
return this.routes.map(route => this.convertRouteToLegacy(route));
}
/**
* Legacy method: Adds a proxy config with optional path pattern
* @param config The legacy configuration to add
* @param pathPattern Optional path pattern for route matching
*/
public addProxyConfig(
config: IReverseProxyConfig,
pathPattern?: string
): void {
const route = this.convertLegacyConfig(config, pathPattern);
this.addRoute(route);
}
/**
* Legacy method: Remove proxy config by hostname
* @param hostname The hostname to remove
* @returns Boolean indicating whether any configs were removed
*/
public removeProxyConfig(hostname: string): boolean {
return this.removeRoutesByDomain(hostname);
}
/**
* Convert legacy IReverseProxyConfig to IRouteConfig
*/
private convertLegacyConfig(config: IReverseProxyConfig, pathPattern?: string): IRouteConfig {
return {
match: {
ports: config.destinationPorts?.[0] || 443,
domains: config.hostName,
path: pathPattern
},
action: {
type: 'forward',
target: {
host: Array.isArray(config.destinationIps) ? config.destinationIps : config.destinationIps,
port: config.destinationPorts?.[0] || 443
},
tls: {
mode: 'terminate',
certificate: {
key: config.privateKey,
cert: config.publicKey
}
}
},
security: config.authentication ? {
basicAuth: {
enabled: true,
users: [{
username: config.authentication.user,
password: config.authentication.pass
}],
realm: 'Protected'
}
} : undefined,
name: `Legacy - ${config.hostName}`,
enabled: true
};
}
/**
* Convert IRouteConfig back to legacy IReverseProxyConfig format
*/
private convertRouteToLegacy(route: IRouteConfig): IReverseProxyConfig {
const action = route.action;
const target = action.target || { host: 'localhost', port: 80 };
// Extract certificate if available
let privateKey = '';
let publicKey = '';
if (action.tls?.certificate && typeof action.tls.certificate === 'object') {
privateKey = action.tls.certificate.key || '';
publicKey = action.tls.certificate.cert || '';
}
return {
hostName: Array.isArray(route.match.domains)
? route.match.domains[0]
: route.match.domains || '*',
destinationIps: Array.isArray(target.host) ? target.host : [target.host as string],
destinationPorts: [
typeof target.port === 'number'
? target.port
: typeof target.port === 'function'
? 443 // Default port for function-based
: 443
],
privateKey,
publicKey,
authentication: route.security?.basicAuth?.enabled && route.security.basicAuth.users.length > 0 ? {
type: 'Basic',
user: route.security.basicAuth.users[0].username || '',
pass: route.security.basicAuth.users[0].password || ''
} : undefined,
rewriteHostHeader: route.headers?.request?.['Host'] ? true : undefined
};
}
}
// Export backward compatibility aliases
export { HttpRouter as ProxyRouter };
export { HttpRouter as RouteRouter };