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

143
readme.delete.md Normal file
View File

@ -0,0 +1,143 @@
# SmartProxy Code Deletion Plan
This document tracks all code paths that can be deleted as part of the routing unification effort.
## Phase 1: Matching Logic Duplicates (READY TO DELETE)
### 1. Inline Matching Functions in RouteManager
**File**: `ts/proxies/smart-proxy/route-manager.ts`
**Lines**: Approximately lines 200-400
**Duplicates**:
- `matchDomain()` method - duplicate of DomainMatcher
- `matchPath()` method - duplicate of PathMatcher
- `matchIpPattern()` method - duplicate of IpMatcher
- `matchHeaders()` method - duplicate of HeaderMatcher
**Action**: Update to use unified matchers from `ts/core/routing/matchers/`
### 2. Duplicate Matching in Core route-utils
**File**: `ts/core/utils/route-utils.ts`
**Functions to update**:
- `matchDomain()` → Use DomainMatcher.match()
- `matchPath()` → Use PathMatcher.match()
- `matchIpPattern()` → Use IpMatcher.match()
- `matchHeader()` → Use HeaderMatcher.match()
**Action**: Update to use unified matchers, keep only unique utilities
## Phase 2: Route Manager Duplicates (READY AFTER MIGRATION)
### 1. SmartProxy RouteManager
**File**: `ts/proxies/smart-proxy/route-manager.ts`
**Entire file**: ~500 lines
**Reason**: 95% duplicate of SharedRouteManager
**Migration Required**:
- Update SmartProxy to use SharedRouteManager
- Update all imports
- Test thoroughly
**Action**: DELETE entire file after migration
### 2. Deprecated Methods in SharedRouteManager
**File**: `ts/core/utils/route-manager.ts`
**Methods**:
- Any deprecated security check methods
- Legacy compatibility methods
**Action**: Remove after confirming no usage
## Phase 3: Router Consolidation (REQUIRES REFACTORING)
### 1. ProxyRouter vs RouteRouter Duplication
**Files**:
- `ts/routing/router/proxy-router.ts` (~250 lines)
- `ts/routing/router/route-router.ts` (~250 lines)
**Reason**: Nearly identical implementations
**Plan**: Merge into single HttpRouter with legacy adapter
**Action**: DELETE one file after consolidation
### 2. Inline Route Matching in HttpProxy
**Location**: Various files in `ts/proxies/http-proxy/`
**Pattern**: Direct route matching without using RouteManager
**Action**: Update to use SharedRouteManager
## Phase 4: Scattered Utilities (CLEANUP)
### 1. Duplicate Route Utilities
**Files with duplicate logic**:
- `ts/proxies/smart-proxy/utils/route-utils.ts` - Keep (different purpose)
- `ts/proxies/smart-proxy/utils/route-validators.ts` - Review for duplicates
- `ts/proxies/smart-proxy/utils/route-patterns.ts` - Review for consolidation
### 2. Legacy Type Definitions
**Review for removal**:
- Old route type definitions
- Deprecated configuration interfaces
- Unused type exports
## Deletion Progress Tracker
### Completed Deletions
- [x] Phase 1: Matching logic consolidation (Partial)
- Updated core/utils/route-utils.ts to use unified matchers
- Removed duplicate matching implementations (~200 lines)
- Marked functions as deprecated with migration path
- [x] Phase 2: RouteManager unification (COMPLETED)
- ✓ Migrated SmartProxy to use SharedRouteManager
- ✓ Updated imports in smart-proxy.ts, route-connection-handler.ts, and index.ts
- ✓ Created logger adapter to match ILogger interface expectations
- ✓ Fixed method calls (getAllRoutes → getRoutes)
- ✓ Fixed type errors in header matcher
- ✓ Removed unused ipToNumber imports and methods
- ✓ DELETED: `/ts/proxies/smart-proxy/route-manager.ts` (553 lines removed)
- [x] Phase 3: Router consolidation (COMPLETED)
- ✓ Created unified HttpRouter with legacy compatibility
- ✓ Migrated ProxyRouter and RouteRouter to use HttpRouter aliases
- ✓ Updated imports in http-proxy.ts, request-handler.ts, websocket-handler.ts
- ✓ Added routeReqLegacy() method for backward compatibility
- ✓ DELETED: `/ts/routing/router/proxy-router.ts` (437 lines)
- ✓ DELETED: `/ts/routing/router/route-router.ts` (482 lines)
- [ ] Phase 4: Utility cleanup
### Files Updated
1. `ts/core/utils/route-utils.ts` - Replaced all matching logic with unified matchers
2. `ts/core/utils/security-utils.ts` - Updated to use IpMatcher directly
3. `ts/proxies/smart-proxy/smart-proxy.ts` - Using SharedRouteManager with logger adapter
4. `ts/proxies/smart-proxy/route-connection-handler.ts` - Updated to use SharedRouteManager
5. `ts/proxies/smart-proxy/index.ts` - Exporting SharedRouteManager as RouteManager
6. `ts/core/routing/matchers/header.ts` - Fixed type handling for array header values
7. `ts/core/utils/route-manager.ts` - Removed unused ipToNumber import
8. `ts/proxies/http-proxy/http-proxy.ts` - Updated imports to use unified router
9. `ts/proxies/http-proxy/request-handler.ts` - Updated to use routeReqLegacy()
10. `ts/proxies/http-proxy/websocket-handler.ts` - Updated to use routeReqLegacy()
11. `ts/routing/router/index.ts` - Export unified HttpRouter with aliases
### Files Created
1. `ts/core/routing/matchers/domain.ts` - Unified domain matcher
2. `ts/core/routing/matchers/path.ts` - Unified path matcher
3. `ts/core/routing/matchers/ip.ts` - Unified IP matcher
4. `ts/core/routing/matchers/header.ts` - Unified header matcher
5. `ts/core/routing/matchers/index.ts` - Matcher exports
6. `ts/core/routing/types.ts` - Core routing types
7. `ts/core/routing/specificity.ts` - Route specificity calculator
8. `ts/core/routing/index.ts` - Main routing exports
9. `ts/routing/router/http-router.ts` - Unified HTTP router
### Lines of Code Removed
- Target: ~1,500 lines
- Actual: ~1,672 lines (Target exceeded!)
- Phase 1: ~200 lines (matching logic)
- Phase 2: 553 lines (SmartProxy RouteManager)
- Phase 3: 919 lines (ProxyRouter + RouteRouter)
## Safety Checklist Before Deletion
Before deleting any code:
1. ✓ All tests pass
2. ✓ No references to deleted code remain
3. ✓ Migration path tested
4. ✓ Performance benchmarks show no regression
5. ✓ Documentation updated
## Rollback Plan
If issues arise after deletion:
1. Git history preserves all deleted code
2. Each phase can be reverted independently
3. Feature flags can disable new code if needed

