2025-05-13 12:48:41 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
2025-05-19 17:28:05 +00:00
|
|
|
import type { ILogger } from '../../proxies/http-proxy/models/types.js';
|
2025-05-13 12:48:41 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Optional path pattern configuration that can be added to proxy configs
|
|
|
|
*/
|
|
|
|
export interface PathPatternConfig {
|
|
|
|
pathPattern?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Interface for router result with additional metadata
|
|
|
|
*/
|
|
|
|
export interface RouterResult {
|
|
|
|
route: IRouteConfig;
|
|
|
|
pathMatch?: string;
|
|
|
|
pathParams?: Record<string, string>;
|
|
|
|
pathRemainder?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Router for HTTP reverse proxy requests based on route configurations
|
|
|
|
*
|
|
|
|
* Supports the following 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)
|
|
|
|
*
|
|
|
|
* Also supports path pattern matching for each domain:
|
|
|
|
* - Exact path: "/api/users"
|
|
|
|
* - Wildcard paths: "/api/*"
|
|
|
|
* - Path parameters: "/users/:id/profile"
|
|
|
|
*/
|
|
|
|
export class RouteRouter {
|
|
|
|
// Store original routes for reference
|
|
|
|
private routes: IRouteConfig[] = [];
|
|
|
|
// Default route to use when no match is found (optional)
|
|
|
|
private defaultRoute?: IRouteConfig;
|
|
|
|
// Store path patterns separately since they're not in the original interface
|
|
|
|
private pathPatterns: Map<IRouteConfig, string> = new Map();
|
|
|
|
// Logger interface
|
|
|
|
private logger: ILogger;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
routes?: IRouteConfig[],
|
|
|
|
logger?: ILogger
|
|
|
|
) {
|
|
|
|
this.logger = logger || {
|
|
|
|
error: console.error,
|
|
|
|
warn: console.warn,
|
|
|
|
info: console.info,
|
|
|
|
debug: console.debug
|
|
|
|
};
|
|
|
|
|
|
|
|
if (routes) {
|
|
|
|
this.setRoutes(routes);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets a new set of routes to be routed to
|
|
|
|
* @param routes Array of route configurations
|
|
|
|
*/
|
|
|
|
public setRoutes(routes: IRouteConfig[]): void {
|
|
|
|
this.routes = [...routes];
|
|
|
|
|
|
|
|
// Sort routes by priority
|
|
|
|
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];
|
|
|
|
return domains.includes('*');
|
|
|
|
});
|
|
|
|
|
|
|
|
// Extract path patterns from route match.path
|
|
|
|
for (const route of this.routes) {
|
|
|
|
if (route.match.path) {
|
|
|
|
this.pathPatterns.set(route, route.match.path);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const uniqueDomains = this.getHostnames();
|
|
|
|
this.logger.info(`Router 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();
|
|
|
|
|
|
|
|
// First try exact hostname match
|
|
|
|
const exactRoute = this.findRouteForHost(hostWithoutPort, urlPath);
|
|
|
|
if (exactRoute) {
|
|
|
|
return exactRoute;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try various wildcard patterns
|
|
|
|
if (hostWithoutPort.includes('.')) {
|
|
|
|
const domainParts = hostWithoutPort.split('.');
|
|
|
|
|
|
|
|
// Try wildcard subdomain (*.example.com)
|
|
|
|
if (domainParts.length > 2) {
|
|
|
|
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
|
|
|
const wildcardRoute = this.findRouteForHost(wildcardDomain, urlPath);
|
|
|
|
if (wildcardRoute) {
|
|
|
|
return wildcardRoute;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try TLD wildcard (example.*)
|
|
|
|
const baseDomain = domainParts.slice(0, -1).join('.');
|
|
|
|
const tldWildcardDomain = `${baseDomain}.*`;
|
|
|
|
const tldWildcardRoute = this.findRouteForHost(tldWildcardDomain, urlPath);
|
|
|
|
if (tldWildcardRoute) {
|
|
|
|
return tldWildcardRoute;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try complex wildcard patterns
|
|
|
|
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
|
|
|
for (const pattern of wildcardPatterns) {
|
|
|
|
const wildcardRoute = this.findRouteForHost(pattern, urlPath);
|
|
|
|
if (wildcardRoute) {
|
|
|
|
return wildcardRoute;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 potential wildcard patterns that could match a given hostname
|
|
|
|
* Handles complex patterns like "*.lossless*" or other partial matches
|
|
|
|
* @param hostname The hostname to find wildcard matches for
|
|
|
|
* @returns Array of potential wildcard patterns that could match
|
|
|
|
*/
|
|
|
|
private findWildcardMatches(hostname: string): string[] {
|
|
|
|
const patterns: string[] = [];
|
|
|
|
|
|
|
|
// Find all routes with wildcard domains
|
|
|
|
for (const route of this.routes) {
|
|
|
|
if (!route.match.domains) continue;
|
|
|
|
|
|
|
|
const domains = Array.isArray(route.match.domains)
|
|
|
|
? route.match.domains
|
|
|
|
: [route.match.domains];
|
|
|
|
|
|
|
|
// Filter to only wildcard domains
|
|
|
|
const wildcardDomains = domains.filter(domain => domain.includes('*'));
|
|
|
|
|
|
|
|
// Convert each wildcard domain to a regex pattern and check if it matches
|
|
|
|
for (const domain of wildcardDomains) {
|
|
|
|
// Skip the default wildcard '*'
|
|
|
|
if (domain === '*') continue;
|
|
|
|
|
|
|
|
// Skip already checked patterns (*.domain.com and domain.*)
|
|
|
|
if (domain.startsWith('*.') && domain.indexOf('*', 2) === -1) continue;
|
|
|
|
if (domain.endsWith('.*') && domain.indexOf('*') === domain.length - 1) continue;
|
|
|
|
|
|
|
|
// Convert wildcard pattern to regex
|
|
|
|
const regexPattern = domain
|
|
|
|
.replace(/\./g, '\\.') // Escape dots
|
|
|
|
.replace(/\*/g, '.*'); // Convert * to .* for regex
|
|
|
|
|
|
|
|
// Create regex object with case insensitive flag
|
|
|
|
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
|
|
|
|
|
|
// If hostname matches this complex pattern, add it to the list
|
|
|
|
if (regex.test(hostname)) {
|
|
|
|
patterns.push(domain);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return patterns;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Find a route for a specific host and path
|
|
|
|
*/
|
|
|
|
private findRouteForHost(hostname: string, path: string): RouterResult | undefined {
|
|
|
|
// Find all routes for this hostname
|
|
|
|
const matchingRoutes = this.routes.filter(route => {
|
|
|
|
if (!route.match.domains) return false;
|
|
|
|
|
|
|
|
const domains = Array.isArray(route.match.domains)
|
|
|
|
? route.match.domains
|
|
|
|
: [route.match.domains];
|
|
|
|
|
|
|
|
return domains.some(domain => domain.toLowerCase() === hostname.toLowerCase());
|
|
|
|
});
|
|
|
|
|
|
|
|
if (matchingRoutes.length === 0) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
// First try routes with path patterns
|
|
|
|
const routesWithPaths = matchingRoutes.filter(route => this.pathPatterns.has(route));
|
|
|
|
|
|
|
|
// Already sorted by priority during setRoutes
|
|
|
|
|
|
|
|
// Check each route with path pattern
|
|
|
|
for (const route of routesWithPaths) {
|
|
|
|
const pathPattern = this.pathPatterns.get(route);
|
|
|
|
if (pathPattern) {
|
|
|
|
const pathMatch = this.matchPath(path, pathPattern);
|
|
|
|
if (pathMatch) {
|
|
|
|
return {
|
|
|
|
route,
|
|
|
|
pathMatch: pathMatch.matched,
|
|
|
|
pathParams: pathMatch.params,
|
|
|
|
pathRemainder: pathMatch.remainder
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If no path pattern matched, use the first route without a path pattern
|
|
|
|
const routeWithoutPath = matchingRoutes.find(route => !this.pathPatterns.has(route));
|
|
|
|
if (routeWithoutPath) {
|
|
|
|
return { route: routeWithoutPath };
|
|
|
|
}
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Matches a URL path against a pattern
|
|
|
|
* Supports:
|
|
|
|
* - Exact matches: /users/profile
|
|
|
|
* - Wildcards: /api/* (matches any path starting with /api/)
|
|
|
|
* - Path parameters: /users/:id (captures id as a parameter)
|
|
|
|
*
|
|
|
|
* @param path The URL path to match
|
|
|
|
* @param pattern The pattern to match against
|
|
|
|
* @returns Match result with params and remainder, or null if no match
|
|
|
|
*/
|
|
|
|
private matchPath(path: string, pattern: string): {
|
|
|
|
matched: string;
|
|
|
|
params: Record<string, string>;
|
|
|
|
remainder: string;
|
|
|
|
} | null {
|
|
|
|
// Handle exact match
|
|
|
|
if (path === pattern) {
|
|
|
|
return {
|
|
|
|
matched: pattern,
|
|
|
|
params: {},
|
|
|
|
remainder: ''
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle wildcard match
|
|
|
|
if (pattern.endsWith('/*')) {
|
|
|
|
const prefix = pattern.slice(0, -2);
|
|
|
|
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
|
|
|
return {
|
|
|
|
matched: prefix,
|
|
|
|
params: {},
|
|
|
|
remainder: path.slice(prefix.length)
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle path parameters
|
|
|
|
const patternParts = pattern.split('/').filter(p => p);
|
|
|
|
const pathParts = path.split('/').filter(p => p);
|
|
|
|
|
|
|
|
// Too few path parts to match
|
|
|
|
if (pathParts.length < patternParts.length) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const params: Record<string, string> = {};
|
|
|
|
|
|
|
|
// Compare each part
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
|
|
const patternPart = patternParts[i];
|
|
|
|
const pathPart = pathParts[i];
|
|
|
|
|
|
|
|
// Handle parameter
|
|
|
|
if (patternPart.startsWith(':')) {
|
|
|
|
const paramName = patternPart.slice(1);
|
|
|
|
params[paramName] = pathPart;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle wildcard at the end
|
|
|
|
if (patternPart === '*' && i === patternParts.length - 1) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle exact match for this part
|
|
|
|
if (patternPart !== pathPart) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Calculate the remainder - the unmatched path parts
|
|
|
|
const remainderParts = pathParts.slice(patternParts.length);
|
|
|
|
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
|
|
|
|
|
|
|
|
// Calculate the matched path
|
|
|
|
const matchedParts = patternParts.map((part, i) => {
|
|
|
|
return part.startsWith(':') ? pathParts[i] : part;
|
|
|
|
});
|
|
|
|
const matched = '/' + matchedParts.join('/');
|
|
|
|
|
|
|
|
return {
|
|
|
|
matched,
|
|
|
|
params,
|
|
|
|
remainder
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 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);
|
|
|
|
|
|
|
|
// Store path pattern if present
|
|
|
|
if (route.match.path) {
|
|
|
|
this.pathPatterns.set(route, route.match.path);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
// Find routes to remove
|
|
|
|
const routesToRemove = this.routes.filter(route => {
|
|
|
|
if (!route.match.domains) return false;
|
|
|
|
|
|
|
|
const domains = Array.isArray(route.match.domains)
|
|
|
|
? route.match.domains
|
|
|
|
: [route.match.domains];
|
|
|
|
|
|
|
|
return domains.includes(domain);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Remove them from the patterns map
|
|
|
|
for (const route of routesToRemove) {
|
|
|
|
this.pathPatterns.delete(route);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter them out of the routes array
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Legacy method for compatibility with ProxyRouter
|
|
|
|
* Converts IReverseProxyConfig to IRouteConfig and calls setRoutes
|
|
|
|
*
|
|
|
|
* @param configs Array of legacy proxy configurations
|
|
|
|
*/
|
|
|
|
public setNewProxyConfigs(configs: any[]): void {
|
|
|
|
// Convert legacy configs to routes and add them
|
|
|
|
const routes: IRouteConfig[] = configs.map(config => {
|
|
|
|
// Create a basic route configuration from the legacy config
|
|
|
|
return {
|
|
|
|
match: {
|
|
|
|
ports: config.destinationPorts[0], // Just use the first port
|
|
|
|
domains: config.hostName
|
|
|
|
},
|
|
|
|
action: {
|
|
|
|
type: 'forward',
|
|
|
|
target: {
|
|
|
|
host: config.destinationIps,
|
|
|
|
port: config.destinationPorts[0]
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
mode: 'terminate',
|
|
|
|
certificate: {
|
|
|
|
key: config.privateKey,
|
|
|
|
cert: config.publicKey
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
name: `Legacy Config - ${config.hostName}`,
|
|
|
|
enabled: true
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
this.setRoutes(routes);
|
|
|
|
}
|
|
|
|
}
|