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:
2025-06-02 03:57:52 +00:00
parent 01e1153fb8
commit 54ffbadb86
28 changed files with 1827 additions and 1724 deletions

17
ts/core/routing/index.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Unified routing module
* Provides all routing functionality in a centralized location
*/
// Export all types
export * from './types.js';
// Export all matchers
export * from './matchers/index.js';
// Export specificity calculator
export * from './specificity.js';
// Convenience re-exports
export { matchers } from './matchers/index.js';
export { RouteSpecificity } from './specificity.js';

View File

@ -0,0 +1,119 @@
import type { IMatcher, IDomainMatchOptions } from '../types.js';
/**
* DomainMatcher provides comprehensive domain matching functionality
* Supporting exact matches, wildcards, and case-insensitive matching
*/
export class DomainMatcher implements IMatcher<boolean, IDomainMatchOptions> {
private static wildcardToRegex(pattern: string): RegExp {
// Escape special regex characters except *
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Replace * with regex equivalent
const regexPattern = escaped.replace(/\*/g, '.*');
return new RegExp(`^${regexPattern}$`, 'i');
}
/**
* Match a domain pattern against a hostname
* @param pattern The pattern to match (supports wildcards like *.example.com)
* @param hostname The hostname to test
* @param options Matching options
* @returns true if the hostname matches the pattern
*/
static match(
pattern: string,
hostname: string,
options: IDomainMatchOptions = {}
): boolean {
// Handle null/undefined cases
if (!pattern || !hostname) {
return false;
}
// Normalize inputs
const normalizedPattern = pattern.toLowerCase().trim();
const normalizedHostname = hostname.toLowerCase().trim();
// Remove trailing dots (FQDN normalization)
const cleanPattern = normalizedPattern.replace(/\.$/, '');
const cleanHostname = normalizedHostname.replace(/\.$/, '');
// Exact match (most common case)
if (cleanPattern === cleanHostname) {
return true;
}
// Wildcard matching
if (options.allowWildcards !== false && cleanPattern.includes('*')) {
const regex = this.wildcardToRegex(cleanPattern);
return regex.test(cleanHostname);
}
// No match
return false;
}
/**
* Check if a pattern contains wildcards
*/
static isWildcardPattern(pattern: string): boolean {
return pattern.includes('*');
}
/**
* Calculate the specificity of a domain pattern
* Higher values mean more specific patterns
*/
static calculateSpecificity(pattern: string): number {
if (!pattern) return 0;
let score = 0;
// Exact domains are most specific
if (!pattern.includes('*')) {
score += 100;
}
// Count domain segments
const segments = pattern.split('.');
score += segments.length * 10;
// Penalize wildcards based on position
if (pattern.startsWith('*')) {
score -= 50; // Leading wildcard is very generic
} else if (pattern.includes('*')) {
score -= 20; // Wildcard elsewhere is less generic
}
// Bonus for longer patterns
score += pattern.length;
return score;
}
/**
* Find all matching patterns from a list
* Returns patterns sorted by specificity (most specific first)
*/
static findAllMatches(
patterns: string[],
hostname: string,
options: IDomainMatchOptions = {}
): string[] {
const matches = patterns.filter(pattern =>
this.match(pattern, hostname, options)
);
// Sort by specificity (highest first)
return matches.sort((a, b) =>
this.calculateSpecificity(b) - this.calculateSpecificity(a)
);
}
/**
* Instance method for interface compliance
*/
match(pattern: string, hostname: string, options?: IDomainMatchOptions): boolean {
return DomainMatcher.match(pattern, hostname, options);
}
}

View File