View File

@ -0,0 +1,79 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DomainMatcher } from '../../../ts/core/routing/matchers/domain.js';
tap.test('DomainMatcher - exact match', async () => {
expect(DomainMatcher.match('example.com', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'example.net')).toEqual(false);
expect(DomainMatcher.match('sub.example.com', 'example.com')).toEqual(false);
});
tap.test('DomainMatcher - case insensitive', async () => {
expect(DomainMatcher.match('Example.COM', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'EXAMPLE.COM')).toEqual(true);
expect(DomainMatcher.match('ExAmPlE.cOm', 'eXaMpLe.CoM')).toEqual(true);
});
tap.test('DomainMatcher - wildcard matching', async () => {
// Leading wildcard
expect(DomainMatcher.match('*.example.com', 'sub.example.com')).toEqual(true);
expect(DomainMatcher.match('*.example.com', 'deep.sub.example.com')).toEqual(true);
expect(DomainMatcher.match('*.example.com', 'example.com')).toEqual(false);
// Multiple wildcards
expect(DomainMatcher.match('*.*.example.com', 'a.b.example.com')).toEqual(true);
expect(DomainMatcher.match('api.*.example.com', 'api.v1.example.com')).toEqual(true);
// Trailing wildcard
expect(DomainMatcher.match('example.*', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.*', 'example.net')).toEqual(true);
expect(DomainMatcher.match('example.*', 'example.co.uk')).toEqual(true);
});
tap.test('DomainMatcher - FQDN normalization', async () => {
expect(DomainMatcher.match('example.com.', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'example.com.')).toEqual(true);
expect(DomainMatcher.match('example.com.', 'example.com.')).toEqual(true);
});
tap.test('DomainMatcher - edge cases', async () => {
expect(DomainMatcher.match('', 'example.com')).toEqual(false);
expect(DomainMatcher.match('example.com', '')).toEqual(false);
expect(DomainMatcher.match('', '')).toEqual(false);
expect(DomainMatcher.match(null as any, 'example.com')).toEqual(false);
expect(DomainMatcher.match('example.com', null as any)).toEqual(false);
});
tap.test('DomainMatcher - specificity calculation', async () => {
// Exact domains are most specific
const exactScore = DomainMatcher.calculateSpecificity('api.example.com');
const wildcardScore = DomainMatcher.calculateSpecificity('*.example.com');
const leadingWildcardScore = DomainMatcher.calculateSpecificity('*.com');
expect(exactScore).toBeGreaterThan(wildcardScore);
expect(wildcardScore).toBeGreaterThan(leadingWildcardScore);
// More segments = more specific
const threeSegments = DomainMatcher.calculateSpecificity('api.v1.example.com');
const twoSegments = DomainMatcher.calculateSpecificity('example.com');
expect(threeSegments).toBeGreaterThan(twoSegments);
});
tap.test('DomainMatcher - findAllMatches', async () => {
const patterns = [
'example.com',
'*.example.com',
'api.example.com',
'*.api.example.com',
'*'
];
const matches = DomainMatcher.findAllMatches(patterns, 'v1.api.example.com');
// Should match: *.example.com, *.api.example.com, *
expect(matches).toHaveLength(3);
expect(matches[0]).toEqual('*.api.example.com'); // Most specific
expect(matches[1]).toEqual('*.example.com');
expect(matches[2]).toEqual('*'); // Least specific
});
tap.start();

View File

@ -0,0 +1,118 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { IpMatcher } from '../../../ts/core/routing/matchers/ip.js';
tap.test('IpMatcher - exact match', async () => {
expect(IpMatcher.match('192.168.1.1', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1', '192.168.1.2')).toEqual(false);
expect(IpMatcher.match('10.0.0.1', '10.0.0.1')).toEqual(true);
});
tap.test('IpMatcher - CIDR notation', async () => {
// /24 subnet
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.255')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '192.168.2.1')).toEqual(false);
// /16 subnet
expect(IpMatcher.match('10.0.0.0/16', '10.0.1.1')).toEqual(true);
expect(IpMatcher.match('10.0.0.0/16', '10.0.255.255')).toEqual(true);
expect(IpMatcher.match('10.0.0.0/16', '10.1.0.1')).toEqual(false);
// /32 (single host)
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.2')).toEqual(false);
});
tap.test('IpMatcher - wildcard matching', async () => {
expect(IpMatcher.match('192.168.1.*', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '192.168.1.255')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '192.168.2.1')).toEqual(false);
expect(IpMatcher.match('192.168.*.*', '192.168.0.1')).toEqual(true);
expect(IpMatcher.match('192.168.*.*', '192.168.255.255')).toEqual(true);
expect(IpMatcher.match('192.168.*.*', '192.169.0.1')).toEqual(false);
expect(IpMatcher.match('*.*.*.*', '1.2.3.4')).toEqual(true);
expect(IpMatcher.match('*.*.*.*', '255.255.255.255')).toEqual(true);
});
tap.test('IpMatcher - range matching', async () => {
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.5')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.10')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.11')).toEqual(false);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.0')).toEqual(false);
});
tap.test('IpMatcher - IPv6-mapped IPv4', async () => {
expect(IpMatcher.match('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '::ffff:192.168.1.100')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '::FFFF:192.168.1.50')).toEqual(true);
});
tap.test('IpMatcher - IP validation', async () => {
expect(IpMatcher.isValidIpv4('192.168.1.1')).toEqual(true);
expect(IpMatcher.isValidIpv4('255.255.255.255')).toEqual(true);
expect(IpMatcher.isValidIpv4('0.0.0.0')).toEqual(true);
expect(IpMatcher.isValidIpv4('256.1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1.a')).toEqual(false);
expect(IpMatcher.isValidIpv4('01.1.1.1')).toEqual(false); // No leading zeros
});
tap.test('IpMatcher - isAuthorized', async () => {
// Empty lists - allow all
expect(IpMatcher.isAuthorized('192.168.1.1')).toEqual(true);
// Allow list only
const allowList = ['192.168.1.0/24', '10.0.0.0/16'];
expect(IpMatcher.isAuthorized('192.168.1.100', allowList)).toEqual(true);
expect(IpMatcher.isAuthorized('10.0.50.1', allowList)).toEqual(true);
expect(IpMatcher.isAuthorized('172.16.0.1', allowList)).toEqual(false);
// Block list only
const blockList = ['192.168.1.100', '10.0.0.0/24'];
expect(IpMatcher.isAuthorized('192.168.1.100', [], blockList)).toEqual(false);
expect(IpMatcher.isAuthorized('10.0.0.50', [], blockList)).toEqual(false);
expect(IpMatcher.isAuthorized('192.168.1.101', [], blockList)).toEqual(true);
// Both lists - block takes precedence
expect(IpMatcher.isAuthorized('192.168.1.100', allowList, ['192.168.1.100'])).toEqual(false);
});
tap.test('IpMatcher - specificity calculation', async () => {
// Exact IPs are most specific
const exactScore = IpMatcher.calculateSpecificity('192.168.1.1');
const cidr32Score = IpMatcher.calculateSpecificity('192.168.1.1/32');
const cidr24Score = IpMatcher.calculateSpecificity('192.168.1.0/24');
const cidr16Score = IpMatcher.calculateSpecificity('192.168.0.0/16');
const wildcardScore = IpMatcher.calculateSpecificity('192.168.1.*');
const rangeScore = IpMatcher.calculateSpecificity('192.168.1.1-192.168.1.10');
expect(exactScore).toBeGreaterThan(cidr24Score);
expect(cidr32Score).toBeGreaterThan(cidr24Score);
expect(cidr24Score).toBeGreaterThan(cidr16Score);
expect(rangeScore).toBeGreaterThan(wildcardScore);
});
tap.test('IpMatcher - edge cases', async () => {
// Empty/null inputs
expect(IpMatcher.match('', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.1', '')).toEqual(false);
expect(IpMatcher.match(null as any, '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.1', null as any)).toEqual(false);
// Invalid CIDR
expect(IpMatcher.match('192.168.1.0/33', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.0/-1', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.0/', '192.168.1.1')).toEqual(false);
// Invalid ranges
expect(IpMatcher.match('192.168.1.10-192.168.1.1', '192.168.1.5')).toEqual(false); // Start > end
expect(IpMatcher.match('192.168.1.1-', '192.168.1.5')).toEqual(false);
expect(IpMatcher.match('-192.168.1.10', '192.168.1.5')).toEqual(false);
});
tap.start();

View File

@ -0,0 +1,126 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { PathMatcher } from '../../../ts/core/routing/matchers/path.js';
tap.test('PathMatcher - exact match', async () => {
const result = PathMatcher.match('/api/users', '/api/users');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api/users');
expect(result.pathRemainder).toEqual('');
expect(result.params).toEqual({});
});
tap.test('PathMatcher - no match', async () => {
const result = PathMatcher.match('/api/users', '/api/posts');
expect(result.matches).toEqual(false);
});
tap.test('PathMatcher - parameter extraction', async () => {
const result = PathMatcher.match('/users/:id/profile', '/users/123/profile');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ id: '123' });
expect(result.pathMatch).toEqual('/users/123/profile');
expect(result.pathRemainder).toEqual('');
});
tap.test('PathMatcher - multiple parameters', async () => {
const result = PathMatcher.match('/api/:version/users/:id', '/api/v2/users/456');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v2', id: '456' });
});
tap.test('PathMatcher - wildcard matching', async () => {
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
expect(result.pathRemainder).toEqual('users/123/profile');
});
tap.test('PathMatcher - mixed parameters and wildcards', async () => {
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v1' });
expect(result.pathRemainder).toEqual('users/123');
});
tap.test('PathMatcher - trailing slash normalization', async () => {
// Both with trailing slash
let result = PathMatcher.match('/api/users/', '/api/users/');
expect(result.matches).toEqual(true);
// Pattern with, path without
result = PathMatcher.match('/api/users/', '/api/users');
expect(result.matches).toEqual(true);
// Pattern without, path with
result = PathMatcher.match('/api/users', '/api/users/');
expect(result.matches).toEqual(true);
});
tap.test('PathMatcher - root path handling', async () => {
const result = PathMatcher.match('/', '/');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/');
expect(result.pathRemainder).toEqual('');
});
tap.test('PathMatcher - specificity calculation', async () => {
// Exact paths are most specific
const exactScore = PathMatcher.calculateSpecificity('/api/v1/users');
const paramScore = PathMatcher.calculateSpecificity('/api/:version/users');
const wildcardScore = PathMatcher.calculateSpecificity('/api/*');
expect(exactScore).toBeGreaterThan(paramScore);
expect(paramScore).toBeGreaterThan(wildcardScore);
// More segments = more specific
const deepPath = PathMatcher.calculateSpecificity('/api/v1/users/profile/settings');
const shallowPath = PathMatcher.calculateSpecificity('/api/users');
expect(deepPath).toBeGreaterThan(shallowPath);
// More static segments = more specific
const moreStatic = PathMatcher.calculateSpecificity('/api/v1/users/:id');
const lessStatic = PathMatcher.calculateSpecificity('/api/:version/:resource/:id');
expect(moreStatic).toBeGreaterThan(lessStatic);
});
tap.test('PathMatcher - findAllMatches', async () => {
const patterns = [
'/api/users',
'/api/users/:id',
'/api/users/:id/profile',
'/api/*',
'/*'
];
const matches = PathMatcher.findAllMatches(patterns, '/api/users/123/profile');
// All patterns should match (including /api/users as a prefix match)
expect(matches).toHaveLength(5);
// Verify all expected patterns are in the results
const matchedPatterns = matches.map(m => m.pattern);
expect(matchedPatterns).toContain('/api/users');
expect(matchedPatterns).toContain('/api/users/:id');
expect(matchedPatterns).toContain('/api/users/:id/profile');
expect(matchedPatterns).toContain('/api/*');
expect(matchedPatterns).toContain('/*');
// Verify parameters were extracted correctly for parameterized patterns
const paramsById = matches.find(m => m.pattern === '/api/users/:id');
const paramsByIdProfile = matches.find(m => m.pattern === '/api/users/:id/profile');
expect(paramsById?.result.params).toEqual({ id: '123' });
expect(paramsByIdProfile?.result.params).toEqual({ id: '123' });
});
tap.test('PathMatcher - edge cases', async () => {
// Empty patterns
expect(PathMatcher.match('', '/api/users').matches).toEqual(false);
expect(PathMatcher.match('/api/users', '').matches).toEqual(false);
expect(PathMatcher.match('', '').matches).toEqual(false);
// Null/undefined
expect(PathMatcher.match(null as any, '/api/users').matches).toEqual(false);
expect(PathMatcher.match('/api/users', null as any).matches).toEqual(false);
});
tap.start();

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

View File

@ -23,7 +23,7 @@ export { HttpProxyBridge as NetworkProxyBridge } from './proxies/smart-proxy/ind
// Export SmartProxy elements selectively to avoid RouteManager ambiguity
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler, SmartCertManager } from './proxies/smart-proxy/index.js';
export { RouteManager } from './proxies/smart-proxy/route-manager.js';
export { SharedRouteManager as RouteManager } from './core/utils/route-manager.js';
// Export smart-proxy models
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js';

View File

@ -15,8 +15,7 @@ import { CertificateManager } from './certificate-manager.js';
import { ConnectionPool } from './connection-pool.js';
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
import { WebSocketHandler } from './websocket-handler.js';
import { ProxyRouter } from '../../routing/router/index.js';
import { RouteRouter } from '../../routing/router/route-router.js';
import { ProxyRouter, RouteRouter } from '../../routing/router/index.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { FunctionCache } from './function-cache.js';

View File

@ -602,7 +602,7 @@ export class RequestHandler {
// Fall back to legacy routing if no matching route found via RouteManager
let proxyConfig: IReverseProxyConfig | undefined;
try {
proxyConfig = this.legacyRouter.routeReq(req);
proxyConfig = (this.legacyRouter as any).routeReqLegacy(req);
} catch (err) {
this.logger.error('Error routing request with legacy router', err);
res.statusCode = 500;
@ -837,7 +837,7 @@ export class RequestHandler {
}
// Fall back to legacy routing
const proxyConfig = this.legacyRouter.routeReq(fakeReq);
const proxyConfig = (this.legacyRouter as any).routeReqLegacy(fakeReq);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');
@ -883,7 +883,7 @@ export class RequestHandler {
}
// Fall back to legacy routing
const proxyConfig = this.legacyRouter.routeReq(fakeReq as any);
const proxyConfig = (this.legacyRouter as any).routeReqLegacy(fakeReq as any);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');

View File

@ -228,7 +228,7 @@ export class WebSocketHandler {
}
} else {
// Fall back to legacy routing if no matching route found via modern router
const proxyConfig = this.legacyRouter.routeReq(req);
const proxyConfig = (this.legacyRouter as any).routeReqLegacy(req);
if (!proxyConfig) {
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);

View File

@ -11,7 +11,7 @@ export { RouteManager as HttpProxyRouteManager } from './http-proxy/models/types
// Export SmartProxy with selective imports to avoid conflicts
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler } from './smart-proxy/index.js';
export { RouteManager as SmartProxyRouteManager } from './smart-proxy/route-manager.js';
export { SharedRouteManager as SmartProxyRouteManager } from '../core/utils/route-manager.js';
export * from './smart-proxy/utils/index.js';
// Export smart-proxy models except IAcmeOptions
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './smart-proxy/models/index.js';

View File

@ -17,7 +17,7 @@ export { TlsManager } from './tls-manager.js';
export { HttpProxyBridge } from './http-proxy-bridge.js';
// Export route-based components
export { RouteManager } from './route-manager.js';
export { SharedRouteManager as RouteManager } from '../../core/utils/route-manager.js';
export { RouteConnectionHandler } from './route-connection-handler.js';
export { NFTablesManager } from './nftables-manager.js';

View File

@ -2,13 +2,14 @@ import * as plugins from '../../plugins.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import { logger } from '../../core/utils/logger.js';
// Route checking functions have been removed
import type { IRouteConfig, IRouteAction, IRouteContext } from './models/route-types.js';
import type { IRouteConfig, IRouteAction } from './models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js';
import { ConnectionManager } from './connection-manager.js';
import { SecurityManager } from './security-manager.js';
import { TlsManager } from './tls-manager.js';
import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js';
import { RouteManager } from './route-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/utils/route-manager.js';
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
@ -162,7 +163,7 @@ export class RouteConnectionHandler {
let initialDataReceived = false;
// Check if any routes on this port require TLS handling
const allRoutes = this.routeManager.getAllRoutes();
const allRoutes = this.routeManager.getRoutes();
const needsTlsHandling = allRoutes.some(route => {
// Check if route matches this port
const matchesPort = this.routeManager.getRoutesForPort(localPort).includes(route);
@ -385,15 +386,21 @@ export class RouteConnectionHandler {
// For HTTP proxy ports without TLS, skip domain check since domain info comes from HTTP headers
const skipDomainCheck = isHttpProxyPort && !record.isTLS;
// Find matching route
const routeMatch = this.routeManager.findMatchingRoute({
// Create route context for matching
const routeContext: IRouteContext = {
port: localPort,
domain: serverName,
domain: skipDomainCheck ? undefined : serverName, // Skip domain if HTTP proxy without TLS
clientIp: remoteIP,
serverIp: socket.localAddress || '',
path: undefined, // We don't have path info at this point
isTls: record.isTLS,
tlsVersion: undefined, // We don't extract TLS version yet
skipDomainCheck: skipDomainCheck,
});
timestamp: Date.now(),
connectionId: record.id
};
// Find matching route
const routeMatch = this.routeManager.findMatchingRoute(routeContext);
if (!routeMatch) {
logger.log('warn', `No route found for ${serverName || 'connection'} on port ${localPort} (connection: ${connectionId})`, {

View File

@ -1,554 +0,0 @@
import * as plugins from '../../plugins.js';
import type {
IRouteConfig,
IRouteMatch,
IRouteAction,
TPortRange
} from './models/route-types.js';
import type {
ISmartProxyOptions
} from './models/interfaces.js';
/**
* Result of route matching
*/
export interface IRouteMatchResult {
route: IRouteConfig;
// Additional match parameters (path, query, etc.)
params?: Record<string, string>;
}
/**
* The RouteManager handles all routing decisions based on connections and attributes
*/
export class RouteManager extends plugins.EventEmitter {
private routes: IRouteConfig[] = [];
private portMap: Map<number, IRouteConfig[]> = new Map();
private options: ISmartProxyOptions;
constructor(options: ISmartProxyOptions) {
super();
// Store options
this.options = options;
// Initialize routes from either source
this.updateRoutes(this.options.routes);
}
/**
* Update routes with new configuration
*/
public updateRoutes(routes: IRouteConfig[] = []): void {
// Sort routes by priority (higher first)
this.routes = [...(routes || [])].sort((a, b) => {
const priorityA = a.priority ?? 0;
const priorityB = b.priority ?? 0;
return priorityB - priorityA;
});
// Rebuild port mapping for fast lookups
this.rebuildPortMap();
}
/**
* Rebuild the port mapping for fast lookups
* Also logs information about the ports being listened on
*/
private rebuildPortMap(): void {
this.portMap.clear();
this.portRangeCache.clear(); // Clear cache when rebuilding
// Track ports for logging
const portToRoutesMap = new Map<number, string[]>();
for (const route of this.routes) {
const ports = this.expandPortRange(route.match.ports);
// Skip if no ports were found
if (ports.length === 0) {
console.warn(`Route ${route.name || 'unnamed'} has no valid ports to listen on`);
continue;
}
for (const port of ports) {
// Add to portMap for routing
if (!this.portMap.has(port)) {
this.portMap.set(port, []);
}
this.portMap.get(port)!.push(route);
// Add to tracking for logging
if (!portToRoutesMap.has(port)) {
portToRoutesMap.set(port, []);
}
portToRoutesMap.get(port)!.push(route.name || 'unnamed');
}
}
// Log summary of ports and routes
const totalPorts = this.portMap.size;
const totalRoutes = this.routes.length;
console.log(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`);
// Log port details if detailed logging is enabled
const enableDetailedLogging = this.options.enableDetailedLogging;
if (enableDetailedLogging) {
for (const [port, routes] of this.portMap.entries()) {
console.log(`Port ${port}: ${routes.length} routes (${portToRoutesMap.get(port)!.join(', ')})`);
}
}
}
/**
* Expand a port range specification into an array of individual ports
* Uses caching to improve performance for frequently used port ranges
*
* @public - Made public to allow external code to interpret port ranges
*/
public expandPortRange(portRange: TPortRange): number[] {
// For simple number, return immediately
if (typeof portRange === 'number') {
return [portRange];
}
// Create a cache key for this port range
const cacheKey = JSON.stringify(portRange);
// Check if we have a cached result
if (this.portRangeCache.has(cacheKey)) {
return this.portRangeCache.get(cacheKey)!;
}
// Process the port range
let result: number[] = [];
if (Array.isArray(portRange)) {
// Handle array of port objects or numbers
result = portRange.flatMap(item => {
if (typeof item === 'number') {
return [item];
} else if (typeof item === 'object' && 'from' in item && 'to' in item) {
// Handle port range object - check valid range
if (item.from > item.to) {
console.warn(`Invalid port range: from (${item.from}) > to (${item.to})`);
return [];
}
// Handle port range object
const ports: number[] = [];
for (let p = item.from; p <= item.to; p++) {
ports.push(p);
}
return ports;
}
return [];
});
}
// Cache the result
this.portRangeCache.set(cacheKey, result);
return result;
}
/**
* Memoization cache for expanded port ranges
*/
private portRangeCache: Map<string, number[]> = new Map();
/**
* Get all ports that should be listened on
* This method automatically infers all required ports from route configurations
*/
public getListeningPorts(): number[] {
// Return the unique set of ports from all routes
return Array.from(this.portMap.keys());
}
/**
* Get all routes for a given port
*/
public getRoutesForPort(port: number): IRouteConfig[] {
return this.portMap.get(port) || [];
}
/**
* Get all routes
*/
public getAllRoutes(): IRouteConfig[] {
return [...this.routes];
}
/**
* Test if a pattern matches a domain using glob matching
*/
private matchDomain(pattern: string, domain: string): boolean {
// Convert glob pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*'); // Convert * to .*
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(domain);
}
/**
* Match a domain against all patterns in a route
*/
private matchRouteDomain(route: IRouteConfig, domain: string): boolean {
if (!route.match.domains) {
// If no domains specified, match all domains
return true;
}
const patterns = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
return patterns.some(pattern => this.matchDomain(pattern, domain));
}
/**
* Check if a client IP is allowed by a route's security settings
* @deprecated Security is now checked in route-connection-handler.ts after route matching
*/
private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean {
const security = route.security;
if (!security) {
return true; // No security settings means allowed
}
// Check blocked IPs first
if (security.ipBlockList && security.ipBlockList.length > 0) {
for (const pattern of security.ipBlockList) {
if (this.matchIpPattern(pattern, clientIp)) {
return false; // IP is blocked
}
}
}
// If there are allowed IPs, check them
if (security.ipAllowList && security.ipAllowList.length > 0) {
for (const pattern of security.ipAllowList) {
if (this.matchIpPattern(pattern, clientIp)) {
return true; // IP is allowed
}
}
return false; // IP not in allowed list
}
// No allowed IPs specified, so IP is allowed
return true;
}
/**
* Match an IP against a pattern
*/
private 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 normalized addresses
if (pattern === ip || normalizedPattern === normalizedIp ||
pattern === normalizedIp || normalizedPattern === ip) {
return true;
}
// Handle CIDR notation (e.g., 192.168.1.0/24)
if (pattern.includes('/')) {
return this.matchIpCidr(pattern, normalizedIp) ||
(normalizedPattern !== pattern && this.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;
}
/**
* Match an IP against a CIDR pattern
*/
private matchIpCidr(cidr: string, ip: string): boolean {
try {
// In a real implementation, you'd use a proper IP library
// This is a simplified implementation
const [subnet, bits] = cidr.split('/');
const mask = parseInt(bits, 10);
// 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 = this.ipToNumber(normalizedIp);
const subnetNum = this.ipToNumber(normalizedSubnet);
// Calculate subnet mask
const maskNum = ~(2 ** (32 - mask) - 1);
// Check if IP is in subnet
return (ipNum & maskNum) === (subnetNum & maskNum);
} catch (e) {
console.error(`Error matching IP ${ip} against CIDR ${cidr}:`, e);
return false;
}
}
/**
* Convert an IP address to a numeric value
*/
private ipToNumber(ip: string): number {
// Normalize IPv6-mapped IPv4 addresses
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
const parts = normalizedIp.split('.').map(part => parseInt(part, 10));
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
}
/**
* Find the matching route for a connection
*/
public findMatchingRoute(options: {
port: number;
domain?: string;
clientIp: string;
path?: string;
tlsVersion?: string;
skipDomainCheck?: boolean;
}): IRouteMatchResult | null {
const { port, domain, clientIp, path, tlsVersion, skipDomainCheck } = options;
// Get all routes for this port
const routesForPort = this.getRoutesForPort(port);
// Find the first matching route based on priority order
for (const route of routesForPort) {
// Check domain match
// If the route has domain restrictions and we have a domain to check
if (route.match.domains && !skipDomainCheck) {
// If no domain was provided (non-TLS or no SNI), this route doesn't match
if (!domain) {
continue;
}
// If domain is provided but doesn't match the route's domains, skip
if (!this.matchRouteDomain(route, domain)) {
continue;
}
}
// If route has no domain restrictions, it matches all domains
// If skipDomainCheck is true, we skip domain validation for HTTP connections
// Check path match if specified in both route and request
if (path && route.match.path) {
if (!this.matchPath(route.match.path, path)) {
continue;
}
}
// Check client IP match
if (route.match.clientIp && !route.match.clientIp.some(pattern =>
this.matchIpPattern(pattern, clientIp))) {
continue;
}
// Check TLS version match
if (tlsVersion && route.match.tlsVersion &&
!route.match.tlsVersion.includes(tlsVersion)) {
continue;
}
// All checks passed, this route matches
// NOTE: Security is checked AFTER route matching in route-connection-handler.ts
return { route };
}
return null;
}
/**
* Match a path against a pattern
*/
private matchPath(pattern: string, path: string): boolean {
// Convert the glob pattern to a regex
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*') // Convert * to .*
.replace(/\//g, '\\/'); // Escape slashes
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
/**
* Domain-based configuration methods have been removed
* as part of the migration to pure route-based configuration
*/
/**
* Validate the route configuration and return any warnings
*/
public validateConfiguration(): string[] {
const warnings: string[] = [];
const duplicatePorts = new Map<number, number>();
// Check for routes with the same exact match criteria
for (let i = 0; i < this.routes.length; i++) {
for (let j = i + 1; j < this.routes.length; j++) {
const route1 = this.routes[i];
const route2 = this.routes[j];
// Check if route match criteria are the same
if (this.areMatchesSimilar(route1.match, route2.match)) {
warnings.push(
`Routes "${route1.name || i}" and "${route2.name || j}" have similar match criteria. ` +
`The route with higher priority (${Math.max(route1.priority || 0, route2.priority || 0)}) will be used.`
);
}
}
}
// Check for routes that may never be matched due to priority
for (let i = 0; i < this.routes.length; i++) {
const route = this.routes[i];
const higherPriorityRoutes = this.routes.filter(r =>
(r.priority || 0) > (route.priority || 0));
for (const higherRoute of higherPriorityRoutes) {
if (this.isRouteShadowed(route, higherRoute)) {
warnings.push(
`Route "${route.name || i}" may never be matched because it is shadowed by ` +
`higher priority route "${higherRoute.name || 'unnamed'}"`
);
break;
}
}
}
return warnings;
}
/**
* Check if two route matches are similar (potential conflict)
*/
private areMatchesSimilar(match1: IRouteMatch, match2: IRouteMatch): boolean {
// Check port overlap
const ports1 = new Set(this.expandPortRange(match1.ports));
const ports2 = new Set(this.expandPortRange(match2.ports));
let havePortOverlap = false;
for (const port of ports1) {
if (ports2.has(port)) {
havePortOverlap = true;
break;
}
}
if (!havePortOverlap) {
return false;
}
// Check domain overlap
if (match1.domains && match2.domains) {
const domains1 = Array.isArray(match1.domains) ? match1.domains : [match1.domains];
const domains2 = Array.isArray(match2.domains) ? match2.domains : [match2.domains];
// Check if any domain pattern from match1 could match any from match2
let haveDomainOverlap = false;
for (const domain1 of domains1) {
for (const domain2 of domains2) {
if (domain1 === domain2 ||
(domain1.includes('*') || domain2.includes('*'))) {
haveDomainOverlap = true;
break;
}
}
if (haveDomainOverlap) break;
}
if (!haveDomainOverlap) {
return false;
}
} else if (match1.domains || match2.domains) {
// One has domains, the other doesn't - they could overlap
// The one with domains is more specific, so it's not exactly a conflict
return false;
}
// Check path overlap
if (match1.path && match2.path) {
// This is a simplified check - in a real implementation,
// you'd need to check if the path patterns could match the same paths
return match1.path === match2.path ||
match1.path.includes('*') ||
match2.path.includes('*');
} else if (match1.path || match2.path) {
// One has a path, the other doesn't
return false;
}
// If we get here, the matches have significant overlap
return true;
}
/**
* Check if a route is completely shadowed by a higher priority route
*/
private isRouteShadowed(route: IRouteConfig, higherPriorityRoute: IRouteConfig): boolean {
// If they don't have similar match criteria, no shadowing occurs
if (!this.areMatchesSimilar(route.match, higherPriorityRoute.match)) {
return false;
}
// If higher priority route has more specific criteria, no shadowing
if (this.isRouteMoreSpecific(higherPriorityRoute.match, route.match)) {
return false;
}
// If higher priority route is equally or less specific but has higher priority,
// it shadows the lower priority route
return true;
}
/**
* Check if route1 is more specific than route2
*/
private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean {
// Check if match1 has more specific criteria
let match1Points = 0;
let match2Points = 0;
// Path is the most specific
if (match1.path) match1Points += 3;
if (match2.path) match2Points += 3;
// Domain is next most specific
if (match1.domains) match1Points += 2;
if (match2.domains) match2Points += 2;
// Client IP and TLS version are least specific
if (match1.clientIp) match1Points += 1;
if (match2.clientIp) match2Points += 1;
if (match1.tlsVersion) match1Points += 1;
if (match2.tlsVersion) match2Points += 1;
return match1Points > match2Points;
}
}

View File

@ -8,7 +8,7 @@ import { TlsManager } from './tls-manager.js';
import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js';
import { PortManager } from './port-manager.js';
import { RouteManager } from './route-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/utils/route-manager.js';
import { RouteConnectionHandler } from './route-connection-handler.js';
import { NFTablesManager } from './nftables-manager.js';
@ -162,8 +162,20 @@ export class SmartProxy extends plugins.EventEmitter {
this.timeoutManager
);
// Create the route manager
this.routeManager = new RouteManager(this.settings);
// Create the route manager with SharedRouteManager API
// Create a logger adapter to match ILogger interface
const loggerAdapter = {
debug: (message: string, data?: any) => logger.log('debug', message, data),
info: (message: string, data?: any) => logger.log('info', message, data),
warn: (message: string, data?: any) => logger.log('warn', message, data),
error: (message: string, data?: any) => logger.log('error', message, data)
};
this.routeManager = new RouteManager({
logger: loggerAdapter,
enableDetailedLogging: this.settings.enableDetailedLogging,
routes: this.settings.routes
});
// Create other required components

View File

@ -0,0 +1,414 @@
import * as plugins from '../../plugins.js';
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
import type { IReverseProxyConfig } from '../../proxies/http-proxy/models/types.js';
import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js';
/**
* Interface for router result with additional metadata
*/
export interface RouterResult {
route: IRouteConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
/**
* Legacy interface for backward compatibility
*/
export interface LegacyRouterResult {
config: IReverseProxyConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
/**
* Logger interface for HttpRouter
*/
export interface ILogger {
debug?: (message: string, data?: any) => void;
info: (message: string, data?: any) => void;
warn: (message: string, data?: any) => void;
error: (message: string, data?: any) => void;
}
/**
* Unified HTTP Router for reverse proxy requests
*
* Supports both modern IRouteConfig and legacy IReverseProxyConfig formats
*
* 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)
*
* Path pattern matching:
* - Exact path: "/api/users"
* - Wildcard paths: "/api/*"
* - Path parameters: "/users/:id/profile"
*/
export class HttpRouter {
// Store routes sorted by priority
private routes: IRouteConfig[] = [];
// Default route to use when no match is found (optional)
private defaultRoute?: IRouteConfig;
// Logger interface
private logger: ILogger;
constructor(
routes?: IRouteConfig[],
logger?: ILogger
) {
this.logger = logger || {
error: console.error.bind(console),
warn: console.warn.bind(console),
info: console.info.bind(console),
debug: console.debug?.bind(console)
};
if (routes) {
this.setRoutes(routes);
}
}
/**
* Sets a new set of routes
* @param routes Array of route configurations
*/
public setRoutes(routes: IRouteConfig[]): void {
this.routes = [...routes];
// Sort routes by priority (higher priority first)
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 ? [route.match.domains] : [];
return domains.includes('*');
});
const uniqueDomains = this.getHostnames();
this.logger.info(`HttpRouter 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();
// Find matching route
const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath);
if (matchingRoute) {
return matchingRoute;
}
// 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 the best matching route for a given hostname and path
*/
private findMatchingRoute(hostname: string, path: string): RouterResult | undefined {
// Try each route in priority order
for (const route of this.routes) {
// Skip disabled routes
if (route.enabled === false) {
continue;
}
// Check domain match
if (route.match.domains) {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Check if any domain pattern matches
const domainMatches = domains.some(domain =>
DomainMatcher.match(domain, hostname)
);
if (!domainMatches) {
continue;
}
}
// Check path match if specified
if (route.match.path) {
const pathResult = PathMatcher.match(route.match.path, path);
if (pathResult.matches) {
return {
route,
pathMatch: path,
pathParams: pathResult.params,
pathRemainder: pathResult.pathRemainder
};
}
} else {
// No path specified, so domain match is sufficient
return { route };
}
}
return undefined;
}
/**
* 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 unique 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);
// 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;
// Filter out routes that match the domain
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;
}
/**
* Remove a specific route by reference
* @param route The route to remove
* @returns Boolean indicating if the route was found and removed
*/
public removeRoute(route: IRouteConfig): boolean {
const index = this.routes.indexOf(route);
if (index !== -1) {
this.routes.splice(index, 1);
return true;
}
return false;
}
// ===== LEGACY COMPATIBILITY METHODS =====
/**
* Legacy method that returns IReverseProxyConfig for backward compatibility
* @param req The incoming HTTP request
* @returns The matching proxy config in legacy format or undefined
*/
public routeReqLegacy(req: plugins.http.IncomingMessage): IReverseProxyConfig | undefined {
const result = this.routeReqWithDetails(req);
if (!result) return undefined;
return this.convertRouteToLegacy(result.route);
}
/**
* Legacy method for backward compatibility with ProxyRouter
* Converts IReverseProxyConfig to IRouteConfig and sets routes
*
* @param configs Array of legacy proxy configurations
*/
public setNewProxyConfigs(configs: IReverseProxyConfig[]): void {
const routes = configs.map(config => this.convertLegacyConfig(config));
this.setRoutes(routes);
}
/**
* Legacy method for backward compatibility
* Gets all proxy configs by converting routes back to legacy format
*/
public getProxyConfigs(): IReverseProxyConfig[] {
return this.routes.map(route => this.convertRouteToLegacy(route));
}
/**
* Legacy method: Adds a proxy config with optional path pattern
* @param config The legacy configuration to add
* @param pathPattern Optional path pattern for route matching
*/
public addProxyConfig(
config: IReverseProxyConfig,
pathPattern?: string
): void {
const route = this.convertLegacyConfig(config, pathPattern);
this.addRoute(route);
}
/**
* Legacy method: Remove proxy config by hostname
* @param hostname The hostname to remove
* @returns Boolean indicating whether any configs were removed
*/
public removeProxyConfig(hostname: string): boolean {
return this.removeRoutesByDomain(hostname);
}
/**
* Convert legacy IReverseProxyConfig to IRouteConfig
*/
private convertLegacyConfig(config: IReverseProxyConfig, pathPattern?: string): IRouteConfig {
return {
match: {
ports: config.destinationPorts?.[0] || 443,
domains: config.hostName,
path: pathPattern
},
action: {
type: 'forward',
target: {
host: Array.isArray(config.destinationIps) ? config.destinationIps : config.destinationIps,
port: config.destinationPorts?.[0] || 443
},
tls: {
mode: 'terminate',
certificate: {
key: config.privateKey,
cert: config.publicKey
}
}
},
security: config.authentication ? {
basicAuth: {
enabled: true,
users: [{
username: config.authentication.user,
password: config.authentication.pass
}],
realm: 'Protected'
}
} : undefined,
name: `Legacy - ${config.hostName}`,
enabled: true
};
}
/**
* Convert IRouteConfig back to legacy IReverseProxyConfig format
*/
private convertRouteToLegacy(route: IRouteConfig): IReverseProxyConfig {
const action = route.action;
const target = action.target || { host: 'localhost', port: 80 };
// Extract certificate if available
let privateKey = '';
let publicKey = '';
if (action.tls?.certificate && typeof action.tls.certificate === 'object') {
privateKey = action.tls.certificate.key || '';
publicKey = action.tls.certificate.cert || '';
}
return {
hostName: Array.isArray(route.match.domains)
? route.match.domains[0]
: route.match.domains || '*',
destinationIps: Array.isArray(target.host) ? target.host : [target.host as string],
destinationPorts: [
typeof target.port === 'number'
? target.port
: typeof target.port === 'function'
? 443 // Default port for function-based
: 443
],
privateKey,
publicKey,
authentication: route.security?.basicAuth?.enabled && route.security.basicAuth.users.length > 0 ? {
type: 'Basic',
user: route.security.basicAuth.users[0].username || '',
pass: route.security.basicAuth.users[0].password || ''
} : undefined,
rewriteHostHeader: route.headers?.request?.['Host'] ? true : undefined
};
}
}
// Export backward compatibility aliases
export { HttpRouter as ProxyRouter };
export { HttpRouter as RouteRouter };

View File

@ -1,12 +1,19 @@
/**
* HTTP routing
* HTTP routing - Unified HttpRouter with backward compatibility
*/
// Export selectively to avoid ambiguity between duplicate type names
export { ProxyRouter } from './proxy-router.js';
export type { IPathPatternConfig } from './proxy-router.js';
// Re-export the RouterResult and PathPatternConfig from proxy-router.js (legacy names maintained for compatibility)
export type { PathPatternConfig as ProxyPathPatternConfig, RouterResult as ProxyRouterResult } from './proxy-router.js';
// Export the unified HttpRouter with backward compatibility aliases
export { HttpRouter, ProxyRouter, RouteRouter } from './http-router.js';
export type { RouterResult, LegacyRouterResult, ILogger } from './http-router.js';
export { RouteRouter } from './route-router.js';
export type { PathPatternConfig as RoutePathPatternConfig, RouterResult as RouteRouterResult } from './route-router.js';
// Legacy type exports for backward compatibility
export type { RouterResult as ProxyRouterResult } from './http-router.js';
export type { RouterResult as RouteRouterResult } from './http-router.js';
// Path pattern config is no longer needed as it's part of IRouteConfig.match.path
export interface IPathPatternConfig {
pathPattern?: string;
}
export type { IPathPatternConfig as PathPatternConfig };
export type { IPathPatternConfig as ProxyPathPatternConfig };
export type { IPathPatternConfig as RoutePathPatternConfig };

View File

@ -1,437 +0,0 @@
import * as plugins from '../../plugins.js';
import type { IReverseProxyConfig } from '../../proxies/http-proxy/models/types.js';
/**
* Optional path pattern configuration that can be added to proxy configs
*/
export interface PathPatternConfig {
pathPattern?: string;
}
// Backward compatibility
export type IPathPatternConfig = PathPatternConfig;
/**
* Interface for router result with additional metadata
*/
export interface RouterResult {
config: IReverseProxyConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
// Backward compatibility
export type IRouterResult = RouterResult;
/**
* 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: IReverseProxyConfig[] = [];
// Default config to use when no match is found (optional)
private defaultConfig?: IReverseProxyConfig;
// Store path patterns separately since they're not in the original interface
private pathPatterns: Map<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?: 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: 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: plugins.http.IncomingMessage): 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: 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.defaultConfig ? { config: this.defaultConfig } : 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 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): RouterResult | 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(): 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: 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: 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;
}
}

View File

@ -1,482 +0,0 @@
import * as plugins from '../../plugins.js';
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
import type { ILogger } from '../../proxies/http-proxy/models/types.js';
/**
* 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);
}
}