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.
This commit is contained in:
414
ts/routing/router/http-router.ts
Normal file
414
ts/routing/router/http-router.ts
Normal file
@ -0,0 +1,414 @@
|
||||
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 };
|
Reference in New Issue
Block a user