@ -0,0 +1,120 @@
import type { IMatcher, IHeaderMatchOptions } from '../types.js';
/**
* HeaderMatcher provides HTTP header matching functionality
* Supporting exact matches, patterns, and case-insensitive matching
*/
export class HeaderMatcher implements IMatcher<boolean, IHeaderMatchOptions> {
/**
* Match a header value against a pattern
* @param pattern The pattern to match
* @param value The header value to test
* @param options Matching options
* @returns true if the value matches the pattern
*/
static match(
pattern: string,
value: string | undefined,
options: IHeaderMatchOptions = {}
): boolean {
// Handle missing header
if (value === undefined || value === null) {
return pattern === '' || pattern === null || pattern === undefined;
}
// Convert to string and normalize
const normalizedPattern = String(pattern);
const normalizedValue = String(value);
// Apply case sensitivity
const comparePattern = options.caseInsensitive !== false
? normalizedPattern.toLowerCase()
: normalizedPattern;
const compareValue = options.caseInsensitive !== false
? normalizedValue.toLowerCase()
: normalizedValue;
// Exact match
if (options.exactMatch !== false) {
return comparePattern === compareValue;
}
// Pattern matching (simple wildcard support)
if (comparePattern.includes('*')) {
const regex = new RegExp(
'^' + comparePattern.replace(/\*/g, '.*') + '$',
options.caseInsensitive !== false ? 'i' : ''
);
return regex.test(normalizedValue);
}
// Contains match (if not exact match mode)
return compareValue.includes(comparePattern);
}
/**
* Match multiple headers against a set of required headers
* @param requiredHeaders Headers that must match
* @param actualHeaders Actual request headers
* @param options Matching options
* @returns true if all required headers match
*/
static matchAll(
requiredHeaders: Record<string, string>,
actualHeaders: Record<string, string | string[] | undefined>,
options: IHeaderMatchOptions = {}
): boolean {
for (const [name, pattern] of Object.entries(requiredHeaders)) {
const headerName = options.caseInsensitive !== false
? name.toLowerCase()
: name;
// Find the actual header (case-insensitive search if needed)
let actualValue: string | undefined;
if (options.caseInsensitive !== false) {
const actualKey = Object.keys(actualHeaders).find(
key => key.toLowerCase() === headerName
);
const rawValue = actualKey ? actualHeaders[actualKey] : undefined;
// Handle array values (multiple headers with same name)
actualValue = Array.isArray(rawValue) ? rawValue.join(', ') : rawValue;
} else {
const rawValue = actualHeaders[name];
// Handle array values (multiple headers with same name)
actualValue = Array.isArray(rawValue) ? rawValue.join(', ') : rawValue;
}
// Check if this header matches
if (!this.match(pattern, actualValue, options)) {
return false;
}
}
return true;
}
/**
* Calculate the specificity of header requirements
* More headers = more specific
*/
static calculateSpecificity(headers: Record<string, string>): number {
const count = Object.keys(headers).length;
let score = count * 10;
// Bonus for headers without wildcards (more specific)
for (const value of Object.values(headers)) {
if (!value.includes('*')) {
score += 5;
}
}
return score;
}
/**
* Instance method for interface compliance
*/
match(pattern: string, value: string, options?: IHeaderMatchOptions): boolean {
return HeaderMatcher.match(pattern, value, options);
}
}

View File

@ -0,0 +1,22 @@
/**
* Unified matching utilities for the routing system
* All route matching logic should use these matchers for consistency
*/
export * from './domain.js';
export * from './path.js';
export * from './ip.js';
export * from './header.js';
// Re-export for convenience
import { DomainMatcher } from './domain.js';
import { PathMatcher } from './path.js';
import { IpMatcher } from './ip.js';
import { HeaderMatcher } from './header.js';
export const matchers = {
domain: DomainMatcher,
path: PathMatcher,
ip: IpMatcher,
header: HeaderMatcher
} as const;

View File

