432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import * as http from 'http';
|
|
import * as url from 'url';
|
|
import * as tsclass from '@tsclass/tsclass';
|
|
|
|
/**
|
|
* Optional path pattern configuration that can be added to proxy configs
|
|
*/
|
|
export interface IPathPatternConfig {
|
|
pathPattern?: string;
|
|
}
|
|
|
|
/**
|
|
* Interface for router result with additional metadata
|
|
*/
|
|
export interface IRouterResult {
|
|
config: tsclass.network.IReverseProxyConfig;
|
|
pathMatch?: string;
|
|
pathParams?: Record<string, string>;
|
|
pathRemainder?: string;
|
|
}
|
|
|
|
/**
|
|
* Router for HTTP reverse proxy requests
|
|
*
|
|
* 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 ProxyRouter {
|
|
// Store original configs for reference
|
|
private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = [];
|
|
// Default config to use when no match is found (optional)
|
|
private defaultConfig?: tsclass.network.IReverseProxyConfig;
|
|
// Store path patterns separately since they're not in the original interface
|
|
private pathPatterns: Map<tsclass.network.IReverseProxyConfig, string> = new Map();
|
|
// Logger interface
|
|
private logger: {
|
|
error: (message: string, data?: any) => void;
|
|
warn: (message: string, data?: any) => void;
|
|
info: (message: string, data?: any) => void;
|
|
debug: (message: string, data?: any) => void;
|
|
};
|
|
|
|
constructor(
|
|
configs?: tsclass.network.IReverseProxyConfig[],
|
|
logger?: {
|
|
error: (message: string, data?: any) => void;
|
|
warn: (message: string, data?: any) => void;
|
|
info: (message: string, data?: any) => void;
|
|
debug: (message: string, data?: any) => void;
|
|
}
|
|
) {
|
|
this.logger = logger || console;
|
|
if (configs) {
|
|
this.setNewProxyConfigs(configs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets a new set of reverse configs to be routed to
|
|
* @param reverseCandidatesArg Array of reverse proxy configurations
|
|
*/
|
|
public setNewProxyConfigs(reverseCandidatesArg: tsclass.network.IReverseProxyConfig[]): void {
|
|
this.reverseProxyConfigs = [...reverseCandidatesArg];
|
|
|
|
// Find default config if any (config with "*" as hostname)
|
|
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
|
|
|
|
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`);
|
|
}
|
|
|
|
/**
|
|
* Routes a request based on hostname and path
|
|
* @param req The incoming HTTP request
|
|
* @returns The matching proxy config or undefined if no match found
|
|
*/
|
|
public routeReq(req: http.IncomingMessage): tsclass.network.IReverseProxyConfig {
|
|
const result = this.routeReqWithDetails(req);
|
|
return result ? result.config : undefined;
|
|
}
|
|
|
|
/**
|
|
* Routes a request with detailed matching information
|
|
* @param req The incoming HTTP request
|
|
* @returns Detailed routing result including matched config and path information
|
|
*/
|
|
public routeReqWithDetails(req: http.IncomingMessage): IRouterResult | undefined {
|
|
// Extract and validate host header
|
|
const originalHost = req.headers.host;
|
|
if (!originalHost) {
|
|
this.logger.error('No host header found in request');
|
|
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
|
|
}
|
|
|
|
// Parse URL for path matching
|
|
const parsedUrl = url.parse(req.url || '/');
|
|
const urlPath = parsedUrl.pathname || '/';
|
|
|
|
// Extract hostname without port
|
|
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
|
|
|
// First try exact hostname match
|
|
const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath);
|
|
if (exactConfig) {
|
|
return exactConfig;
|
|
}
|
|
|
|
// 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 wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
|
|
if (wildcardConfig) {
|
|
return wildcardConfig;
|
|
}
|
|
}
|
|
|
|
// Try TLD wildcard (example.*)
|
|
const baseDomain = domainParts.slice(0, -1).join('.');
|
|
const tldWildcardDomain = `${baseDomain}.*`;
|
|
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
|
|
if (tldWildcardConfig) {
|
|
return tldWildcardConfig;
|
|
}
|
|
|
|
// Try complex wildcard patterns
|
|
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
|
for (const pattern of wildcardPatterns) {
|
|
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
|
|
if (wildcardConfig) {
|
|
return wildcardConfig;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to default config if available
|
|
if (this.defaultConfig) {
|
|
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
|
|
return { config: this.defaultConfig };
|
|
}
|
|
|
|
this.logger.error(`No config 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[] = [];
|
|
const hostnameParts = hostname.split('.');
|
|
|
|
// Find all configured hostnames that contain wildcards
|
|
const wildcardConfigs = this.reverseProxyConfigs.filter(
|
|
config => config.hostName.includes('*')
|
|
);
|
|
|
|
// Extract unique wildcard patterns
|
|
const wildcardPatterns = [...new Set(
|
|
wildcardConfigs.map(config => config.hostName.toLowerCase())
|
|
)];
|
|
|
|
// For each wildcard pattern, check if it could match the hostname
|
|
// using simplified regex pattern matching
|
|
for (const pattern of wildcardPatterns) {
|
|
// Skip the default wildcard '*'
|
|
if (pattern === '*') continue;
|
|
|
|
// Skip already checked patterns (*.domain.com and domain.*)
|
|
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
|
|
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
|
|
|
|
// Convert wildcard pattern to regex
|
|
const regexPattern = pattern
|
|
.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(pattern);
|
|
}
|
|
}
|
|
|
|
return patterns;
|
|
}
|
|
|
|
/**
|
|
* Find a config for a specific host and path
|
|
*/
|
|
private findConfigForHost(hostname: string, path: string): IRouterResult | undefined {
|
|
// Find all configs for this hostname
|
|
const configs = this.reverseProxyConfigs.filter(
|
|
config => config.hostName.toLowerCase() === hostname.toLowerCase()
|
|
);
|
|
|
|
if (configs.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// First try configs with path patterns
|
|
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
|
|
|
|
// Sort by path pattern specificity - more specific first
|
|
configsWithPaths.sort((a, b) => {
|
|
const aPattern = this.pathPatterns.get(a) || '';
|
|
const bPattern = this.pathPatterns.get(b) || '';
|
|
|
|
// Exact patterns come before wildcard patterns
|
|
const aHasWildcard = aPattern.includes('*');
|
|
const bHasWildcard = bPattern.includes('*');
|
|
|
|
if (aHasWildcard && !bHasWildcard) return 1;
|
|
if (!aHasWildcard && bHasWildcard) return -1;
|
|
|
|
// Longer patterns are considered more specific
|
|
return bPattern.length - aPattern.length;
|
|
});
|
|
|
|
// Check each config with path pattern
|
|
for (const config of configsWithPaths) {
|
|
const pathPattern = this.pathPatterns.get(config);
|
|
if (pathPattern) {
|
|
const pathMatch = this.matchPath(path, pathPattern);
|
|
if (pathMatch) {
|
|
return {
|
|
config,
|
|
pathMatch: pathMatch.matched,
|
|
pathParams: pathMatch.params,
|
|
pathRemainder: pathMatch.remainder
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no path pattern matched, use the first config without a path pattern
|
|
const configWithoutPath = configs.find(config => !this.pathPatterns.has(config));
|
|
if (configWithoutPath) {
|
|
return { config: configWithoutPath };
|
|
}
|
|
|
|
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 proxy configurations
|
|
* @returns Array of all active configurations
|
|
*/
|
|
public getProxyConfigs(): tsclass.network.IReverseProxyConfig[] {
|
|
return [...this.reverseProxyConfigs];
|
|
}
|
|
|
|
/**
|
|
* Gets all hostnames that this router is configured to handle
|
|
* @returns Array of hostnames
|
|
*/
|
|
public getHostnames(): string[] {
|
|
const hostnames = new Set<string>();
|
|
for (const config of this.reverseProxyConfigs) {
|
|
if (config.hostName !== '*') {
|
|
hostnames.add(config.hostName.toLowerCase());
|
|
}
|
|
}
|
|
return Array.from(hostnames);
|
|
}
|
|
|
|
/**
|
|
* Adds a single new proxy configuration
|
|
* @param config The configuration to add
|
|
* @param pathPattern Optional path pattern for route matching
|
|
*/
|
|
public addProxyConfig(
|
|
config: tsclass.network.IReverseProxyConfig,
|
|
pathPattern?: string
|
|
): void {
|
|
this.reverseProxyConfigs.push(config);
|
|
|
|
// Store path pattern if provided
|
|
if (pathPattern) {
|
|
this.pathPatterns.set(config, pathPattern);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets a path pattern for an existing config
|
|
* @param config The existing configuration
|
|
* @param pathPattern The path pattern to set
|
|
* @returns Boolean indicating if the config was found and updated
|
|
*/
|
|
public setPathPattern(
|
|
config: tsclass.network.IReverseProxyConfig,
|
|
pathPattern: string
|
|
): boolean {
|
|
const exists = this.reverseProxyConfigs.includes(config);
|
|
if (exists) {
|
|
this.pathPatterns.set(config, pathPattern);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Removes a proxy configuration by hostname
|
|
* @param hostname The hostname to remove
|
|
* @returns Boolean indicating whether any configs were removed
|
|
*/
|
|
public removeProxyConfig(hostname: string): boolean {
|
|
const initialCount = this.reverseProxyConfigs.length;
|
|
|
|
// Find configs to remove
|
|
const configsToRemove = this.reverseProxyConfigs.filter(
|
|
config => config.hostName === hostname
|
|
);
|
|
|
|
// Remove them from the patterns map
|
|
for (const config of configsToRemove) {
|
|
this.pathPatterns.delete(config);
|
|
}
|
|
|
|
// Filter them out of the configs array
|
|
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
|
|
config => config.hostName !== hostname
|
|
);
|
|
|
|
return this.reverseProxyConfigs.length !== initialCount;
|
|
}
|
|
} |