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
## 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)
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
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.
## IPv6/IPv4 Mapping Issue
## 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:
- 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
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.
## 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
- [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
### Solution
### Phase 2: Native Route Configuration Processing
- [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
To fix this issue, update the route configurations to include both formats of the IP address. Here's how to modify the affected route:
### Phase 3: Simplify NetworkProxyBridge
- [x] 3.1 Update NetworkProxyBridge to directly pass route configs to NetworkProxy
- [x] 3.2 Remove all translation/conversion logic in the bridge
- [x] 3.3 Simplify domain registration from routes to Port80Handler
- [x] 3.4 Make the bridge a lightweight pass-through component
- [x] 3.5 Add comprehensive logging for route synchronization
- [x] 3.6 Streamline certificate handling between components
```typescript
// Wildcard domain route for *.lossless.digital
{
match: {
ports: 443,
domains: ['*.lossless.digital'],
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
- [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
### Alternative Long-Term Fix
### Phase 5: Enhanced HTTP Features Using Route Logic
- [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
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:
### Phase 6: Testing, Documentation and Code Sharing
- [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
1. Modifying the `matchIpPattern` function in `route-manager.ts` to normalize IPv6-mapped IPv4 addresses:
### Phase 7: Unify Component Architecture
- [x] 7.1 Implement a shared RouteManager used by both SmartProxy and NetworkProxy
- [x] 7.2 Extract common route matching logic to a shared utility module
- [x] 7.3 Consolidate duplicate security management code
- [x] 7.4 Remove all legacy NetworkProxyBridge conversion code
- [x] 7.5 Make the NetworkProxyBridge a pure proxy pass-through component
- [x] 7.6 Standardize event naming and handling across components
```typescript
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 (normalizedPattern === normalizedIp) {
return true;
}
// Rest of the existing function...
}
```
### Phase 8: Certificate Management Consolidation
- [x] 8.1 Create a unified CertificateManager component
- [x] 8.2 Centralize certificate storage and renewal logic
- [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
2. Making similar modifications to other IP-related functions in the codebase.
### Phase 9: Context and Configuration Standardization
- [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
## Wild Card Domain Matching Issue
### Phase 10: Component Consolidation
- [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
### Explanation
### Phase 11: Performance Optimization & Advanced Features
- [ ] 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
The wildcard domain matching in SmartProxy works as follows:
## Benefits of Simplified Architecture
1. When a pattern like `*.lossless.digital` is specified, it's converted to a regex: `/^.*\.lossless\.digital$/i`
2. This correctly matches any subdomain like `my.lossless.digital`, `api.lossless.digital`, etc.
3. However, it does NOT match the apex domain `lossless.digital` (without a subdomain)
1. **Reduced Duplication**:
- Shared route processing logic
- Single certificate management system
- Unified context objects
If you need to match both the apex domain and subdomains, use a list:
```typescript
domains: ['lossless.digital', '*.lossless.digital']
```
2. **Simplified Codebase**:
- Fewer managers with cleaner responsibilities
- Consistent APIs across components
- Reduced complexity in bridge components
## Debugging SmartProxy
3. **Improved Maintainability**:
- Easier to understand component relationships
- Consolidated logic for critical operations
- Clearer separation of concerns
To debug routing issues in SmartProxy:
4. **Enhanced Performance**:
- Less overhead in communication between components
- Reduced memory usage through shared objects
- More efficient request processing
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
5. **Better Developer Experience**:
- Consistent conceptual model across system
- More intuitive configuration interface
- Simplified debugging and troubleshooting
2. Run the proxy with debugging enabled:
```
pnpm run startNew
```
## Implementation Approach
3. Monitor the logs for detailed information about the routing process and identify where matches are failing.
The implementation of phases 7-10 will focus on gradually consolidating duplicate functionality:
## Priority and Route Order
1. First, implement shared managers and utilities to be used by both proxies
2. Then consolidate certificate management to simplify ACME handling
3. Create standardized context objects and configurations
4. Finally, merge overlapping functionality between proxy components
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.
This approach will maintain compatibility with existing code while progressively simplifying the architecture to reduce complexity and improve performance.
When routes have the same priority (or none specified), they're evaluated in the order they're defined in the configuration.

View File

@ -1,202 +1,207 @@
import { expect } from '@push.rocks/tapbundle';
import { expect, tap } from '@push.rocks/tapbundle';
import {
EventSystem,
ProxyEvents,
ComponentType
} from '../../../ts/core/utils/event-system.js';
// Test event system
expect.describe('Event System', async () => {
let eventSystem: EventSystem;
let receivedEvents: any[] = [];
// Setup function for creating a new event system
function setupEventSystem(): { eventSystem: EventSystem, receivedEvents: any[] } {
const eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
const receivedEvents: any[] = [];
return { eventSystem, receivedEvents };
}
tap.test('Event System - certificate events with correct structure', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up a new event system before each test
expect.beforeEach(() => {
eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
receivedEvents = [];
// Set up listeners
eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => {
receivedEvents.push({
type: 'issued',
data
});
});
expect.it('should emit certificate events with correct structure', async () => {
// Set up listeners
eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => {
receivedEvents.push({
type: 'issued',
data
});
eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => {
receivedEvents.push({
type: 'renewed',
data
});
eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => {
receivedEvents.push({
type: 'renewed',
data
});
});
// Emit events
eventSystem.emitCertificateIssued({
domain: 'example.com',
certificate: 'cert-content',
privateKey: 'key-content',
expiryDate: new Date('2025-01-01')
});
eventSystem.emitCertificateRenewed({
domain: 'example.com',
certificate: 'new-cert-content',
privateKey: 'new-key-content',
expiryDate: new Date('2026-01-01'),
isRenewal: true
});
// Verify events
expect(receivedEvents.length).to.equal(2);
// Check issuance event
expect(receivedEvents[0].type).to.equal('issued');
expect(receivedEvents[0].data.domain).to.equal('example.com');
expect(receivedEvents[0].data.certificate).to.equal('cert-content');
expect(receivedEvents[0].data.componentType).to.equal(ComponentType.SMART_PROXY);
expect(receivedEvents[0].data.componentId).to.equal('test-id');
expect(receivedEvents[0].data.timestamp).to.be.a('number');
// Check renewal event
expect(receivedEvents[1].type).to.equal('renewed');
expect(receivedEvents[1].data.domain).to.equal('example.com');
expect(receivedEvents[1].data.isRenewal).to.be.true;
expect(receivedEvents[1].data.expiryDate).to.deep.equal(new Date('2026-01-01'));
});
expect.it('should emit component lifecycle events', async () => {
// Set up listeners
eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => {
receivedEvents.push({
type: 'started',
data
});
});
eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => {
receivedEvents.push({
type: 'stopped',
data
});
});
// Emit events
eventSystem.emitComponentStarted('TestComponent', '1.0.0');
eventSystem.emitComponentStopped('TestComponent');
// Verify events
expect(receivedEvents.length).to.equal(2);
// Check started event
expect(receivedEvents[0].type).to.equal('started');
expect(receivedEvents[0].data.name).to.equal('TestComponent');
expect(receivedEvents[0].data.version).to.equal('1.0.0');
// Check stopped event
expect(receivedEvents[1].type).to.equal('stopped');
expect(receivedEvents[1].data.name).to.equal('TestComponent');
// Emit events
eventSystem.emitCertificateIssued({
domain: 'example.com',
certificate: 'cert-content',
privateKey: 'key-content',
expiryDate: new Date('2025-01-01')
});
expect.it('should emit connection events', async () => {
// Set up listeners
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({
type: 'established',
data
});
});
eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => {
receivedEvents.push({
type: 'closed',
data
});
});
// Emit events
eventSystem.emitConnectionEstablished({
connectionId: 'conn-123',
clientIp: '192.168.1.1',
port: 443,
isTls: true,
domain: 'example.com'
});
eventSystem.emitConnectionClosed({
connectionId: 'conn-123',
clientIp: '192.168.1.1',
port: 443
});
// Verify events
expect(receivedEvents.length).to.equal(2);
// Check established event
expect(receivedEvents[0].type).to.equal('established');
expect(receivedEvents[0].data.connectionId).to.equal('conn-123');
expect(receivedEvents[0].data.clientIp).to.equal('192.168.1.1');
expect(receivedEvents[0].data.port).to.equal(443);
expect(receivedEvents[0].data.isTls).to.be.true;
// Check closed event
expect(receivedEvents[1].type).to.equal('closed');
expect(receivedEvents[1].data.connectionId).to.equal('conn-123');
eventSystem.emitCertificateRenewed({
domain: 'example.com',
certificate: 'new-cert-content',
privateKey: 'new-key-content',
expiryDate: new Date('2026-01-01'),
isRenewal: true
});
expect.it('should support once and off subscription methods', async () => {
// Set up a listener that should fire only once
eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({
type: 'once',
data
});
// Verify events
expect(receivedEvents.length).toEqual(2);
// Check issuance event
expect(receivedEvents[0].type).toEqual('issued');
expect(receivedEvents[0].data.domain).toEqual('example.com');
expect(receivedEvents[0].data.certificate).toEqual('cert-content');
expect(receivedEvents[0].data.componentType).toEqual(ComponentType.SMART_PROXY);
expect(receivedEvents[0].data.componentId).toEqual('test-id');
expect(typeof receivedEvents[0].data.timestamp).toEqual('number');
// Check renewal event
expect(receivedEvents[1].type).toEqual('renewed');
expect(receivedEvents[1].data.domain).toEqual('example.com');
expect(receivedEvents[1].data.isRenewal).toEqual(true);
expect(receivedEvents[1].data.expiryDate).toEqual(new Date('2026-01-01'));
});
tap.test('Event System - component lifecycle events', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up listeners
eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => {
receivedEvents.push({
type: 'started',
data
});
// Set up a persistent listener
const persistentHandler = (data: any) => {
receivedEvents.push({
type: 'persistent',
data
});
};
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
// First event should trigger both listeners
eventSystem.emitConnectionEstablished({
connectionId: 'conn-1',
clientIp: '192.168.1.1',
port: 443
});
// Second event should only trigger the persistent listener
eventSystem.emitConnectionEstablished({
connectionId: 'conn-2',
clientIp: '192.168.1.1',
port: 443
});
// Unsubscribe the persistent listener
eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
// Third event should not trigger any listeners
eventSystem.emitConnectionEstablished({
connectionId: 'conn-3',
clientIp: '192.168.1.1',
port: 443
});
// Verify events
expect(receivedEvents.length).to.equal(3);
expect(receivedEvents[0].type).to.equal('once');
expect(receivedEvents[0].data.connectionId).to.equal('conn-1');
expect(receivedEvents[1].type).to.equal('persistent');
expect(receivedEvents[1].data.connectionId).to.equal('conn-1');
expect(receivedEvents[2].type).to.equal('persistent');
expect(receivedEvents[2].data.connectionId).to.equal('conn-2');
});
});
eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => {
receivedEvents.push({
type: 'stopped',
data
});
});
// Emit events
eventSystem.emitComponentStarted('TestComponent', '1.0.0');
eventSystem.emitComponentStopped('TestComponent');
// Verify events
expect(receivedEvents.length).toEqual(2);
// Check started event
expect(receivedEvents[0].type).toEqual('started');
expect(receivedEvents[0].data.name).toEqual('TestComponent');
expect(receivedEvents[0].data.version).toEqual('1.0.0');
// Check stopped event
expect(receivedEvents[1].type).toEqual('stopped');
expect(receivedEvents[1].data.name).toEqual('TestComponent');
});
tap.test('Event System - connection events', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up listeners
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({
type: 'established',
data
});
});
eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => {
receivedEvents.push({
type: 'closed',
data
});
});
// Emit events
eventSystem.emitConnectionEstablished({
connectionId: 'conn-123',
clientIp: '192.168.1.1',
port: 443,
isTls: true,
domain: 'example.com'
});
eventSystem.emitConnectionClosed({
connectionId: 'conn-123',
clientIp: '192.168.1.1',
port: 443
});
// Verify events
expect(receivedEvents.length).toEqual(2);
// Check established event
expect(receivedEvents[0].type).toEqual('established');
expect(receivedEvents[0].data.connectionId).toEqual('conn-123');
expect(receivedEvents[0].data.clientIp).toEqual('192.168.1.1');
expect(receivedEvents[0].data.port).toEqual(443);
expect(receivedEvents[0].data.isTls).toEqual(true);
// Check closed event
expect(receivedEvents[1].type).toEqual('closed');
expect(receivedEvents[1].data.connectionId).toEqual('conn-123');
});
tap.test('Event System - once and off subscription methods', async () => {
const { eventSystem, receivedEvents } = setupEventSystem();
// Set up a listener that should fire only once
eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
receivedEvents.push({
type: 'once',
data
});
});
// Set up a persistent listener
const persistentHandler = (data: any) => {
receivedEvents.push({
type: 'persistent',
data
});
};
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
// First event should trigger both listeners
eventSystem.emitConnectionEstablished({
connectionId: 'conn-1',
clientIp: '192.168.1.1',
port: 443
});
// Second event should only trigger the persistent listener
eventSystem.emitConnectionEstablished({
connectionId: 'conn-2',
clientIp: '192.168.1.1',
port: 443
});
// Unsubscribe the persistent listener
eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
// Third event should not trigger any listeners
eventSystem.emitConnectionEstablished({
connectionId: 'conn-3',
clientIp: '192.168.1.1',
port: 443
});
// Verify events
expect(receivedEvents.length).toEqual(3);
expect(receivedEvents[0].type).toEqual('once');
expect(receivedEvents[0].data.connectionId).toEqual('conn-1');
expect(receivedEvents[1].type).toEqual('persistent');
expect(receivedEvents[1].data.connectionId).toEqual('conn-1');
expect(receivedEvents[2].type).toEqual('persistent');
expect(receivedEvents[2].data.connectionId).toEqual('conn-2');
});
export default tap.start();

View File

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

View File

@ -1,24 +1,22 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import { NetworkProxy } from '../ts/proxies/network-proxy/index.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.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));
// Declare variables for tests
let networkProxy: NetworkProxy;
let testServer: http.Server;
let testServerHttp2: http2.Http2Server;
let testServer: plugins.http.Server;
let testServerHttp2: plugins.http2.Http2Server;
let serverPort: number;
let serverPortHttp2: number;
// Setup test environment
tap.test('setup NetworkProxy function-based targets test environment', async () => {
// 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.end(JSON.stringify({
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
testServerHttp2 = http2.createServer();
testServerHttp2 = plugins.http2.createServer();
testServerHttp2.on('stream', (stream, headers) => {
stream.respond({
'content-type': 'application/json',
@ -82,10 +80,10 @@ tap.test('should support static host/port routes', async () => {
const routes: IRouteConfig[] = [
{
name: 'static-route',
domain: 'example.com',
priority: 100,
match: {
domain: 'example.com'
domains: 'example.com',
ports: 0
},
action: {
type: 'forward',
@ -124,10 +122,10 @@ tap.test('should support function-based host', async () => {
const routes: IRouteConfig[] = [
{
name: 'function-host-route',
domain: 'function.example.com',
priority: 100,
match: {
domain: 'function.example.com'
domains: 'function.example.com',
ports: 0
},
action: {
type: 'forward',
@ -169,10 +167,10 @@ tap.test('should support function-based port', async () => {
const routes: IRouteConfig[] = [
{
name: 'function-port-route',
domain: 'function-port.example.com',
priority: 100,
match: {
domain: 'function-port.example.com'
domains: 'function-port.example.com',
ports: 0
},
action: {
type: 'forward',
@ -214,10 +212,10 @@ tap.test('should support function-based host AND port', async () => {
const routes: IRouteConfig[] = [
{
name: 'function-both-route',
domain: 'function-both.example.com',
priority: 100,
match: {
domain: 'function-both.example.com'
domains: 'function-both.example.com',
ports: 0
},
action: {
type: 'forward',
@ -260,10 +258,10 @@ tap.test('should support context-based routing with path', async () => {
const routes: IRouteConfig[] = [
{
name: 'context-path-route',
domain: 'context.example.com',
priority: 100,
match: {
domain: 'context.example.com'
domains: 'context.example.com',
ports: 0
},
action: {
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
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) => {
// Use HTTPS with rejectUnauthorized: false to accept self-signed certificates
const req = https.request({
const req = plugins.https.request({
...options,
rejectUnauthorized: false, // Accept self-signed certificates
}, (res) => {

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
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.'
}

View File

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

View File

@ -13,8 +13,8 @@
* @returns Whether the domain matches the pattern
*/
export function matchDomain(pattern: string, domain: string): boolean {
// Handle exact match
if (pattern === domain) {
// Handle exact match (case-insensitive)
if (pattern.toLowerCase() === domain.toLowerCase()) {
return true;
}
@ -139,9 +139,13 @@ export function matchIpCidr(cidr: string, ip: string): boolean {
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(ip);
const subnetNum = ipToNumber(subnet);
const ipNum = ipToNumber(normalizedIp);
const subnetNum = ipToNumber(normalizedSubnet);
// Calculate subnet mask
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
*/
export function matchIpPattern(pattern: string, ip: string): boolean {
// Handle exact match
if (pattern === ip) {
// 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 === '*') {
if (pattern === '*' || normalizedPattern === '*') {
return true;
}
// Handle CIDR notation (e.g., 192.168.1.0/24)
if (pattern.includes('/')) {
return matchIpCidr(pattern, ip);
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}$`);
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;