@ -0,0 +1,207 @@
import type { IMatcher, IIpMatchOptions } from '../types.js';
/**
* IpMatcher provides comprehensive IP address matching functionality
* Supporting exact matches, CIDR notation, ranges, and wildcards
*/
export class IpMatcher implements IMatcher<boolean, IIpMatchOptions> {
/**
* Check if a value is a valid IPv4 address
*/
static isValidIpv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
return parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255 && part === num.toString();
});
}
/**
* Check if a value is a valid IPv6 address (simplified check)
*/
static isValidIpv6(ip: string): boolean {
// Basic IPv6 validation - can be enhanced
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|(([0-9a-fA-F]{1,4}:){1,7}|:):|(([0-9a-fA-F]{1,4}:){1,6}|::):[0-9a-fA-F]{1,4})$/;
return ipv6Regex.test(ip);
}
/**
* Convert IP address to numeric value for comparison
*/
private static ipToNumber(ip: string): number {
const parts = ip.split('.');
return parts.reduce((acc, part, index) => {
return acc + (parseInt(part, 10) << (8 * (3 - index)));
}, 0);
}
/**
* Match an IP against a CIDR notation pattern
*/
static matchCidr(cidr: string, ip: string): boolean {
const [range, bits] = cidr.split('/');
if (!bits || !this.isValidIpv4(range) || !this.isValidIpv4(ip)) {
return false;
}
const rangeMask = parseInt(bits, 10);
if (isNaN(rangeMask) || rangeMask < 0 || rangeMask > 32) {
return false;
}
const rangeNum = this.ipToNumber(range);
const ipNum = this.ipToNumber(ip);
const mask = (-1 << (32 - rangeMask)) >>> 0;
return (rangeNum & mask) === (ipNum & mask);
}
/**
* Match an IP against a wildcard pattern
*/
static matchWildcard(pattern: string, ip: string): boolean {
if (!this.isValidIpv4(ip)) return false;
const patternParts = pattern.split('.');
const ipParts = ip.split('.');
if (patternParts.length !== 4) return false;
return patternParts.every((part, index) => {
if (part === '*') return true;
return part === ipParts[index];
});
}
/**
* Match an IP against a range (e.g., "192.168.1.1-192.168.1.100")
*/
static matchRange(range: string, ip: string): boolean {
const [start, end] = range.split('-').map(s => s.trim());
if (!start || !end || !this.isValidIpv4(start) || !this.isValidIpv4(end) || !this.isValidIpv4(ip)) {
return false;
}
const startNum = this.ipToNumber(start);
const endNum = this.ipToNumber(end);
const ipNum = this.ipToNumber(ip);
return ipNum >= startNum && ipNum <= endNum;
}
/**
* Match an IP pattern against an IP address
* Supports multiple formats:
* - Exact match: "192.168.1.1"
* - CIDR: "192.168.1.0/24"
* - Wildcard: "192.168.1.*"
* - Range: "192.168.1.1-192.168.1.100"
*/
static match(
pattern: string,
ip: string,
options: IIpMatchOptions = {}
): boolean {
// Handle null/undefined cases
if (!pattern || !ip) {
return false;
}
// Normalize inputs
const normalizedPattern = pattern.trim();
const normalizedIp = ip.trim();
// Extract IPv4 from IPv6-mapped addresses (::ffff:192.168.1.1)
const ipv4Match = normalizedIp.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/i);
const testIp = ipv4Match ? ipv4Match[1] : normalizedIp;
// Exact match
if (normalizedPattern === testIp) {
return true;
}
// CIDR notation
if (options.allowCidr !== false && normalizedPattern.includes('/')) {
return this.matchCidr(normalizedPattern, testIp);
}
// Wildcard matching
if (normalizedPattern.includes('*')) {
return this.matchWildcard(normalizedPattern, testIp);
}
// Range matching
if (options.allowRanges !== false && normalizedPattern.includes('-')) {
return this.matchRange(normalizedPattern, testIp);
}
return false;
}
/**
* Check if an IP is authorized based on allow and block lists
*/
static isAuthorized(
ip: string,
allowList: string[] = [],
blockList: string[] = []
): boolean {
// If IP is in block list, deny
if (blockList.some(pattern => this.match(pattern, ip))) {
return false;
}
// If allow list is empty, allow all (except blocked)
if (allowList.length === 0) {
return true;
}
// If allow list exists, IP must match
return allowList.some(pattern => this.match(pattern, ip));
}
/**
* Calculate the specificity of an IP pattern
* Higher values mean more specific patterns
*/
static calculateSpecificity(pattern: string): number {
if (!pattern) return 0;
let score = 0;
// Exact IPs are most specific
if (this.isValidIpv4(pattern) || this.isValidIpv6(pattern)) {
score += 100;
}
// CIDR notation
if (pattern.includes('/')) {
const [, bits] = pattern.split('/');
const maskBits = parseInt(bits, 10);
if (!isNaN(maskBits)) {
score += maskBits; // Higher mask = more specific
}
}
// Wildcard patterns
const wildcards = (pattern.match(/\*/g) || []).length;
score -= wildcards * 20; // More wildcards = less specific
// Range patterns are somewhat specific
if (pattern.includes('-')) {
score += 30;
}
return score;
}
/**
* Instance method for interface compliance
*/
match(pattern: string, ip: string, options?: IIpMatchOptions): boolean {
return IpMatcher.match(pattern, ip, options);
}
}

View File

