From 54ffbadb863cb3fa339228c4d410dcea6684aa5e Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Mon, 2 Jun 2025 03:57:52 +0000 Subject: [PATCH] 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. --- readme.delete.md | 143 +++++ test/core/routing/test.domain-matcher.ts | 79 +++ test/core/routing/test.ip-matcher.ts | 118 ++++ test/core/routing/test.path-matcher.ts | 126 ++++ ts/core/routing/index.ts | 17 + ts/core/routing/matchers/domain.ts | 119 ++++ ts/core/routing/matchers/header.ts | 120 ++++ ts/core/routing/matchers/index.ts | 22 + ts/core/routing/matchers/ip.ts | 207 +++++++ ts/core/routing/matchers/path.ts | 178 ++++++ ts/core/routing/specificity.ts | 141 +++++ ts/core/routing/types.ts | 49 ++ ts/core/utils/route-manager.ts | 8 - ts/core/utils/route-utils.ts | 246 ++------ ts/core/utils/security-utils.ts | 10 +- ts/index.ts | 2 +- ts/proxies/http-proxy/http-proxy.ts | 3 +- ts/proxies/http-proxy/request-handler.ts | 6 +- ts/proxies/http-proxy/websocket-handler.ts | 2 +- ts/proxies/index.ts | 2 +- ts/proxies/smart-proxy/index.ts | 2 +- .../smart-proxy/route-connection-handler.ts | 23 +- ts/proxies/smart-proxy/route-manager.ts | 554 ------------------ ts/proxies/smart-proxy/smart-proxy.ts | 18 +- ts/routing/router/http-router.ts | 414 +++++++++++++ ts/routing/router/index.ts | 23 +- ts/routing/router/proxy-router.ts | 437 -------------- ts/routing/router/route-router.ts | 482 --------------- 28 files changed, 1827 insertions(+), 1724 deletions(-) create mode 100644 readme.delete.md create mode 100644 test/core/routing/test.domain-matcher.ts create mode 100644 test/core/routing/test.ip-matcher.ts create mode 100644 test/core/routing/test.path-matcher.ts create mode 100644 ts/core/routing/index.ts create mode 100644 ts/core/routing/matchers/domain.ts create mode 100644 ts/core/routing/matchers/header.ts create mode 100644 ts/core/routing/matchers/index.ts create mode 100644 ts/core/routing/matchers/ip.ts create mode 100644 ts/core/routing/matchers/path.ts create mode 100644 ts/core/routing/specificity.ts create mode 100644 ts/core/routing/types.ts delete mode 100644 ts/proxies/smart-proxy/route-manager.ts create mode 100644 ts/routing/router/http-router.ts delete mode 100644 ts/routing/router/proxy-router.ts delete mode 100644 ts/routing/router/route-router.ts diff --git a/readme.delete.md b/readme.delete.md new file mode 100644 index 0000000..0dc7da3 --- /dev/null +++ b/readme.delete.md @@ -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 \ No newline at end of file diff --git a/test/core/routing/test.domain-matcher.ts b/test/core/routing/test.domain-matcher.ts new file mode 100644 index 0000000..00a6358 --- /dev/null +++ b/test/core/routing/test.domain-matcher.ts @@ -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(); \ No newline at end of file diff --git a/test/core/routing/test.ip-matcher.ts b/test/core/routing/test.ip-matcher.ts new file mode 100644 index 0000000..ed430a5 --- /dev/null +++ b/test/core/routing/test.ip-matcher.ts @@ -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(); \ No newline at end of file diff --git a/test/core/routing/test.path-matcher.ts b/test/core/routing/test.path-matcher.ts new file mode 100644 index 0000000..34db367 --- /dev/null +++ b/test/core/routing/test.path-matcher.ts @@ -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(); \ No newline at end of file diff --git a/ts/core/routing/index.ts b/ts/core/routing/index.ts new file mode 100644 index 0000000..aab0bd5 --- /dev/null +++ b/ts/core/routing/index.ts @@ -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'; \ No newline at end of file diff --git a/ts/core/routing/matchers/domain.ts b/ts/core/routing/matchers/domain.ts new file mode 100644 index 0000000..80f51cc --- /dev/null +++ b/ts/core/routing/matchers/domain.ts @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/ts/core/routing/matchers/header.ts b/ts/core/routing/matchers/header.ts new file mode 100644 index 0000000..6bb96dd --- /dev/null +++ b/ts/core/routing/matchers/header.ts @@ -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 { + /** + * 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, + actualHeaders: Record, + 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): 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); + } +} \ No newline at end of file diff --git a/ts/core/routing/matchers/index.ts b/ts/core/routing/matchers/index.ts new file mode 100644 index 0000000..c4c8253 --- /dev/null +++ b/ts/core/routing/matchers/index.ts @@ -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; \ No newline at end of file diff --git a/ts/core/routing/matchers/ip.ts b/ts/core/routing/matchers/ip.ts new file mode 100644 index 0000000..1459ef9 --- /dev/null +++ b/ts/core/routing/matchers/ip.ts @@ -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 { + /** + * 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); + } +} \ No newline at end of file diff --git a/ts/core/routing/matchers/path.ts b/ts/core/routing/matchers/path.ts new file mode 100644 index 0000000..f1452d7 --- /dev/null +++ b/ts/core/routing/matchers/path.ts @@ -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 { + /** + * 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 = {}; + 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); + } +} \ No newline at end of file diff --git a/ts/core/routing/specificity.ts b/ts/core/routing/specificity.ts new file mode 100644 index 0000000..0174c72 --- /dev/null +++ b/ts/core/routing/specificity.ts @@ -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 = {}; + 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 + ); + } +} \ No newline at end of file diff --git a/ts/core/routing/types.ts b/ts/core/routing/types.ts new file mode 100644 index 0000000..837908f --- /dev/null +++ b/ts/core/routing/types.ts @@ -0,0 +1,49 @@ +/** + * Core routing types used throughout the routing system + */ + +export interface IPathMatchResult { + matches: boolean; + params?: Record; + 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 { + match(pattern: string, value: string, options?: O): T | boolean; +} + +export interface IAsyncMatcher { + match(pattern: string, value: string, options?: O): Promise; +} \ No newline at end of file diff --git a/ts/core/utils/route-manager.ts b/ts/core/utils/route-manager.ts index e38025e..6c7b1a1 100644 --- a/ts/core/utils/route-manager.ts +++ b/ts/core/utils/route-manager.ts @@ -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 diff --git a/ts/core/utils/route-utils.ts b/ts/core/utils/route-utils.ts index 96d9588..52d3a8b 100644 --- a/ts/core/utils/route-utils.ts +++ b/ts/core/utils/route-utils.ts @@ -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 = {}; + 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; diff --git a/ts/core/utils/security-utils.ts b/ts/core/utils/security-utils.ts index c17b37e..97af39e 100644 --- a/ts/core/utils/security-utils.ts +++ b/ts/core/utils/security-utils.ts @@ -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; } } diff --git a/ts/index.ts b/ts/index.ts index c8423f0..861b748 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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'; diff --git a/ts/proxies/http-proxy/http-proxy.ts b/ts/proxies/http-proxy/http-proxy.ts index 5c1e405..2561828 100644 --- a/ts/proxies/http-proxy/http-proxy.ts +++ b/ts/proxies/http-proxy/http-proxy.ts @@ -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'; diff --git a/ts/proxies/http-proxy/request-handler.ts b/ts/proxies/http-proxy/request-handler.ts index 3fffe8a..11ddfad 100644 --- a/ts/proxies/http-proxy/request-handler.ts +++ b/ts/proxies/http-proxy/request-handler.ts @@ -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'); diff --git a/ts/proxies/http-proxy/websocket-handler.ts b/ts/proxies/http-proxy/websocket-handler.ts index dd5dc41..fb55742 100644 --- a/ts/proxies/http-proxy/websocket-handler.ts +++ b/ts/proxies/http-proxy/websocket-handler.ts @@ -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}`); diff --git a/ts/proxies/index.ts b/ts/proxies/index.ts index a84ea0a..abc55ad 100644 --- a/ts/proxies/index.ts +++ b/ts/proxies/index.ts @@ -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'; diff --git a/ts/proxies/smart-proxy/index.ts b/ts/proxies/smart-proxy/index.ts index 057dd7c..1a63eca 100644 --- a/ts/proxies/smart-proxy/index.ts +++ b/ts/proxies/smart-proxy/index.ts @@ -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'; diff --git a/ts/proxies/smart-proxy/route-connection-handler.ts b/ts/proxies/smart-proxy/route-connection-handler.ts index d0130ef..9b065a9 100644 --- a/ts/proxies/smart-proxy/route-connection-handler.ts +++ b/ts/proxies/smart-proxy/route-connection-handler.ts @@ -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})`, { diff --git a/ts/proxies/smart-proxy/route-manager.ts b/ts/proxies/smart-proxy/route-manager.ts deleted file mode 100644 index dc40669..0000000 --- a/ts/proxies/smart-proxy/route-manager.ts +++ /dev/null @@ -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; -} - -/** - * The RouteManager handles all routing decisions based on connections and attributes - */ -export class RouteManager extends plugins.EventEmitter { - private routes: IRouteConfig[] = []; - private portMap: Map = 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(); - - 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 = 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(); - - // 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; - } -} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index d8fada5..564157a 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -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 diff --git a/ts/routing/router/http-router.ts b/ts/routing/router/http-router.ts new file mode 100644 index 0000000..b9e8f38 --- /dev/null +++ b/ts/routing/router/http-router.ts @@ -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; + pathRemainder?: string; +} + +/** + * Legacy interface for backward compatibility + */ +export interface LegacyRouterResult { + config: IReverseProxyConfig; + pathMatch?: string; + pathParams?: Record; + 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(); + 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 }; \ No newline at end of file diff --git a/ts/routing/router/index.ts b/ts/routing/router/index.ts index 15e9b22..07d6c0d 100644 --- a/ts/routing/router/index.ts +++ b/ts/routing/router/index.ts @@ -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 }; diff --git a/ts/routing/router/proxy-router.ts b/ts/routing/router/proxy-router.ts deleted file mode 100644 index 691cf6d..0000000 --- a/ts/routing/router/proxy-router.ts +++ /dev/null @@ -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; - 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 = 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; - 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 = {}; - - // 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(); - 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; - } -} \ No newline at end of file diff --git a/ts/routing/router/route-router.ts b/ts/routing/router/route-router.ts deleted file mode 100644 index 097c113..0000000 --- a/ts/routing/router/route-router.ts +++ /dev/null @@ -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; - 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 = 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; - 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 = {}; - - // 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(); - 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); - } -} \ No newline at end of file