fix(network-proxy, route-utils, route-manager): Normalize IPv6-mapped IPv4 addresses in IP matching functions and remove deprecated legacy configuration methods in NetworkProxy. Update route-utils and route-manager to compare both canonical and IPv6-mapped IP forms, adjust tests accordingly, and clean up legacy exports.

This commit is contained in:
Philipp Kunz 2025-05-14 12:26:43 +00:00
parent 0fe0692e43
commit bb54ea8192
15 changed files with 511 additions and 1208 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## 2025-05-14 - 16.0.3 - fix(network-proxy, route-utils, route-manager)
Normalize IPv6-mapped IPv4 addresses in IP matching functions and remove deprecated legacy configuration methods in NetworkProxy. Update route-utils and route-manager to compare both canonical and IPv6-mapped IP forms, adjust tests accordingly, and clean up legacy exports.
- Updated matchIpPattern and matchIpCidr to normalize IPv6-mapped IPv4 addresses.
- Replaced legacy 'domain' field references with 'domains' in route configurations.
- Removed deprecated methods for converting legacy proxy configs and legacy route helpers.
- Adjusted test cases (event system, route utils, network proxy function targets) to use modern interfaces.
- Improved logging and error messages in route-manager and route-utils for better debugging.
## 2025-05-10 - 16.0.2 - fix(test/certificate-provisioning) ## 2025-05-10 - 16.0.2 - fix(test/certificate-provisioning)
Update certificate provisioning tests with updated port mapping and ACME options; use accountEmail instead of contactEmail, adjust auto-api route creation to use HTTPS terminate helper, and refine expectations for wildcard passthrough domains. Update certificate provisioning tests with updated port mapping and ACME options; use accountEmail instead of contactEmail, adjust auto-api route creation to use HTTPS terminate helper, and refine expectations for wildcard passthrough domains.

View File