@ -0,0 +1,178 @@
import type { IMatcher, IPathMatchResult } from '../types.js';
/**
* PathMatcher provides comprehensive path matching functionality
* Supporting exact matches, wildcards, and parameter extraction
*/
export class PathMatcher implements IMatcher<IPathMatchResult> {
/**
* Convert a path pattern to a regex and extract parameter names
* Supports:
* - Exact paths: /api/users
* - Wildcards: /api/*
* - Parameters: /api/users/:id
* - Mixed: /api/users/:id/*
*/
private static patternToRegex(pattern: string): {
regex: RegExp;
paramNames: string[]
} {
const paramNames: string[] = [];
let regexPattern = pattern;
// Escape special regex characters except : and *
regexPattern = regexPattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Handle path parameters (:param)
regexPattern = regexPattern.replace(/:(\w+)/g, (match, paramName) => {
paramNames.push(paramName);
return '([^/]+)'; // Match any non-slash characters
});
// Handle wildcards
regexPattern = regexPattern.replace(/\*/g, '(.*)');
// Ensure the pattern matches from start
regexPattern = `^${regexPattern}`;
return {
regex: new RegExp(regexPattern),
paramNames
};
}
/**
* Match a path pattern against a request path
* @param pattern The pattern to match
* @param path The request path to test
* @returns Match result with params and remainder
*/
static match(pattern: string, path: string): IPathMatchResult {
// Handle null/undefined cases
if (!pattern || !path) {
return { matches: false };
}
// Normalize paths (remove trailing slashes unless it's just "/")
const normalizedPattern = pattern === '/' ? '/' : pattern.replace(/\/$/, '');
const normalizedPath = path === '/' ? '/' : path.replace(/\/$/, '');
// Exact match (most common case)
if (normalizedPattern === normalizedPath) {
return {
matches: true,
pathMatch: normalizedPath,
pathRemainder: '',
params: {}
};
}
// Pattern matching (wildcards and parameters)
const { regex, paramNames } = this.patternToRegex(normalizedPattern);
const match = normalizedPath.match(regex);
if (!match) {
return { matches: false };
}
// Extract parameters
const params: Record<string, string> = {};
paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
// Calculate path match and remainder
let pathMatch = match[0];
let pathRemainder = normalizedPath.substring(pathMatch.length);
// Handle wildcard captures
if (normalizedPattern.includes('*') && match.length > paramNames.length + 1) {
const wildcardCapture = match[match.length - 1];
if (wildcardCapture) {
pathRemainder = wildcardCapture;
pathMatch = normalizedPath.substring(0, normalizedPath.length - wildcardCapture.length);
}
}
// Clean up path match (remove trailing slash if present)
if (pathMatch !== '/' && pathMatch.endsWith('/')) {
pathMatch = pathMatch.slice(0, -1);
}
return {
matches: true,
pathMatch,
pathRemainder,
params
};
}
/**
* Check if a pattern contains parameters or wildcards
*/
static isDynamicPattern(pattern: string): boolean {
return pattern.includes(':') || pattern.includes('*');
}
/**
* Calculate the specificity of a path pattern
* Higher values mean more specific patterns
*/
static calculateSpecificity(pattern: string): number {
if (!pattern) return 0;
let score = 0;
// Exact paths are most specific
if (!this.isDynamicPattern(pattern)) {
score += 100;
}
// Count path segments
const segments = pattern.split('/').filter(s => s.length > 0);
score += segments.length * 10;
// Count static segments (more static = more specific)
const staticSegments = segments.filter(s => !s.startsWith(':') && s !== '*');
score += staticSegments.length * 20;
// Penalize wildcards and parameters
const wildcards = (pattern.match(/\*/g) || []).length;
const params = (pattern.match(/:/g) || []).length;
score -= wildcards * 30; // Wildcards are very generic
score -= params * 10; // Parameters are somewhat generic
// Bonus for longer patterns
score += pattern.length;
return score;
}
/**
* Find all matching patterns from a list
* Returns patterns sorted by specificity (most specific first)
*/
static findAllMatches(patterns: string[], path: string): Array<{
pattern: string;
result: IPathMatchResult;
}> {
const matches = patterns
.map(pattern => ({
pattern,
result: this.match(pattern, path)
}))
.filter(({ result }) => result.matches);
// Sort by specificity (highest first)
return matches.sort((a, b) =>
this.calculateSpecificity(b.pattern) - this.calculateSpecificity(a.pattern)
);
}
/**
* Instance method for interface compliance
*/
match(pattern: string, path: string): IPathMatchResult {
return PathMatcher.match(pattern, path);
}
}

View File

