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>;
}

View File

@ -12,7 +12,6 @@ import {
matchPath,
matchIpPattern,
matchIpCidr,
ipToNumber,
isIpAuthorized,
calculateRouteSpecificity
} from './route-utils.js';
@ -343,13 +342,6 @@ export class SharedRouteManager extends plugins.EventEmitter {
return matchIpCidr(cidr, ip);
}
/**
* Convert an IP address to a numeric value
* @deprecated Use the ipToNumber function from route-utils.js instead
*/
private ipToNumber(ip: string): number {
return ipToNumber(ip);
}
/**
* Validate the route configuration and return any warnings

View File

@ -1,34 +1,21 @@
/**
* Route matching utilities for SmartProxy components
*
* Contains shared logic for domain matching, path matching, and IP matching
* to be used by different proxy components throughout the system.
* This file provides utility functions that use the unified matchers
* and additional route-specific utilities.
*/
import { DomainMatcher, PathMatcher, IpMatcher, HeaderMatcher } from '../routing/matchers/index.js';
import { RouteSpecificity } from '../routing/specificity.js';
import type { IRouteSpecificity } from '../routing/types.js';
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
/**
* Match a domain pattern against a domain
*
* @param pattern Domain pattern with optional wildcards (e.g., "*.example.com")
* @param domain Domain to match against the pattern
* @returns Whether the domain matches the pattern
* @deprecated Use DomainMatcher.match() directly
*/
export function matchDomain(pattern: string, domain: string): boolean {
// Handle exact match (case-insensitive)
if (pattern.toLowerCase() === domain.toLowerCase()) {
return true;
}
// Handle wildcard pattern
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*'); // Convert * to .*
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(domain);
}
return false;
return DomainMatcher.match(pattern, domain);
}
/**
@ -55,211 +42,50 @@ export function matchRouteDomain(domains: string | string[] | undefined, domain:
/**
* Match a path pattern against a path
*
* @param pattern Path pattern with optional wildcards
* @param path Path to match against the pattern
* @returns Whether the path matches the pattern
* @deprecated Use PathMatcher.match() directly
*/
export function matchPath(pattern: string, path: string): boolean {
// Handle exact match
if (pattern === path) {
return true;
}
// Handle simple wildcard at the end (like /api/*)
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
return path.startsWith(prefix);
}
// Handle more complex wildcard patterns
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*') // Convert * to .*
.replace(/\//g, '\\/'); // Escape slashes
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
return false;
return PathMatcher.match(pattern, path).matches;
}
/**
* Parse CIDR notation into subnet and mask bits
*
* @param cidr CIDR string (e.g., "192.168.1.0/24")
* @returns Object with subnet and bits, or null if invalid
*/
export function parseCidr(cidr: string): { subnet: string; bits: number } | null {
try {
const [subnet, bitsStr] = cidr.split('/');
const bits = parseInt(bitsStr, 10);
if (isNaN(bits) || bits < 0 || bits > 32) {
return null;
}
return { subnet, bits };
} catch (e) {
return null;
}
}
/**
* Convert an IP address to a numeric value
*
* @param ip IPv4 address string (e.g., "192.168.1.1")
* @returns Numeric representation of the IP
*/
export function ipToNumber(ip: string): number {
// Handle IPv6-mapped IPv4 addresses (::ffff:192.168.1.1)
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7);
}
const parts = ip.split('.').map(part => parseInt(part, 10));
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
}
// Helper functions removed - use IpMatcher internal methods instead
/**
* Match an IP against a CIDR pattern
*
* @param cidr CIDR pattern (e.g., "192.168.1.0/24")
* @param ip IP to match against the pattern
* @returns Whether the IP is in the CIDR range
* @deprecated Use IpMatcher.matchCidr() directly
*/
export function matchIpCidr(cidr: string, ip: string): boolean {
const parsed = parseCidr(cidr);
if (!parsed) {
return false;
}
try {
const { subnet, bits } = parsed;
// Normalize IPv6-mapped IPv4 addresses
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet;
// Convert IP addresses to numeric values
const ipNum = ipToNumber(normalizedIp);
const subnetNum = ipToNumber(normalizedSubnet);
// Calculate subnet mask
const maskNum = ~(2 ** (32 - bits) - 1);
// Check if IP is in subnet
return (ipNum & maskNum) === (subnetNum & maskNum);
} catch (e) {
return false;
}
return IpMatcher.matchCidr(cidr, ip);
}
/**
* Match an IP pattern against an IP
*
* @param pattern IP pattern (exact, CIDR, or with wildcards)
* @param ip IP to match against the pattern
* @returns Whether the IP matches the pattern
* @deprecated Use IpMatcher.match() directly
*/
export function matchIpPattern(pattern: string, ip: string): boolean {
// Normalize IPv6-mapped IPv4 addresses
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern;
// Handle exact match with all variations
if (pattern === ip || normalizedPattern === normalizedIp ||
pattern === normalizedIp || normalizedPattern === ip) {
return true;
}
// Handle "all" wildcard
if (pattern === '*' || normalizedPattern === '*') {
return true;
}
// Handle CIDR notation (e.g., 192.168.1.0/24)
if (pattern.includes('/')) {
return matchIpCidr(pattern, normalizedIp) ||
(normalizedPattern !== pattern && matchIpCidr(normalizedPattern, normalizedIp));
}
// Handle glob pattern (e.g., 192.168.1.*)
if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(ip) || regex.test(normalizedIp)) {
return true;
}
// If pattern was normalized, also test with normalized pattern
if (normalizedPattern !== pattern) {
const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`);
return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp);
}
}
return false;
return IpMatcher.match(pattern, ip);
}
/**
* Match an IP against allowed and blocked IP patterns
*
* @param ip IP to check
* @param ipAllowList Array of allowed IP patterns
* @param ipBlockList Array of blocked IP patterns
* @returns Whether the IP is allowed
* @deprecated Use IpMatcher.isAuthorized() directly
*/
export function isIpAuthorized(
ip: string,
ipAllowList: string[] = ['*'],
ipBlockList: string[] = []
): boolean {
// Check blocked IPs first
if (ipBlockList.length > 0) {
for (const pattern of ipBlockList) {
if (matchIpPattern(pattern, ip)) {
return false; // IP is blocked
}
}
}
// If there are allowed IPs, check them
if (ipAllowList.length > 0) {
// Special case: if '*' is in allowed IPs, all non-blocked IPs are allowed
if (ipAllowList.includes('*')) {
return true;
}
for (const pattern of ipAllowList) {
if (matchIpPattern(pattern, ip)) {
return true; // IP is allowed
}
}
return false; // IP not in allowed list
}
// No allowed IPs specified, so IP is allowed by default
return true;
return IpMatcher.isAuthorized(ip, ipAllowList, ipBlockList);
}
/**
* Match an HTTP header pattern against a header value
*
* @param pattern Expected header value (string or RegExp)
* @param value Actual header value
* @returns Whether the header matches the pattern
* @deprecated Use HeaderMatcher.match() directly
*/
export function matchHeader(pattern: string | RegExp, value: string): boolean {
if (typeof pattern === 'string') {
return pattern === value;
} else if (pattern instanceof RegExp) {
return pattern.test(value);
}
return false;
// Convert RegExp to string pattern for HeaderMatcher
const stringPattern = pattern instanceof RegExp ? pattern.source : pattern;
return HeaderMatcher.match(stringPattern, value, { exactMatch: true });
}
/**
@ -268,6 +94,7 @@ export function matchHeader(pattern: string | RegExp, value: string): boolean {
*
* @param match Match criteria to evaluate
* @returns Numeric specificity score
* @deprecated Consider using RouteSpecificity.calculate() with full IRouteConfig
*/
export function calculateRouteSpecificity(match: {
domains?: string | string[];
@ -278,34 +105,37 @@ export function calculateRouteSpecificity(match: {
}): number {
let score = 0;
// Path is very specific
// Path specificity using PathMatcher
if (match.path) {
// More specific if it doesn't use wildcards
score += match.path.includes('*') ? 3 : 4;
score += PathMatcher.calculateSpecificity(match.path);
}
// Domain is next most specific
// Domain specificity using DomainMatcher
if (match.domains) {
const domains = Array.isArray(match.domains) ? match.domains : [match.domains];
// More domains or more specific domains (without wildcards) increase specificity
score += domains.length;
// Add bonus for exact domains (without wildcards)
score += domains.some(d => !d.includes('*')) ? 1 : 0;
// Use the highest specificity among all domains
const domainScore = Math.max(...domains.map(d => DomainMatcher.calculateSpecificity(d)));
score += domainScore;
}
// Headers are quite specific
// Headers specificity using HeaderMatcher
if (match.headers) {
score += Object.keys(match.headers).length * 2;
const stringHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(match.headers)) {
stringHeaders[key] = value instanceof RegExp ? value.source : value;
}
score += HeaderMatcher.calculateSpecificity(stringHeaders);
}
// Client IP adds some specificity
if (match.clientIp && match.clientIp.length > 0) {
score += 1;
// Use the first IP pattern for specificity
score += IpMatcher.calculateSpecificity(match.clientIp[0]);
}
// TLS version adds minimal specificity
if (match.tlsVersion && match.tlsVersion.length > 0) {
score += 1;
score += match.tlsVersion.length * 10;
}
return score;

View File

@ -1,9 +1,5 @@
import * as plugins from '../../plugins.js';
import {
matchIpPattern,
ipToNumber,
matchIpCidr
} from './route-utils.js';
import { IpMatcher } from '../routing/matchers/ip.js';
/**
* Security utilities for IP validation, rate limiting,
@ -90,7 +86,7 @@ export function isIPAuthorized(
// First check if IP is blocked - blocked IPs take precedence
if (blockedIPs.length > 0) {
for (const pattern of blockedIPs) {
if (matchIpPattern(pattern, ip)) {
if (IpMatcher.match(pattern, ip)) {
return false;
}
}
@ -104,7 +100,7 @@ export function isIPAuthorized(
// Then check if IP is allowed in the explicit allow list
if (allowedIPs.length > 0) {
for (const pattern of allowedIPs) {
if (matchIpPattern(pattern, ip)) {
if (IpMatcher.match(pattern, ip)) {
return true;
}
}