@ -1,139 +1,103 @@
# Enhanced NetworkProxy with Native Route-Based Configuration # SmartProxy Configuration Troubleshooting
## Project Goal ## IPv6/IPv4 Mapping Issue
Transform NetworkProxy to natively use route-based configurations (`IRouteConfig`) as its primary configuration format, completely eliminating translation layers while maintaining backward compatibility through adapter methods for existing code.
## Current Status ### Problem Identified
The SmartProxy is failing to match connections for wildcard domains (like `*.lossless.digital`) when IP restrictions are in place. After extensive debugging, the root cause has been identified:
The current implementation uses: When a connection comes in from an IPv4 address (e.g., `212.95.99.130`), the Node.js server receives it as an IPv6-mapped IPv4 address with the format `::ffff:212.95.99.130`. However, the route configuration is expecting the exact string `212.95.99.130`, causing a mismatch.
- SmartProxy has a rich `IRouteConfig` format with match/action pattern
- NetworkProxy uses a simpler `IReverseProxyConfig` focused on hostname and destination
- `NetworkProxyBridge` translates between these formats, losing information
- Dynamic function-based hosts and ports aren't supported in NetworkProxy
- Duplicate configuration logic exists across components
## Planned Enhancements From the debug logs:
```
[DEBUG] Route rejected: clientIp mismatch. Request: ::ffff:212.95.99.130, Route patterns: ["212.95.99.130"]
```
### Phase 1: Convert NetworkProxy to Native Route Configuration ### Solution
- [x] 1.1 Refactor NetworkProxy to use `IRouteConfig` as its primary internal format
- [x] 1.3 Update all internal processing to work directly with route configs
- [x] 1.4 Add a type-safe context object matching SmartProxy's
- [x] 1.5 Ensure backward compatibility for all existing NetworkProxy methods
- [x] 1.6 Remove `IReverseProxyConfig` usage in NetworkProxy
### Phase 2: Native Route Configuration Processing To fix this issue, update the route configurations to include both formats of the IP address. Here's how to modify the affected route:
- [x] 2.1 Make `updateRouteConfigs(routes: IRouteConfig[])` the primary configuration method
- [x] 2.3 Implement a full RouteManager in NetworkProxy (reusing code from SmartProxy if possible)
- [x] 2.4 Support all route matching criteria (domains, paths, headers, clientIp)
- [x] 2.5 Handle priority-based route matching and conflict resolution
- [x] 2.6 Update certificate management to work with routes directly
### Phase 3: Simplify NetworkProxyBridge ```typescript
- [x] 3.1 Update NetworkProxyBridge to directly pass route configs to NetworkProxy // Wildcard domain route for *.lossless.digital
- [x] 3.2 Remove all translation/conversion logic in the bridge {
- [x] 3.3 Simplify domain registration from routes to Port80Handler match: {
- [x] 3.4 Make the bridge a lightweight pass-through component ports: 443,
- [x] 3.5 Add comprehensive logging for route synchronization domains: ['*.lossless.digital'],
- [x] 3.6 Streamline certificate handling between components clientIp: ['212.95.99.130', '::ffff:212.95.99.130'], // Include both formats
},
action: {
type: 'forward',
target: {
host: '212.95.99.130',
port: 443
},
tls: {
mode: 'passthrough'
},
security: {
allowedIps: ['212.95.99.130', '::ffff:212.95.99.130'] // Include both formats
}
},
name: 'Wildcard lossless.digital route (IP restricted)'
}
```
### Phase 4: Native Function-Based Target Support ### Alternative Long-Term Fix
- [x] 4.1 Implement IRouteContext creation in NetworkProxy's request handler
- [x] 4.2 Add direct support for function-based host evaluation
- [x] 4.3 Add direct support for function-based port evaluation
- [x] 4.4 Implement caching for function results to improve performance
- [x] 4.5 Add comprehensive error handling for function execution
- [x] 4.6 Share context object implementation with SmartProxy
### Phase 5: Enhanced HTTP Features Using Route Logic A more robust solution would be to modify the SmartProxy codebase to automatically handle IPv6-mapped IPv4 addresses by normalizing them before comparison. This would involve:
- [x] 5.1 Implement full route-based header manipulation
- [x] 5.2 Add support for URL rewriting using route context
- [x] 5.3 Support template variable resolution for strings
- [x] 5.4 Implement route security features (IP filtering, rate limiting)
- [x] 5.5 Add context-aware CORS handling
- [x] 5.6 Enable route-based WebSocket upgrades
### Phase 6: Testing, Documentation and Code Sharing 1. Modifying the `matchIpPattern` function in `route-manager.ts` to normalize IPv6-mapped IPv4 addresses:
- [x] 6.1 Create comprehensive tests for native route configuration
- [x] 6.2 Add specific tests for function-based targets
- [x] 6.3 Document NetworkProxy's native route capabilities
- [x] 6.4 Create shared utilities between SmartProxy and NetworkProxy
- [x] 6.5 Provide migration guide for direct NetworkProxy users
- [ ] 6.6 Benchmark performance improvements
### Phase 7: Unify Component Architecture ```typescript
- [x] 7.1 Implement a shared RouteManager used by both SmartProxy and NetworkProxy private matchIpPattern(pattern: string, ip: string): boolean {
- [x] 7.2 Extract common route matching logic to a shared utility module // Normalize IPv6-mapped IPv4 addresses
- [x] 7.3 Consolidate duplicate security management code const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
- [x] 7.4 Remove all legacy NetworkProxyBridge conversion code const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern;
- [x] 7.5 Make the NetworkProxyBridge a pure proxy pass-through component
- [x] 7.6 Standardize event naming and handling across components
### Phase 8: Certificate Management Consolidation // Handle exact match with normalized addresses
- [x] 8.1 Create a unified CertificateManager component if (normalizedPattern === normalizedIp) {
- [x] 8.2 Centralize certificate storage and renewal logic return true;
- [x] 8.3 Simplify ACME challenge handling across proxies }
- [x] 8.4 Implement shared certificate events for all components
- [x] 8.5 Remove redundant certificate synchronization logic
- [x] 8.6 Standardize SNI handling between different proxies
### Phase 9: Context and Configuration Standardization // Rest of the existing function...
- [x] 9.1 Implement a single shared IRouteContext class }
- [x] 9.2 Remove all duplicate context creation logic ```
- [x] 9.3 Standardize option interfaces across components
- [x] 9.4 Create shared default configurations
- [x] 9.5 Implement a unified configuration validation system
- [x] 9.6 Add runtime type checking for configurations
### Phase 10: Component Consolidation 2. Making similar modifications to other IP-related functions in the codebase.
- [x] 10.1 Merge SmartProxy and NetworkProxy functionality where appropriate
- [x] 10.2 Create a unified connection pool management system
- [x] 10.3 Standardize timeout handling across components
- [x] 10.4 Implement shared logging and monitoring
- [x] 10.5 Remove all deprecated methods and legacy compatibility
- [x] 10.6 Reduce API surface area to essentials
### Phase 11: Performance Optimization & Advanced Features ## Wild Card Domain Matching Issue
- [ ] 11.1 Conduct comprehensive performance benchmarking
- [ ] 11.2 Optimize memory usage in high-connection scenarios
- [ ] 11.3 Implement connection pooling for backend targets
- [ ] 11.4 Add support for HTTP/3 and QUIC protocols
- [ ] 11.5 Enhance WebSocket support with compression and multiplexing
- [ ] 11.6 Add advanced observability through metrics and tracing integration
## Benefits of Simplified Architecture ### Explanation
1. **Reduced Duplication**: The wildcard domain matching in SmartProxy works as follows:
- Shared route processing logic
- Single certificate management system
- Unified context objects
2. **Simplified Codebase**: 1. When a pattern like `*.lossless.digital` is specified, it's converted to a regex: `/^.*\.lossless\.digital$/i`
- Fewer managers with cleaner responsibilities 2. This correctly matches any subdomain like `my.lossless.digital`, `api.lossless.digital`, etc.
- Consistent APIs across components 3. However, it does NOT match the apex domain `lossless.digital` (without a subdomain)
- Reduced complexity in bridge components
3. **Improved Maintainability**: If you need to match both the apex domain and subdomains, use a list:
- Easier to understand component relationships ```typescript
- Consolidated logic for critical operations domains: ['lossless.digital', '*.lossless.digital']
- Clearer separation of concerns ```
4. **Enhanced Performance**: ## Debugging SmartProxy
- Less overhead in communication between components
- Reduced memory usage through shared objects
- More efficient request processing
5. **Better Developer Experience**: To debug routing issues in SmartProxy:
- Consistent conceptual model across system
- More intuitive configuration interface
- Simplified debugging and troubleshooting
## Implementation Approach 1. Add detailed logging to the `route-manager.js` file in the `dist_ts` directory:
- `findMatchingRoute` method - to see what criteria are being checked
- `matchRouteDomain` method - to see domain matching logic
- `matchDomain` method - to see pattern matching
- `matchIpPattern` method - to see IP matching logic
The implementation of phases 7-10 will focus on gradually consolidating duplicate functionality: 2. Run the proxy with debugging enabled:
```
pnpm run startNew
```
1. First, implement shared managers and utilities to be used by both proxies 3. Monitor the logs for detailed information about the routing process and identify where matches are failing.
2. Then consolidate certificate management to simplify ACME handling
3. Create standardized context objects and configurations
4. Finally, merge overlapping functionality between proxy components
This approach will maintain compatibility with existing code while progressively simplifying the architecture to reduce complexity and improve performance. ## Priority and Route Order
Remember that routes are evaluated in priority order (higher priority first). If multiple routes could match the same request, ensure that the more specific routes have higher priority.
When routes have the same priority (or none specified), they're evaluated in the order they're defined in the configuration.

View File

@ -1,22 +1,20 @@
import { expect } from '@push.rocks/tapbundle'; import { expect, tap } from '@push.rocks/tapbundle';
import { import {
EventSystem, EventSystem,
ProxyEvents, ProxyEvents,
ComponentType ComponentType
} from '../../../ts/core/utils/event-system.js'; } from '../../../ts/core/utils/event-system.js';
// Test event system // Setup function for creating a new event system
expect.describe('Event System', async () => { function setupEventSystem(): { eventSystem: EventSystem, receivedEvents: any[] } {
let eventSystem: EventSystem; const eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
let receivedEvents: any[] = []; const receivedEvents: any[] = [];
return { eventSystem, receivedEvents };
}
// Set up a new event system before each test tap.test('Event System - certificate events with correct structure', async () => {
expect.beforeEach(() => { const { eventSystem, receivedEvents } = setupEventSystem();
eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
receivedEvents = [];
});
expect.it('should emit certificate events with correct structure', async () => {
// Set up listeners // Set up listeners
eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => { eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => {
receivedEvents.push({ receivedEvents.push({
@ -49,24 +47,26 @@ expect.describe('Event System', async () => {
}); });
// Verify events // Verify events
expect(receivedEvents.length).to.equal(2); expect(receivedEvents.length).toEqual(2);
// Check issuance event // Check issuance event
expect(receivedEvents[0].type).to.equal('issued'); expect(receivedEvents[0].type).toEqual('issued');
expect(receivedEvents[0].data.domain).to.equal('example.com'); expect(receivedEvents[0].data.domain).toEqual('example.com');
expect(receivedEvents[0].data.certificate).to.equal('cert-content'); expect(receivedEvents[0].data.certificate).toEqual('cert-content');
expect(receivedEvents[0].data.componentType).to.equal(ComponentType.SMART_PROXY); expect(receivedEvents[0].data.componentType).toEqual(ComponentType.SMART_PROXY);
expect(receivedEvents[0].data.componentId).to.equal('test-id'); expect(receivedEvents[0].data.componentId).toEqual('test-id');
expect(receivedEvents[0].data.timestamp).to.be.a('number'); expect(typeof receivedEvents[0].data.timestamp).toEqual('number');
// Check renewal event // Check renewal event
expect(receivedEvents[1].type).to.equal('renewed'); expect(receivedEvents[1].type).toEqual('renewed');
expect(receivedEvents[1].data.domain).to.equal('example.com'); expect(receivedEvents[1].data.domain).toEqual('example.com');
expect(receivedEvents[1].data.isRenewal).to.be.true; expect(receivedEvents[1].data.isRenewal).toEqual(true);
expect(receivedEvents[1].data.expiryDate).to.deep.equal(new Date('2026-01-01')); expect(receivedEvents[1].data.expiryDate).toEqual(new Date('2026-01-01'));
}); });
expect.it('should emit component lifecycle events', async () => { tap.test('Event System - component lifecycle events', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up listeners // Set up listeners
eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => { eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => {
receivedEvents.push({ receivedEvents.push({
@ -87,19 +87,21 @@ expect.describe('Event System', async () => {
eventSystem.emitComponentStopped('TestComponent'); eventSystem.emitComponentStopped('TestComponent');
// Verify events // Verify events
expect(receivedEvents.length).to.equal(2); expect(receivedEvents.length).toEqual(2);
// Check started event // Check started event
expect(receivedEvents[0].type).to.equal('started'); expect(receivedEvents[0].type).toEqual('started');
expect(receivedEvents[0].data.name).to.equal('TestComponent'); expect(receivedEvents[0].data.name).toEqual('TestComponent');
expect(receivedEvents[0].data.version).to.equal('1.0.0'); expect(receivedEvents[0].data.version).toEqual('1.0.0');
// Check stopped event // Check stopped event
expect(receivedEvents[1].type).to.equal('stopped'); expect(receivedEvents[1].type).toEqual('stopped');
expect(receivedEvents[1].data.name).to.equal('TestComponent'); expect(receivedEvents[1].data.name).toEqual('TestComponent');
}); });
expect.it('should emit connection events', async () => { tap.test('Event System - connection events', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up listeners // Set up listeners
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({ receivedEvents.push({
@ -131,21 +133,23 @@ expect.describe('Event System', async () => {
}); });
// Verify events // Verify events
expect(receivedEvents.length).to.equal(2); expect(receivedEvents.length).toEqual(2);
// Check established event // Check established event
expect(receivedEvents[0].type).to.equal('established'); expect(receivedEvents[0].type).toEqual('established');
expect(receivedEvents[0].data.connectionId).to.equal('conn-123'); expect(receivedEvents[0].data.connectionId).toEqual('conn-123');
expect(receivedEvents[0].data.clientIp).to.equal('192.168.1.1'); expect(receivedEvents[0].data.clientIp).toEqual('192.168.1.1');
expect(receivedEvents[0].data.port).to.equal(443); expect(receivedEvents[0].data.port).toEqual(443);
expect(receivedEvents[0].data.isTls).to.be.true; expect(receivedEvents[0].data.isTls).toEqual(true);
// Check closed event // Check closed event
expect(receivedEvents[1].type).to.equal('closed'); expect(receivedEvents[1].type).toEqual('closed');
expect(receivedEvents[1].data.connectionId).to.equal('conn-123'); expect(receivedEvents[1].data.connectionId).toEqual('conn-123');
}); });
expect.it('should support once and off subscription methods', async () => { tap.test('Event System - once and off subscription methods', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up a listener that should fire only once // Set up a listener that should fire only once
eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({ receivedEvents.push({
@ -189,14 +193,15 @@ expect.describe('Event System', async () => {
}); });
// Verify events // Verify events
expect(receivedEvents.length).to.equal(3); expect(receivedEvents.length).toEqual(3);
expect(receivedEvents[0].type).to.equal('once'); expect(receivedEvents[0].type).toEqual('once');
expect(receivedEvents[0].data.connectionId).to.equal('conn-1'); expect(receivedEvents[0].data.connectionId).toEqual('conn-1');
expect(receivedEvents[1].type).to.equal('persistent'); expect(receivedEvents[1].type).toEqual('persistent');
expect(receivedEvents[1].data.connectionId).to.equal('conn-1'); expect(receivedEvents[1].data.connectionId).toEqual('conn-1');
expect(receivedEvents[2].type).to.equal('persistent'); expect(receivedEvents[2].type).toEqual('persistent');
expect(receivedEvents[2].data.connectionId).to.equal('conn-2'); expect(receivedEvents[2].data.connectionId).toEqual('conn-2');
});
}); });
export default tap.start();

View File

@ -1,89 +1,82 @@
import { expect } from '@push.rocks/tapbundle'; import { expect, tap } from '@push.rocks/tapbundle';
import * as routeUtils from '../../../ts/core/utils/route-utils.js'; import * as routeUtils from '../../../ts/core/utils/route-utils.js';
// Test domain matching // Test domain matching
expect.describe('Route Utils - Domain Matching', async () => { tap.test('Route Utils - Domain Matching - exact domains', async () => {
expect.it('should match exact domains', async () => { expect(routeUtils.matchDomain('example.com', 'example.com')).toEqual(true);
expect(routeUtils.matchDomain('example.com', 'example.com')).to.be.true;
}); });
expect.it('should match wildcard domains', async () => { tap.test('Route Utils - Domain Matching - wildcard domains', async () => {
expect(routeUtils.matchDomain('*.example.com', 'sub.example.com')).to.be.true; expect(routeUtils.matchDomain('*.example.com', 'sub.example.com')).toEqual(true);
expect(routeUtils.matchDomain('*.example.com', 'another.sub.example.com')).to.be.true; expect(routeUtils.matchDomain('*.example.com', 'another.sub.example.com')).toEqual(true);
expect(routeUtils.matchDomain('*.example.com', 'example.com')).to.be.false; expect(routeUtils.matchDomain('*.example.com', 'example.com')).toEqual(false);
}); });
expect.it('should match domains case-insensitively', async () => { tap.test('Route Utils - Domain Matching - case insensitivity', async () => {
expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).to.be.true; expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).toEqual(true);
}); });
expect.it('should match routes with multiple domain patterns', async () => { tap.test('Route Utils - Domain Matching - multiple domain patterns', async () => {
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'example.com')).to.be.true; expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'example.com')).toEqual(true);
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'sub.test.com')).to.be.true; expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'sub.test.com')).toEqual(true);
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'something.else')).to.be.false; expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'something.else')).toEqual(false);
});
}); });
// Test path matching // Test path matching
expect.describe('Route Utils - Path Matching', async () => { tap.test('Route Utils - Path Matching - exact paths', async () => {
expect.it('should match exact paths', async () => { expect(routeUtils.matchPath('/api/users', '/api/users')).toEqual(true);
expect(routeUtils.matchPath('/api/users', '/api/users')).to.be.true;
}); });
expect.it('should match wildcard paths', async () => { tap.test('Route Utils - Path Matching - wildcard paths', async () => {
expect(routeUtils.matchPath('/api/*', '/api/users')).to.be.true; expect(routeUtils.matchPath('/api/*', '/api/users')).toEqual(true);
expect(routeUtils.matchPath('/api/*', '/api/products')).to.be.true; expect(routeUtils.matchPath('/api/*', '/api/products')).toEqual(true);
expect(routeUtils.matchPath('/api/*', '/something/else')).to.be.false; expect(routeUtils.matchPath('/api/*', '/something/else')).toEqual(false);
}); });
expect.it('should match complex wildcard patterns', async () => { tap.test('Route Utils - Path Matching - complex wildcard patterns', async () => {
expect(routeUtils.matchPath('/api/*/details', '/api/users/details')).to.be.true; expect(routeUtils.matchPath('/api/*/details', '/api/users/details')).toEqual(true);
expect(routeUtils.matchPath('/api/*/details', '/api/products/details')).to.be.true; expect(routeUtils.matchPath('/api/*/details', '/api/products/details')).toEqual(true);
expect(routeUtils.matchPath('/api/*/details', '/api/users/other')).to.be.false; expect(routeUtils.matchPath('/api/*/details', '/api/users/other')).toEqual(false);
});
}); });
// Test IP matching // Test IP matching
expect.describe('Route Utils - IP Matching', async () => { tap.test('Route Utils - IP Matching - exact IPs', async () => {
expect.it('should match exact IPs', async () => { expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).toEqual(true);
expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).to.be.true;
}); });
expect.it('should match wildcard IPs', async () => { tap.test('Route Utils - IP Matching - wildcard IPs', async () => {
expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.1.100')).to.be.true; expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.1.100')).toEqual(true);
expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.2.1')).to.be.false; expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.2.1')).toEqual(false);
}); });
expect.it('should match CIDR notation', async () => { tap.test('Route Utils - IP Matching - CIDR notation', async () => {
expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.1.100')).to.be.true; expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.1.100')).toEqual(true);
expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.2.1')).to.be.false; expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.2.1')).toEqual(false);
}); });
expect.it('should handle IPv6-mapped IPv4 addresses', async () => { tap.test('Route Utils - IP Matching - IPv6-mapped IPv4 addresses', async () => {
expect(routeUtils.matchIpPattern('192.168.1.1', '::ffff:192.168.1.1')).to.be.true; expect(routeUtils.matchIpPattern('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true);
}); });
expect.it('should correctly authorize IPs based on allow/block lists', async () => { tap.test('Route Utils - IP Matching - IP authorization with allow/block lists', async () => {
// With allow and block lists // With allow and block lists
expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).to.be.true; expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).toEqual(true);
expect(routeUtils.isIpAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).to.be.false; expect(routeUtils.isIpAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).toEqual(false);
// With only allow list // With only allow list
expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'])).to.be.true; expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'])).toEqual(true);
expect(routeUtils.isIpAuthorized('192.168.2.1', ['192.168.1.*'])).to.be.false; expect(routeUtils.isIpAuthorized('192.168.2.1', ['192.168.1.*'])).toEqual(false);
// With only block list // With only block list
expect(routeUtils.isIpAuthorized('192.168.1.5', undefined, ['192.168.1.5'])).to.be.false; expect(routeUtils.isIpAuthorized('192.168.1.5', undefined, ['192.168.1.5'])).toEqual(false);
expect(routeUtils.isIpAuthorized('192.168.1.1', undefined, ['192.168.1.5'])).to.be.true; expect(routeUtils.isIpAuthorized('192.168.1.1', undefined, ['192.168.1.5'])).toEqual(true);
// With wildcard in allow list // With wildcard in allow list
expect(routeUtils.isIpAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).to.be.true; expect(routeUtils.isIpAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).toEqual(true);
});
}); });
// Test route specificity calculation // Test route specificity calculation
expect.describe('Route Utils - Route Specificity', async () => { tap.test('Route Utils - Route Specificity - calculating correctly', async () => {
expect.it('should calculate route specificity correctly', async () => {
const basicRoute = { domains: 'example.com' }; const basicRoute = { domains: 'example.com' };
const pathRoute = { domains: 'example.com', path: '/api' }; const pathRoute = { domains: 'example.com', path: '/api' };
const wildcardPathRoute = { domains: 'example.com', path: '/api/*' }; const wildcardPathRoute = { domains: 'example.com', path: '/api/*' };
@ -96,21 +89,22 @@ expect.describe('Route Utils - Route Specificity', async () => {
}; };
// Path routes should have higher specificity than domain-only routes // Path routes should have higher specificity than domain-only routes
expect(routeUtils.calculateRouteSpecificity(pathRoute)) expect(routeUtils.calculateRouteSpecificity(pathRoute) >
.to.be.greaterThan(routeUtils.calculateRouteSpecificity(basicRoute)); routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true);
// Exact path routes should have higher specificity than wildcard path routes // Exact path routes should have higher specificity than wildcard path routes
expect(routeUtils.calculateRouteSpecificity(pathRoute)) expect(routeUtils.calculateRouteSpecificity(pathRoute) >
.to.be.greaterThan(routeUtils.calculateRouteSpecificity(wildcardPathRoute)); routeUtils.calculateRouteSpecificity(wildcardPathRoute)).toEqual(true);
// Routes with headers should have higher specificity than routes without // Routes with headers should have higher specificity than routes without
expect(routeUtils.calculateRouteSpecificity(headerRoute)) expect(routeUtils.calculateRouteSpecificity(headerRoute) >
.to.be.greaterThan(routeUtils.calculateRouteSpecificity(basicRoute)); routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true);
// Complex routes should have the highest specificity // Complex routes should have the highest specificity
expect(routeUtils.calculateRouteSpecificity(complexRoute)) expect(routeUtils.calculateRouteSpecificity(complexRoute) >
.to.be.greaterThan(routeUtils.calculateRouteSpecificity(pathRoute)); routeUtils.calculateRouteSpecificity(pathRoute)).toEqual(true);
expect(routeUtils.calculateRouteSpecificity(complexRoute)) expect(routeUtils.calculateRouteSpecificity(complexRoute) >
.to.be.greaterThan(routeUtils.calculateRouteSpecificity(headerRoute)); routeUtils.calculateRouteSpecificity(headerRoute)).toEqual(true);
});
}); });
export default tap.start();

View File

@ -1,24 +1,22 @@
import { expect, tap } from '@push.rocks/tapbundle'; import { expect, tap } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import { NetworkProxy } from '../ts/proxies/network-proxy/index.js'; import { NetworkProxy } from '../ts/proxies/network-proxy/index.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
import type { IRouteContext } from '../ts/core/models/route-context.js'; import type { IRouteContext } from '../ts/core/models/route-context.js';
import * as http from 'http';
import * as https from 'https';
import * as http2 from 'http2';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Declare variables for tests // Declare variables for tests
let networkProxy: NetworkProxy; let networkProxy: NetworkProxy;
let testServer: http.Server; let testServer: plugins.http.Server;
let testServerHttp2: http2.Http2Server; let testServerHttp2: plugins.http2.Http2Server;
let serverPort: number; let serverPort: number;
let serverPortHttp2: number; let serverPortHttp2: number;
// Setup test environment // Setup test environment
tap.test('setup NetworkProxy function-based targets test environment', async () => { tap.test('setup NetworkProxy function-based targets test environment', async () => {
// Create simple HTTP server to respond to requests // Create simple HTTP server to respond to requests
testServer = http.createServer((req, res) => { testServer = plugins.http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
url: req.url, url: req.url,
@ -29,7 +27,7 @@ tap.test('setup NetworkProxy function-based targets test environment', async ()
}); });
// Create simple HTTP/2 server to respond to requests // Create simple HTTP/2 server to respond to requests
testServerHttp2 = http2.createServer(); testServerHttp2 = plugins.http2.createServer();
testServerHttp2.on('stream', (stream, headers) => { testServerHttp2.on('stream', (stream, headers) => {
stream.respond({ stream.respond({
'content-type': 'application/json', 'content-type': 'application/json',
@ -82,10 +80,10 @@ tap.test('should support static host/port routes', async () => {
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
{ {
name: 'static-route', name: 'static-route',
domain: 'example.com',
priority: 100, priority: 100,
match: { match: {
domain: 'example.com' domains: 'example.com',
ports: 0
}, },
action: { action: {
type: 'forward', type: 'forward',
@ -124,10 +122,10 @@ tap.test('should support function-based host', async () => {
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
{ {
name: 'function-host-route', name: 'function-host-route',
domain: 'function.example.com',
priority: 100, priority: 100,
match: { match: {
domain: 'function.example.com' domains: 'function.example.com',
ports: 0
}, },
action: { action: {
type: 'forward', type: 'forward',
@ -169,10 +167,10 @@ tap.test('should support function-based port', async () => {
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
{ {
name: 'function-port-route', name: 'function-port-route',
domain: 'function-port.example.com',
priority: 100, priority: 100,
match: { match: {
domain: 'function-port.example.com' domains: 'function-port.example.com',
ports: 0
}, },
action: { action: {
type: 'forward', type: 'forward',
@ -214,10 +212,10 @@ tap.test('should support function-based host AND port', async () => {
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
{ {
name: 'function-both-route', name: 'function-both-route',
domain: 'function-both.example.com',
priority: 100, priority: 100,
match: { match: {
domain: 'function-both.example.com' domains: 'function-both.example.com',
ports: 0
}, },
action: { action: {
type: 'forward', type: 'forward',
@ -260,10 +258,10 @@ tap.test('should support context-based routing with path', async () => {
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
{ {
name: 'context-path-route', name: 'context-path-route',
domain: 'context.example.com',
priority: 100, priority: 100,
match: { match: {
domain: 'context.example.com' domains: 'context.example.com',
ports: 0
}, },
action: { action: {
type: 'forward', type: 'forward',
@ -338,10 +336,10 @@ tap.test('cleanup NetworkProxy function-based targets test environment', async (
}); });
// Helper function to make HTTPS requests with self-signed certificate support // Helper function to make HTTPS requests with self-signed certificate support
async function makeRequest(options: http.RequestOptions): Promise<{ statusCode: number, headers: http.IncomingHttpHeaders, body: string }> { async function makeRequest(options: plugins.http.RequestOptions): Promise<{ statusCode: number, headers: plugins.http.IncomingHttpHeaders, body: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Use HTTPS with rejectUnauthorized: false to accept self-signed certificates // Use HTTPS with rejectUnauthorized: false to accept self-signed certificates
const req = https.request({ const req = plugins.https.request({
...options, ...options,
rejectUnauthorized: false, // Accept self-signed certificates rejectUnauthorized: false, // Accept self-signed certificates
}, (res) => { }, (res) => {

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '16.0.2', version: '16.0.3',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
} }

View File

@ -1,3 +1,5 @@
import * as plugins from '../../plugins.js';
/** /**
* Shared Route Context Interface * Shared Route Context Interface
* *
@ -42,8 +44,8 @@ export interface IRouteContext {
* Used only in NetworkProxy for HTTP request handling * Used only in NetworkProxy for HTTP request handling
*/ */
export interface IHttpRouteContext extends IRouteContext { export interface IHttpRouteContext extends IRouteContext {
req?: any; // http.IncomingMessage req?: plugins.http.IncomingMessage;
res?: any; // http.ServerResponse res?: plugins.http.ServerResponse;
method?: string; // HTTP method (GET, POST, etc.) method?: string; // HTTP method (GET, POST, etc.)
} }
@ -52,7 +54,7 @@ export interface IHttpRouteContext extends IRouteContext {
* Used only in NetworkProxy for HTTP/2 request handling * Used only in NetworkProxy for HTTP/2 request handling
*/ */
export interface IHttp2RouteContext extends IHttpRouteContext { export interface IHttp2RouteContext extends IHttpRouteContext {
stream?: any; // http2.Http2Stream stream?: plugins.http2.ServerHttp2Stream;
headers?: Record<string, string>; // HTTP/2 pseudo-headers like :method, :path headers?: Record<string, string>; // HTTP/2 pseudo-headers like :method, :path
} }

View File

@ -13,8 +13,8 @@
* @returns Whether the domain matches the pattern * @returns Whether the domain matches the pattern
*/ */
export function matchDomain(pattern: string, domain: string): boolean { export function matchDomain(pattern: string, domain: string): boolean {
// Handle exact match // Handle exact match (case-insensitive)
if (pattern === domain) { if (pattern.toLowerCase() === domain.toLowerCase()) {
return true; return true;
} }
@ -139,9 +139,13 @@ export function matchIpCidr(cidr: string, ip: string): boolean {
try { try {
const { subnet, bits } = parsed; 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 // Convert IP addresses to numeric values
const ipNum = ipToNumber(ip); const ipNum = ipToNumber(normalizedIp);
const subnetNum = ipToNumber(subnet); const subnetNum = ipToNumber(normalizedSubnet);
// Calculate subnet mask // Calculate subnet mask
const maskNum = ~(2 ** (32 - bits) - 1); const maskNum = ~(2 ** (32 - bits) - 1);
@ -161,26 +165,41 @@ export function matchIpCidr(cidr: string, ip: string): boolean {
* @returns Whether the IP matches the pattern * @returns Whether the IP matches the pattern
*/ */
export function matchIpPattern(pattern: string, ip: string): boolean { export function matchIpPattern(pattern: string, ip: string): boolean {
// Handle exact match // Normalize IPv6-mapped IPv4 addresses
if (pattern === ip) { 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; return true;
} }
// Handle "all" wildcard // Handle "all" wildcard
if (pattern === '*') { if (pattern === '*' || normalizedPattern === '*') {
return true; return true;
} }
// Handle CIDR notation (e.g., 192.168.1.0/24) // Handle CIDR notation (e.g., 192.168.1.0/24)
if (pattern.includes('/')) { if (pattern.includes('/')) {
return matchIpCidr(pattern, ip); return matchIpCidr(pattern, normalizedIp) ||
(normalizedPattern !== pattern && matchIpCidr(normalizedPattern, normalizedIp));
} }
// Handle glob pattern (e.g., 192.168.1.*) // Handle glob pattern (e.g., 192.168.1.*)
if (pattern.includes('*')) { if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`); const regex = new RegExp(`^${regexPattern}$`);
return regex.test(ip); 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 false;