@ -0,0 +1,141 @@
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
import type { IRouteSpecificity } from './types.js';
import { DomainMatcher, PathMatcher, IpMatcher, HeaderMatcher } from './matchers/index.js';
/**
* Unified route specificity calculator
* Provides consistent specificity scoring across all routing components
*/
export class RouteSpecificity {
/**
* Calculate the total specificity score for a route
* Higher scores indicate more specific routes that should match first
*/
static calculate(route: IRouteConfig): IRouteSpecificity {
const specificity: IRouteSpecificity = {
pathSpecificity: 0,
domainSpecificity: 0,
ipSpecificity: 0,
headerSpecificity: 0,
tlsSpecificity: 0,
totalScore: 0
};
// Path specificity
if (route.match.path) {
specificity.pathSpecificity = PathMatcher.calculateSpecificity(route.match.path);
}
// Domain specificity
if (route.match.domains) {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Use the highest specificity among all domains
specificity.domainSpecificity = Math.max(
...domains.map(d => DomainMatcher.calculateSpecificity(d))
);
}
// IP specificity (clientIp is an array of IPs)
if (route.match.clientIp && route.match.clientIp.length > 0) {
// Use the first IP pattern for specificity calculation
specificity.ipSpecificity = IpMatcher.calculateSpecificity(route.match.clientIp[0]);
}
// Header specificity (convert RegExp values to strings)
if (route.match.headers) {
const stringHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(route.match.headers)) {
stringHeaders[key] = value instanceof RegExp ? value.source : value;
}
specificity.headerSpecificity = HeaderMatcher.calculateSpecificity(stringHeaders);
}
// TLS version specificity
if (route.match.tlsVersion && route.match.tlsVersion.length > 0) {
specificity.tlsSpecificity = route.match.tlsVersion.length * 10;
}
// Calculate total score with weights
specificity.totalScore =
specificity.pathSpecificity * 3 + // Path is most important
specificity.domainSpecificity * 2 + // Domain is second
specificity.ipSpecificity * 1.5 + // IP is moderately important
specificity.headerSpecificity * 1 + // Headers are less important
specificity.tlsSpecificity * 0.5; // TLS is least important
return specificity;
}
/**
* Compare two routes and determine which is more specific
* @returns positive if route1 is more specific, negative if route2 is more specific, 0 if equal
*/
static compare(route1: IRouteConfig, route2: IRouteConfig): number {
const spec1 = this.calculate(route1);
const spec2 = this.calculate(route2);
// First compare by total score
if (spec1.totalScore !== spec2.totalScore) {
return spec1.totalScore - spec2.totalScore;
}
// If total scores are equal, compare by individual components
// Path is most important tiebreaker
if (spec1.pathSpecificity !== spec2.pathSpecificity) {
return spec1.pathSpecificity - spec2.pathSpecificity;
}
// Then domain
if (spec1.domainSpecificity !== spec2.domainSpecificity) {
return spec1.domainSpecificity - spec2.domainSpecificity;
}
// Then IP
if (spec1.ipSpecificity !== spec2.ipSpecificity) {
return spec1.ipSpecificity - spec2.ipSpecificity;
}
// Then headers
if (spec1.headerSpecificity !== spec2.headerSpecificity) {
return spec1.headerSpecificity - spec2.headerSpecificity;
}
// Finally TLS
return spec1.tlsSpecificity - spec2.tlsSpecificity;
}
/**
* Sort routes by specificity (most specific first)
*/
static sort(routes: IRouteConfig[]): IRouteConfig[] {
return [...routes].sort((a, b) => this.compare(b, a));
}
/**
* Find the most specific route from a list
*/
static findMostSpecific(routes: IRouteConfig[]): IRouteConfig | null {
if (routes.length === 0) return null;
return routes.reduce((most, current) =>
this.compare(current, most) > 0 ? current : most
);
}
/**
* Check if a route has any matching criteria
*/
static hasMatchCriteria(route: IRouteConfig): boolean {
const match = route.match;
return !!(
match.domains ||
match.path ||
match.clientIp?.length ||
match.headers ||
match.tlsVersion?.length
);
}
}

49
ts/core/routing/types.ts Normal file
View File

@ -0,0 +1,49 @@
/**
* Core routing types used throughout the routing system
*/
export interface IPathMatchResult {
matches: boolean;
params?: Record<string, string>;
pathMatch?: string;
pathRemainder?: string;
}
export interface IRouteMatchResult {
matches: boolean;
score: number;
specificity: number;
matchedCriteria: string[];
}
export interface IDomainMatchOptions {
allowWildcards?: boolean;
caseInsensitive?: boolean;
}
export interface IIpMatchOptions {
allowCidr?: boolean;
allowRanges?: boolean;
}
export interface IHeaderMatchOptions {
caseInsensitive?: boolean;
exactMatch?: boolean;
}
export interface IRouteSpecificity {
pathSpecificity: number;
domainSpecificity: number;
ipSpecificity: number;
headerSpecificity: number;
tlsSpecificity: number;
totalScore: number;
}
export interface IMatcher<T = any, O = any> {
match(pattern: string, value: string, options?: O): T | boolean;
}
export interface IAsyncMatcher<T = any, O = any> {
match(pattern: string, value: string, options?: O): Promise<T | boolean>;
}