smartproxy/ts/classes.router.ts

351 lines
11 KiB
TypeScript
Raw Permalink Normal View History

import * as http from 'http';
import * as url from 'url';
import * as tsclass from '@tsclass/tsclass';
2019-08-20 18:42:52 +02:00
/**
* 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;
}
2022-07-29 00:49:46 +02:00
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);
}
}
2019-08-20 19:02:13 +02:00
2019-08-22 12:49:29 +02:00
/**
* Sets a new set of reverse configs to be routed to
* @param reverseCandidatesArg Array of reverse proxy configurations
2019-08-22 12:49:29 +02:00
*/
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)`);
2019-08-22 12:49:29 +02:00
}
2019-09-23 19:20:53 +02:00
/**
* 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
2019-09-23 19:20:53 +02:00
*/
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
2019-08-22 12:49:29 +02:00
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 wildcard subdomain
if (hostWithoutPort.includes('.')) {
const domainParts = hostWithoutPort.split('.');
if (domainParts.length > 2) {
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
const wildcardConfig = this.findConfigForHost(wildcardDomain, 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;
2019-08-20 19:02:13 +02:00
}
/**
* 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;
}
}