View File

@ -291,12 +291,15 @@ export class RouteManager {
/** /**
* Match an IP pattern against an IP * Match an IP pattern against an IP
* Supports exact matches, wildcard patterns, and CIDR notation
*/ */
private matchIp(pattern: string, ip: string): boolean { private matchIp(pattern: string, ip: string): boolean {
// Exact match
if (pattern === ip) { if (pattern === ip) {
return true; return true;
} }
// Wildcard matching (e.g., 192.168.0.*)
if (pattern.includes('*')) { if (pattern.includes('*')) {
const regexPattern = pattern const regexPattern = pattern
.replace(/\./g, '\\.') .replace(/\./g, '\\.')
@ -306,10 +309,65 @@ export class RouteManager {
return regex.test(ip); return regex.test(ip);
} }
// TODO: Implement CIDR matching // CIDR matching (e.g., 192.168.0.0/24)
if (pattern.includes('/')) {
try {
const [subnet, bits] = pattern.split('/');
// Convert IP addresses to numeric format for comparison
const ipBinary = this.ipToBinary(ip);
const subnetBinary = this.ipToBinary(subnet);
if (!ipBinary || !subnetBinary) {
return false;
}
// Get the subnet mask from CIDR notation
const mask = parseInt(bits, 10);
if (isNaN(mask) || mask < 0 || mask > 32) {
return false;
}
// Check if the first 'mask' bits match between IP and subnet
return ipBinary.slice(0, mask) === subnetBinary.slice(0, mask);
} catch (error) {
// If we encounter any error during CIDR matching, return false
return false;
}
}
return false; return false;
} }
/**
* Convert an IP address to its binary representation
* @param ip The IP address to convert
* @returns Binary string representation or null if invalid
*/
private ipToBinary(ip: string): string | null {
// Handle IPv4 addresses only for now
const parts = ip.split('.');
// Validate IP format
if (parts.length !== 4) {
return null;
}
// Convert each octet to 8-bit binary and concatenate
try {
return parts
.map(part => {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error('Invalid IP octet');
}
return num.toString(2).padStart(8, '0');
})
.join('');
} catch (error) {
return null;
}
}
} }
/** /**

View File

@ -500,68 +500,8 @@ export class NetworkProxy implements IMetricsTracker {
this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`); this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`);
} }
/** // Legacy methods have been removed.
* @deprecated Use updateRouteConfigs instead // Please use updateRouteConfigs() directly with modern route-based configuration.
* Legacy method for updating proxy configurations using IReverseProxyConfig
* This method is maintained for backward compatibility
*/
public async updateProxyConfigs(
proxyConfigsArg: IReverseProxyConfig[]
): Promise<void> {
this.logger.info(`Converting ${proxyConfigsArg.length} legacy configs to route configs`);
// Convert legacy configs to route configs
const routes: IRouteConfig[] = proxyConfigsArg.map(config =>
convertLegacyConfigToRouteConfig(config, this.options.port)
);
// Use the primary method
return this.updateRouteConfigs(routes);
}
/**
* @deprecated Use route-based configuration instead
* Converts SmartProxy domain configurations to NetworkProxy configs
* This method is maintained for backward compatibility
*/
public convertSmartProxyConfigs(
domainConfigs: Array<{
domains: string[];
targetIPs?: string[];
allowedIPs?: string[];
}>,
sslKeyPair?: { key: string; cert: string }
): IReverseProxyConfig[] {
this.logger.warn('convertSmartProxyConfigs is deprecated - use route-based configuration instead');
const proxyConfigs: IReverseProxyConfig[] = [];
// Use default certificates if not provided
const defaultCerts = this.certificateManager.getDefaultCertificates();
const sslKey = sslKeyPair?.key || defaultCerts.key;
const sslCert = sslKeyPair?.cert || defaultCerts.cert;
for (const domainConfig of domainConfigs) {
// Each domain in the domains array gets its own config
for (const domain of domainConfig.domains) {
// Skip non-hostname patterns (like IP addresses)
if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') {
continue;
}
proxyConfigs.push({
hostName: domain,
destinationIps: domainConfig.targetIPs || ['localhost'],
destinationPorts: [this.options.port], // Use the NetworkProxy port
privateKey: sslKey,
publicKey: sslCert
});
}
}
this.logger.info(`Converted ${domainConfigs.length} SmartProxy configs to ${proxyConfigs.length} NetworkProxy configs`);
return proxyConfigs;
}
/** /**
* Adds default headers to be included in all responses * Adds default headers to be included in all responses
@ -650,62 +590,4 @@ export class NetworkProxy implements IMetricsTracker {
public getRouteConfigs(): IRouteConfig[] { public getRouteConfigs(): IRouteConfig[] {
return this.routeManager.getRoutes(); return this.routeManager.getRoutes();
} }
/**
* @deprecated Use getRouteConfigs instead
* Gets all proxy configurations currently in use in the legacy format
* This method is maintained for backward compatibility
*/
public getProxyConfigs(): IReverseProxyConfig[] {
this.logger.warn('getProxyConfigs is deprecated - use getRouteConfigs instead');
// Create legacy proxy configs from our route configurations
const legacyConfigs: IReverseProxyConfig[] = [];
const currentRoutes = this.routeManager.getRoutes();
for (const route of currentRoutes) {
// Skip non-forward routes or routes without domains
if (route.action.type !== 'forward' || !route.match.domains || !route.action.target) {
continue;
}
// Skip routes with function-based targets
if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') {
continue;
}
// Get domains
const domains = Array.isArray(route.match.domains)
? route.match.domains.filter(d => !d.includes('*'))
: route.match.domains.includes('*') ? [] : [route.match.domains];
// Get certificate
let privateKey = '';
let publicKey = '';
if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') {
privateKey = route.action.tls.certificate.key;
publicKey = route.action.tls.certificate.cert;
} else {
const defaultCerts = this.certificateManager.getDefaultCertificates();
privateKey = defaultCerts.key;
publicKey = defaultCerts.cert;
}
// Create legacy config for each domain
for (const domain of domains) {
legacyConfigs.push({
hostName: domain,
destinationIps: Array.isArray(route.action.target.host)
? route.action.target.host
: [route.action.target.host],
destinationPorts: [route.action.target.port],
privateKey,
publicKey
});
}
}
return legacyConfigs;
}
} }