View File

@ -291,12 +291,15 @@ export class RouteManager {
/**
* Match an IP pattern against an IP
* Supports exact matches, wildcard patterns, and CIDR notation
*/
private matchIp(pattern: string, ip: string): boolean {
// Exact match
if (pattern === ip) {
return true;
}
// Wildcard matching (e.g., 192.168.0.*)
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/\./g, '\\.')
@ -306,10 +309,65 @@ export class RouteManager {
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;
}
/**
* 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`);
}
/**
* @deprecated Use updateRouteConfigs instead
* 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;
}
// Legacy methods have been removed.
// Please use updateRouteConfigs() directly with modern route-based configuration.
/**
* Adds default headers to be included in all responses
@ -650,62 +590,4 @@ export class NetworkProxy implements IMetricsTracker {
public getRouteConfigs(): IRouteConfig[] {
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;
}
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
// 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
// Additional properties
@ -68,8 +68,8 @@ export interface IRouteContext {
* Target configuration for forwarding
*/
export interface IRouteTarget {
host: string | string[] | ((context: any) => string | string[]); // Support static or dynamic host selection with any compatible context
port: number | ((context: any) => number); // Support static or dynamic port mapping with any compatible context
host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution
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)
}
@ -108,7 +108,8 @@ export interface IRouteAuthentication {
oauthClientId?: string;
oauthClientSecret?: 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
*/
private matchIpPattern(pattern: string, ip: string): boolean {
// Handle exact match
if (pattern === ip) {
// 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, ip);
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}$`);
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;
@ -274,9 +289,13 @@ export class RouteManager extends plugins.EventEmitter {
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(ip);
const subnetNum = this.ipToNumber(subnet);
const ipNum = this.ipToNumber(normalizedIp);
const subnetNum = this.ipToNumber(normalizedSubnet);
// Calculate subnet mask
const maskNum = ~(2 ** (32 - mask) - 1);
@ -293,7 +312,10 @@ export class RouteManager extends plugins.EventEmitter {
* Convert an IP address to a numeric value
*/
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];
}