- 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.
414 lines
12 KiB
TypeScript
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 }; |