View File

@ -661,150 +661,6 @@ export class RequestHandler {
}); });
return; return;
} }
try {
// Find target based on hostname
const proxyConfig = this.router.routeReq(req);
if (!proxyConfig) {
// No matching proxy configuration
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
res.statusCode = 404;
res.end('Not Found: No proxy configuration for this host');
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
return;
}
// Get destination IP using round-robin if multiple IPs configured
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
// Create options for the proxy request
const options: plugins.http.RequestOptions = {
hostname: destination.host,
port: destination.port,
path: req.url,
method: req.method,
headers: { ...req.headers }
};
// Remove host header to avoid issues with virtual hosts on target server
// The host header should match the target server's expected hostname
if (options.headers && options.headers.host) {
if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) {
options.headers.host = `${destination.host}:${destination.port}`;
}
}
this.logger.debug(
`Proxying request to ${destination.host}:${destination.port}${req.url}`,
{ method: req.method }
);
// Create proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code
res.statusCode = proxyRes.statusCode || 500;
// Copy headers from proxy response to client response
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value !== undefined) {
res.setHeader(key, value);
}
}
// Pipe proxy response to client response
proxyRes.pipe(res);
// Increment served requests counter when the response finishes
res.on('finish', () => {
if (this.metricsTracker) {
this.metricsTracker.incrementRequestsServed();
}
// Log the completed request
const duration = Date.now() - startTime;
this.logger.debug(
`Request completed in ${duration}ms: ${req.method} ${req.url} ${res.statusCode}`,
{ duration, statusCode: res.statusCode }
);
});
});
// Handle proxy request errors
proxyReq.on('error', (error) => {
const duration = Date.now() - startTime;
this.logger.error(
`Proxy error for ${req.method} ${req.url}: ${error.message}`,
{ duration, error: error.message }
);
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
// Check if headers have already been sent
if (!res.headersSent) {
res.statusCode = 502;
res.end(`Bad Gateway: ${error.message}`);
} else {
// If headers already sent, just close the connection
res.end();
}
});
// Pipe request body to proxy request and handle client-side errors
req.pipe(proxyReq);
// Handle client disconnection
req.on('error', (error) => {
this.logger.debug(`Client connection error: ${error.message}`);
proxyReq.destroy();
// Increment failed requests counter on client errors
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
});
// Handle response errors
res.on('error', (error) => {
this.logger.debug(`Response error: ${error.message}`);
proxyReq.destroy();
// Increment failed requests counter on response errors
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
});
} catch (error) {
// Handle any unexpected errors
this.logger.error(
`Unexpected error handling request: ${error.message}`,
{ error: error.stack }
);
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
if (!res.headersSent) {
res.statusCode = 500;
res.end('Internal Server Error');
} else {
res.end();
}
}
} }
/** /**

View File

@ -56,7 +56,7 @@ export interface IRouteContext {
routeId?: string; // The ID of the matched route routeId?: string; // The ID of the matched route
// Target information (resolved from dynamic mapping) // Target information (resolved from dynamic mapping)
targetHost?: string; // The resolved target host targetHost?: string | string[]; // The resolved target host(s)
targetPort?: number; // The resolved target port targetPort?: number; // The resolved target port
// Additional properties // Additional properties
@ -68,8 +68,8 @@ export interface IRouteContext {
* Target configuration for forwarding * Target configuration for forwarding
*/ */
export interface IRouteTarget { export interface IRouteTarget {
host: string | string[] | ((context: any) => string | string[]); // Support static or dynamic host selection with any compatible context host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution
port: number | ((context: any) => number); // Support static or dynamic port mapping with any compatible context port: number | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping
preservePort?: boolean; // Use incoming port as target port (ignored if port is a function) preservePort?: boolean; // Use incoming port as target port (ignored if port is a function)
} }
@ -108,7 +108,8 @@ export interface IRouteAuthentication {
oauthClientId?: string; oauthClientId?: string;
oauthClientSecret?: string; oauthClientSecret?: string;
oauthRedirectUri?: string; oauthRedirectUri?: string;
[key: string]: any; // Allow additional auth-specific options // Specific options for different auth types
options?: Record<string, unknown>;
} }
/** /**

View File

@ -1,498 +0,0 @@
import type {
IRouteConfig,
IRouteMatch,
IRouteAction,
IRouteTarget,
IRouteTls,
IRouteRedirect,
IRouteSecurity,
IRouteAdvanced,
TPortRange
} from './models/route-types.js';
/**
* Basic helper function to create a route configuration
*/
export function createRoute(
match: IRouteMatch,
action: IRouteAction,
metadata?: {
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return {
match,
action,
...metadata
};
}
/**
* Create a basic HTTP route configuration
*/
export function createHttpRoute(
options: {
ports?: number | number[]; // Default: 80
domains?: string | string[];
path?: string;
target: IRouteTarget;
headers?: Record<string, string>;
security?: IRouteSecurity;
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return createRoute(
{
ports: options.ports || 80,
...(options.domains ? { domains: options.domains } : {}),
...(options.path ? { path: options.path } : {})
},
{
type: 'forward',
target: options.target,
...(options.headers || options.security ? {
advanced: {
...(options.headers ? { headers: options.headers } : {})
},
...(options.security ? { security: options.security } : {})
} : {})
},
{
name: options.name || 'HTTP Route',
description: options.description,
priority: options.priority,
tags: options.tags
}
);
}
/**
* Create an HTTPS route configuration with TLS termination
*/
export function createHttpsRoute(
options: {
ports?: number | number[]; // Default: 443
domains: string | string[];
path?: string;
target: IRouteTarget;
tlsMode?: 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
headers?: Record<string, string>;
security?: IRouteSecurity;
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return createRoute(
{
ports: options.ports || 443,
domains: options.domains,
...(options.path ? { path: options.path } : {})
},
{
type: 'forward',
target: options.target,
tls: {
mode: options.tlsMode || 'terminate',
certificate: options.certificate || 'auto'
},
...(options.headers || options.security ? {
advanced: {
...(options.headers ? { headers: options.headers } : {})
},
...(options.security ? { security: options.security } : {})
} : {})
},
{
name: options.name || 'HTTPS Route',
description: options.description,
priority: options.priority,
tags: options.tags
}
);
}
/**
* Create an HTTPS passthrough route configuration
*/
export function createPassthroughRoute(
options: {
ports?: number | number[]; // Default: 443
domains?: string | string[];
target: IRouteTarget;
security?: IRouteSecurity;
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return createRoute(
{
ports: options.ports || 443,
...(options.domains ? { domains: options.domains } : {})
},
{
type: 'forward',
target: options.target,
tls: {
mode: 'passthrough'
},
...(options.security ? { security: options.security } : {})
},
{
name: options.name || 'HTTPS Passthrough Route',
description: options.description,
priority: options.priority,
tags: options.tags
}
);
}
/**
* Create a redirect route configuration
*/
export function createRedirectRoute(
options: {
ports?: number | number[]; // Default: 80
domains?: string | string[];
path?: string;
redirectTo: string;
statusCode?: 301 | 302 | 307 | 308;
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return createRoute(
{
ports: options.ports || 80,
...(options.domains ? { domains: options.domains } : {}),
...(options.path ? { path: options.path } : {})
},
{
type: 'redirect',
redirect: {
to: options.redirectTo,
status: options.statusCode || 301
}
},
{
name: options.name || 'Redirect Route',
description: options.description,
priority: options.priority,
tags: options.tags
}
);
}
/**
* Create an HTTP to HTTPS redirect route configuration
*/
export function createHttpToHttpsRedirect(
options: {
domains: string | string[];
statusCode?: 301 | 302 | 307 | 308;
name?: string;
priority?: number;
}
): IRouteConfig {
const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains];
return createRedirectRoute({
ports: 80,
domains: options.domains,
redirectTo: 'https://{domain}{path}',
statusCode: options.statusCode || 301,
name: options.name || `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`,
priority: options.priority || 100 // High priority for redirects
});
}
/**
* Create a block route configuration
*/
export function createBlockRoute(
options: {
ports: number | number[];
domains?: string | string[];
clientIp?: string[];
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
return createRoute(
{
ports: options.ports,
...(options.domains ? { domains: options.domains } : {}),
...(options.clientIp ? { clientIp: options.clientIp } : {})
},
{
type: 'block'
},
{
name: options.name || 'Block Route',
description: options.description,
priority: options.priority || 1000, // Very high priority for blocks
tags: options.tags
}
);
}
/**
* Create a load balancer route configuration
*/
export function createLoadBalancerRoute(
options: {
ports?: number | number[]; // Default: 443
domains: string | string[];
path?: string;
targets: string[]; // Array of host names/IPs for load balancing
targetPort: number;
tlsMode?: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
headers?: Record<string, string>;
security?: IRouteSecurity;
name?: string;
description?: string;
tags?: string[];
}
): IRouteConfig {
const useTls = options.tlsMode !== undefined;
const defaultPort = useTls ? 443 : 80;
return createRoute(
{
ports: options.ports || defaultPort,
domains: options.domains,
...(options.path ? { path: options.path } : {})
},
{
type: 'forward',
target: {
host: options.targets,
port: options.targetPort
},
...(useTls ? {
tls: {
mode: options.tlsMode!,
...(options.tlsMode !== 'passthrough' && options.certificate ? {
certificate: options.certificate
} : {})
}
} : {}),
...(options.headers || options.security ? {
advanced: {
...(options.headers ? { headers: options.headers } : {})
},
...(options.security ? { security: options.security } : {})
} : {})
},
{
name: options.name || 'Load Balanced Route',
description: options.description || `Load balancing across ${options.targets.length} backends`,
tags: options.tags
}
);
}
/**
* Create a complete HTTPS server configuration with HTTP redirect
*/
export function createHttpsServer(
options: {
domains: string | string[];
target: IRouteTarget;
certificate?: 'auto' | { key: string; cert: string };
security?: IRouteSecurity;
addHttpRedirect?: boolean;
name?: string;
}
): IRouteConfig[] {
const routes: IRouteConfig[] = [];
const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains];
// Add HTTPS route
routes.push(createHttpsRoute({
domains: options.domains,
target: options.target,
certificate: options.certificate || 'auto',
security: options.security,
name: options.name || `HTTPS Server for ${domainArray.join(', ')}`
}));
// Add HTTP to HTTPS redirect if requested
if (options.addHttpRedirect !== false) {
routes.push(createHttpToHttpsRedirect({
domains: options.domains,
name: `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`,
priority: 100
}));
}
return routes;
}
/**
* Create a port range configuration from various input formats
*/
export function createPortRange(
ports: number | number[] | string | Array<{ from: number; to: number }>
): TPortRange {
// If it's a string like "80,443" or "8000-9000", parse it
if (typeof ports === 'string') {
if (ports.includes('-')) {
// Handle range like "8000-9000"
const [start, end] = ports.split('-').map(p => parseInt(p.trim(), 10));
return [{ from: start, to: end }];
} else if (ports.includes(',')) {
// Handle comma-separated list like "80,443,8080"
return ports.split(',').map(p => parseInt(p.trim(), 10));
} else {
// Handle single port as string
return parseInt(ports.trim(), 10);
}
}
// Otherwise return as is
return ports;
}
/**
* Create a security configuration object
*/
export function createSecurityConfig(
options: {
allowedIps?: string[];
blockedIps?: string[];
maxConnections?: number;
authentication?: {
type: 'basic' | 'digest' | 'oauth';
// Auth-specific options
[key: string]: any;
};
}
): IRouteSecurity {
return {
...(options.allowedIps ? { allowedIps: options.allowedIps } : {}),
...(options.blockedIps ? { blockedIps: options.blockedIps } : {}),
...(options.maxConnections ? { maxConnections: options.maxConnections } : {}),
...(options.authentication ? { authentication: options.authentication } : {})
};
}
/**
* Create a static file server route
*/
export function createStaticFileRoute(
options: {
ports?: number | number[]; // Default: 80
domains: string | string[];
path?: string;
targetDirectory: string;
tlsMode?: 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
headers?: Record<string, string>;
security?: IRouteSecurity;
name?: string;
description?: string;
priority?: number;
tags?: string[];
}
): IRouteConfig {
const useTls = options.tlsMode !== undefined;
const defaultPort = useTls ? 443 : 80;
return createRoute(
{
ports: options.ports || defaultPort,
domains: options.domains,
...(options.path ? { path: options.path } : {})
},
{
type: 'forward',
target: {
host: 'localhost', // Static file serving is typically handled locally
port: 0, // Special value indicating a static file server
preservePort: false
},
...(useTls ? {
tls: {
mode: options.tlsMode!,
certificate: options.certificate || 'auto'
}
} : {}),
advanced: {
...(options.headers ? { headers: options.headers } : {}),
staticFiles: {
root: options.targetDirectory,
index: ['index.html', 'index.htm'],
directory: options.targetDirectory // For backward compatibility
}
},
...(options.security ? { security: options.security } : {})
},
{
name: options.name || 'Static File Server',
description: options.description || `Serving static files from ${options.targetDirectory}`,
priority: options.priority,
tags: options.tags
}
);
}
/**
* Create a test route for debugging purposes
*/
export function createTestRoute(
options: {
ports?: number | number[]; // Default: 8000
domains?: string | string[];
path?: string;
response?: {
status?: number;
headers?: Record<string, string>;
body?: string;
};
name?: string;
}
): IRouteConfig {
return createRoute(
{
ports: options.ports || 8000,
...(options.domains ? { domains: options.domains } : {}),
...(options.path ? { path: options.path } : {})
},
{
type: 'forward',
target: {
host: 'test', // Special value indicating a test route
port: 0
},
advanced: {
testResponse: {
status: options.response?.status || 200,
headers: options.response?.headers || { 'Content-Type': 'text/plain' },
body: options.response?.body || 'Test route is working!'
}
}
},
{
name: options.name || 'Test Route',
description: 'Route for testing and debugging',
priority: 500,
tags: ['test', 'debug']
}
);
}

View File

@ -1,9 +0,0 @@
/**
* Route helpers for SmartProxy
*
* This module provides helper functions for creating various types of route configurations
* to be used with the SmartProxy system.
*/
// Re-export all functions from the route-helpers.ts file
export * from '../route-helpers.js';

View File

@ -244,21 +244,36 @@ export class RouteManager extends plugins.EventEmitter {
* Match an IP against a pattern * Match an IP against a pattern
*/ */
private matchIpPattern(pattern: string, ip: string): boolean { private matchIpPattern(pattern: string, ip: string): boolean {
// Handle exact match // Normalize IPv6-mapped IPv4 addresses
if (pattern === ip) { 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; return true;
} }
// Handle CIDR notation (e.g., 192.168.1.0/24) // Handle CIDR notation (e.g., 192.168.1.0/24)
if (pattern.includes('/')) { if (pattern.includes('/')) {
return this.matchIpCidr(pattern, ip); return this.matchIpCidr(pattern, normalizedIp) ||
(normalizedPattern !== pattern && this.matchIpCidr(normalizedPattern, normalizedIp));
} }
// Handle glob pattern (e.g., 192.168.1.*) // Handle glob pattern (e.g., 192.168.1.*)
if (pattern.includes('*')) { if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`); const regex = new RegExp(`^${regexPattern}$`);
return regex.test(ip); 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 false;
@ -274,9 +289,13 @@ export class RouteManager extends plugins.EventEmitter {
const [subnet, bits] = cidr.split('/'); const [subnet, bits] = cidr.split('/');
const mask = parseInt(bits, 10); 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 // Convert IP addresses to numeric values
const ipNum = this.ipToNumber(ip); const ipNum = this.ipToNumber(normalizedIp);
const subnetNum = this.ipToNumber(subnet); const subnetNum = this.ipToNumber(normalizedSubnet);
// Calculate subnet mask // Calculate subnet mask
const maskNum = ~(2 ** (32 - mask) - 1); const maskNum = ~(2 ** (32 - mask) - 1);
@ -293,7 +312,10 @@ export class RouteManager extends plugins.EventEmitter {
* Convert an IP address to a numeric value * Convert an IP address to a numeric value
*/ */
private ipToNumber(ip: string): number { private ipToNumber(ip: string): number {
const parts = ip.split('.').map(part => parseInt(part, 10)); // 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]; return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
} }