start fixing tests

This commit is contained in:
Juergen Kunz 2025-06-06 07:40:59 +00:00
parent 18d79ac7e1
commit b9be6533ae
15 changed files with 330 additions and 477 deletions

View File

@ -1,5 +1,5 @@
{
"expiryDate": "2025-09-01T06:26:42.172Z",
"issueDate": "2025-06-03T06:26:42.172Z",
"savedAt": "2025-06-03T06:26:42.172Z"
"expiryDate": "2025-09-03T17:57:28.583Z",
"issueDate": "2025-06-05T17:57:28.583Z",
"savedAt": "2025-06-05T17:57:28.583Z"
}

View File

@ -802,4 +802,51 @@ class ProxyProtocolSocket {
📋 **Consider full WrappedSocket for future major version**
- Cleaner architecture
- Better maintainability
- But requires significant refactoring
- But requires significant refactoring
## WrappedSocket Implementation (PROXY Protocol Phase 1) - v19.5.19+
The WrappedSocket class has been implemented as the foundation for PROXY protocol support:
### Implementation Details
1. **Design Approach**: Uses JavaScript Proxy to delegate all Socket methods/properties to the underlying socket while allowing override of specific properties (remoteAddress, remotePort).
2. **Key Design Decisions**:
- NOT a Duplex stream - Initially tried this approach but it created infinite loops
- Simple wrapper using Proxy pattern for transparent delegation
- All sockets are wrapped, not just those from trusted proxies
- Trusted proxy detection happens after wrapping
3. **Usage Pattern**:
```typescript
// In RouteConnectionHandler.handleConnection()
const wrappedSocket = new WrappedSocket(socket);
// Pass wrappedSocket throughout the flow
// When calling socket-utils functions, extract underlying socket:
const underlyingSocket = getUnderlyingSocket(socket);
setupBidirectionalForwarding(underlyingSocket, targetSocket, {...});
```
4. **Important Implementation Notes**:
- Socket utility functions (setupBidirectionalForwarding, cleanupSocket) expect raw net.Socket
- Always extract underlying socket before passing to these utilities using `getUnderlyingSocket()`
- WrappedSocket preserves all Socket functionality through Proxy delegation
- TypeScript typing handled via index signature: `[key: string]: any`
5. **Files Modified**:
- `ts/core/models/wrapped-socket.ts` - The WrappedSocket implementation
- `ts/core/models/socket-types.ts` - Helper functions and type guards
- `ts/proxies/smart-proxy/route-connection-handler.ts` - Updated to wrap all incoming sockets
- `ts/proxies/smart-proxy/connection-manager.ts` - Updated to accept WrappedSocket
- `ts/proxies/smart-proxy/http-proxy-bridge.ts` - Updated to handle WrappedSocket
6. **Test Coverage**:
- `test/test.wrapped-socket-forwarding.ts` - Verifies data forwarding through wrapped sockets
### Next Steps for PROXY Protocol
- Phase 2: Parse PROXY protocol header from trusted proxies
- Phase 3: Update real client IP/port after parsing
- Phase 4: Test with HAProxy and AWS ELB
- Phase 5: Documentation and configuration

View File

@ -70,14 +70,14 @@ interface IRouteAction {
#### IMPORTANT: Phase 1 Must Be Completed First
The `ProxyProtocolSocket` (WrappedSocket) is the foundation for all PROXY protocol functionality. This wrapper class must be implemented and integrated BEFORE any PROXY protocol parsing can begin.
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED (v19.5.19)
This phase creates the socket wrapper infrastructure that all subsequent phases depend on.
1. **Create WrappedSocket class** in `ts/core/models/wrapped-socket.ts`
- Basic socket wrapper that extends EventEmitter
- Used JavaScript Proxy pattern instead of EventEmitter (avoids infinite loops)
- Properties for real client IP and port
- Transparent getters that return real or socket IP/port
- Pass-through methods for all socket operations
- All socket methods/properties delegated via Proxy
2. **Implement core wrapper functionality**
- Constructor accepts regular socket + optional metadata
@ -88,14 +88,13 @@ This phase creates the socket wrapper infrastructure that all subsequent phases
3. **Update ConnectionManager to handle wrapped sockets**
- Accept either `net.Socket` or `WrappedSocket`
- Use duck typing or instanceof checks
- Ensure all IP lookups use the getter methods
- Created `getUnderlyingSocket()` helper for socket utilities
- All socket utility functions extract underlying socket
4. **Create comprehensive tests**
- Test wrapper with and without proxy info
- Verify getter fallback behavior
- Test event forwarding
- Test socket method pass-through
4. **Integration completed**
- All incoming sockets wrapped in RouteConnectionHandler
- Socket forwarding verified working with wrapped sockets
- Type safety maintained with index signature
**Deliverables**: ✅ Working WrappedSocket that can wrap any socket and provide transparent access to client info.

View File

@ -94,12 +94,13 @@ tap.test('PathMatcher - findAllMatches', async () => {
const matches = PathMatcher.findAllMatches(patterns, '/api/users/123/profile');
// All patterns should match (including /api/users as a prefix match)
expect(matches).toHaveLength(5);
// With the stricter path matching, /api/users won't match /api/users/123/profile
// Only patterns with wildcards, parameters, or exact matches will work
expect(matches).toHaveLength(4);
// Verify all expected patterns are in the results
const matchedPatterns = matches.map(m => m.pattern);
expect(matchedPatterns).toContain('/api/users');
expect(matchedPatterns).not.toContain('/api/users'); // This won't match anymore (no prefix matching)
expect(matchedPatterns).toContain('/api/users/:id');
expect(matchedPatterns).toContain('/api/users/:id/profile');
expect(matchedPatterns).toContain('/api/*');

View File

@ -1,110 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as routeUtils from '../../../ts/core/utils/route-utils.js';
// Test domain matching
tap.test('Route Utils - Domain Matching - exact domains', async () => {
expect(routeUtils.matchDomain('example.com', 'example.com')).toEqual(true);
});
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);
});
tap.test('Route Utils - Domain Matching - case insensitivity', async () => {
expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).toEqual(true);
});
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
tap.test('Route Utils - Path Matching - exact paths', async () => {
expect(routeUtils.matchPath('/api/users', '/api/users')).toEqual(true);
});
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);
});
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
tap.test('Route Utils - IP Matching - exact IPs', async () => {
expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).toEqual(true);
});
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);
});
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);
});
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);
});
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
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

@ -54,7 +54,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
findMatchingRoute: (criteria: any) => ({
route: mockSettings.routes[0]
}),
getAllRoutes: () => mockSettings.routes,
getRoutes: () => mockSettings.routes,
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
return ports.some(p => {
@ -182,7 +182,7 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
findMatchingRoute: (criteria: any) => ({
route: mockSettings.routes[0]
}),
getAllRoutes: () => mockSettings.routes,
getRoutes: () => mockSettings.routes,
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
return ports.some(p => {

View File

@ -34,6 +34,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
};
proxy['httpProxyBridge'].stop = async () => {
console.log('Mock: HttpProxyBridge stopped');
return Promise.resolve(); // Ensure it returns a resolved promise
};
await proxy.start();
@ -44,11 +45,14 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
forwardedToHttpProxy = true;
connectionPath = 'httpproxy';
console.log('Mock: Connection forwarded to HttpProxy with args:', args[0], 'on port:', args[2]?.localPort);
// Just close the connection for the test
args[1].end(); // socket.end()
// Properly close the connection for the test
const socket = args[1];
socket.end();
socket.destroy();
};
// No need to mock getHttpProxy - the bridge already handles HttpProxy availability
// Mock getHttpProxy to indicate HttpProxy is available
(proxy as any).httpProxyBridge.getHttpProxy = () => ({ available: true });
// Make a connection to port 8080
const client = new net.Socket();
@ -73,13 +77,16 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
expect(connectionPath).toEqual('httpproxy');
client.destroy();
// Restore original method before stopping
(proxy as any).httpProxyBridge.forwardToHttpProxy = originalForward;
console.log('About to stop proxy...');
await proxy.stop();
console.log('Proxy stopped');
// Wait a bit to ensure port is released
await new Promise(resolve => setTimeout(resolve, 100));
// Restore original method
(proxy as any).httpProxyBridge.forwardToHttpProxy = originalForward;
});
// Test that verifies the fix detects non-TLS connections
@ -123,8 +130,10 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
proxy['httpProxyBridge'].forwardToHttpProxy = async function(...args: any[]) {
httpProxyForwardCalled = true;
console.log('HttpProxy forward called with connectionId:', args[0]);
// Just end the connection
args[1].end();
// Properly close the connection
const socket = args[1];
socket.end();
socket.destroy();
};
// Mock HttpProxyBridge methods
@ -136,6 +145,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
};
proxy['httpProxyBridge'].stop = async () => {
console.log('Mock: HttpProxyBridge stopped');
return Promise.resolve(); // Ensure it returns a resolved promise
};
// Mock getHttpProxy to return a truthy value

View File

@ -63,9 +63,21 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
}
};
console.log('Making HTTP request to proxy...');
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.request(options, (res) => resolve(res));
req.on('error', reject);
const req = http.request(options, (res) => {
console.log('Got response from proxy:', res.statusCode);
resolve(res);
});
req.on('error', (err) => {
console.error('Request error:', err);
reject(err);
});
req.setTimeout(5000, () => {
console.error('Request timeout');
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
@ -85,6 +97,9 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
await new Promise<void>((resolve) => {
targetServer.close(() => resolve());
});
// Wait a bit to ensure port is fully released
await new Promise(resolve => setTimeout(resolve, 500));
});
tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
@ -135,15 +150,30 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
}
};
console.log('Making HTTP request to proxy...');
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.request(options, (res) => resolve(res));
req.on('error', reject);
const req = http.request(options, (res) => {
console.log('Got response from proxy:', res.statusCode);
resolve(res);
});
req.on('error', (err) => {
console.error('Request error:', err);
reject(err);
});
req.setTimeout(5000, () => {
console.error('Request timeout');
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
let responseData = '';
response.setEncoding('utf8');
response.on('data', chunk => responseData += chunk);
response.on('data', chunk => {
console.log('Received data chunk:', chunk);
responseData += chunk;
});
await new Promise(resolve => response.on('end', resolve));
expect(response.statusCode).toEqual(200);
@ -154,6 +184,9 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
await new Promise<void>((resolve) => {
targetServer.close(() => resolve());
});
// Wait a bit to ensure port is fully released
await new Promise(resolve => setTimeout(resolve, 500));
});
tap.start();
export default tap.start();

View File

@ -82,13 +82,16 @@ tap.test('setup HttpProxy function-based targets test environment', async (tools
// Test static host/port routes
tap.test('should support static host/port routes', async () => {
// Get proxy port first
const proxyPort = httpProxy.getListeningPort();
const routes: IRouteConfig[] = [
{
name: 'static-route',
priority: 100,
match: {
domains: 'example.com',
ports: 0
ports: proxyPort
},
action: {
type: 'forward',
@ -102,9 +105,6 @@ tap.test('should support static host/port routes', async () => {
await httpProxy.updateRouteConfigs(routes);
// Get proxy port using the improved getListeningPort() method
const proxyPort = httpProxy.getListeningPort();
// Make request to proxy
const response = await makeRequest({
hostname: 'localhost',
@ -124,13 +124,14 @@ tap.test('should support static host/port routes', async () => {
// Test function-based host
tap.test('should support function-based host', async () => {
const proxyPort = httpProxy.getListeningPort();
const routes: IRouteConfig[] = [
{
name: 'function-host-route',
priority: 100,
match: {
domains: 'function.example.com',
ports: 0
ports: proxyPort
},
action: {
type: 'forward',
@ -147,9 +148,6 @@ tap.test('should support function-based host', async () => {
await httpProxy.updateRouteConfigs(routes);
// Get proxy port using the improved getListeningPort() method
const proxyPort = httpProxy.getListeningPort();
// Make request to proxy
const response = await makeRequest({
hostname: 'localhost',
@ -169,13 +167,14 @@ tap.test('should support function-based host', async () => {
// Test function-based port
tap.test('should support function-based port', async () => {
const proxyPort = httpProxy.getListeningPort();
const routes: IRouteConfig[] = [
{
name: 'function-port-route',
priority: 100,
match: {
domains: 'function-port.example.com',
ports: 0
ports: proxyPort
},
action: {
type: 'forward',
@ -192,9 +191,6 @@ tap.test('should support function-based port', async () => {
await httpProxy.updateRouteConfigs(routes);
// Get proxy port using the improved getListeningPort() method
const proxyPort = httpProxy.getListeningPort();
// Make request to proxy
const response = await makeRequest({
hostname: 'localhost',
@ -214,13 +210,14 @@ tap.test('should support function-based port', async () => {
// Test function-based host AND port
tap.test('should support function-based host AND port', async () => {
const proxyPort = httpProxy.getListeningPort();
const routes: IRouteConfig[] = [
{
name: 'function-both-route',
priority: 100,
match: {
domains: 'function-both.example.com',
ports: 0
ports: proxyPort
},
action: {
type: 'forward',
@ -238,9 +235,6 @@ tap.test('should support function-based host AND port', async () => {
await httpProxy.updateRouteConfigs(routes);
// Get proxy port using the improved getListeningPort() method
const proxyPort = httpProxy.getListeningPort();
// Make request to proxy
const response = await makeRequest({
hostname: 'localhost',
@ -260,13 +254,14 @@ tap.test('should support function-based host AND port', async () => {
// Test context-based routing with path
tap.test('should support context-based routing with path', async () => {
const proxyPort = httpProxy.getListeningPort();
const routes: IRouteConfig[] = [
{
name: 'context-path-route',
priority: 100,
match: {
domains: 'context.example.com',
ports: 0
ports: proxyPort
},
action: {
type: 'forward',
@ -287,9 +282,6 @@ tap.test('should support context-based routing with path', async () => {
await httpProxy.updateRouteConfigs(routes);
// Get proxy port using the improved getListeningPort() method
const proxyPort = httpProxy.getListeningPort();
// Make request to proxy with /api path
const apiResponse = await makeRequest({
hostname: 'localhost',

View File

@ -1,10 +1,10 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as tsclass from '@tsclass/tsclass';
import * as http from 'http';
import { ProxyRouter, type RouterResult } from '../ts/routing/router/proxy-router.js';
import { HttpRouter, type RouterResult } from '../ts/routing/router/http-router.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
// Test proxies and configurations
let router: ProxyRouter;
let router: HttpRouter;
// Sample hostname for testing
const TEST_DOMAIN = 'example.com';
@ -23,33 +23,40 @@ function createMockRequest(host: string, url: string = '/'): http.IncomingMessag
return req;
}
// Helper: Creates a test proxy configuration
function createProxyConfig(
// Helper: Creates a test route configuration
function createRouteConfig(
hostname: string,
destinationIp: string = '10.0.0.1',
destinationPort: number = 8080
): tsclass.network.IReverseProxyConfig {
): IRouteConfig {
return {
hostName: hostname,
publicKey: 'mock-cert',
privateKey: 'mock-key',
destinationIps: [destinationIp],
destinationPorts: [destinationPort],
} as tsclass.network.IReverseProxyConfig;
name: `route-${hostname}`,
match: {
domains: [hostname],
ports: 443
},
action: {
type: 'forward',
target: {
host: destinationIp,
port: destinationPort
}
}
};
}
// SETUP: Create a ProxyRouter instance
tap.test('setup proxy router test environment', async () => {
router = new ProxyRouter();
// SETUP: Create an HttpRouter instance
tap.test('setup http router test environment', async () => {
router = new HttpRouter();
// Initialize with empty config
router.setNewProxyConfigs([]);
router.updateRoutes([]);
});
// Test basic routing by hostname
tap.test('should route requests by hostname', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const config = createRouteConfig(TEST_DOMAIN);
router.updateRoutes([config]);
const req = createMockRequest(TEST_DOMAIN);
const result = router.routeReq(req);
@ -60,8 +67,8 @@ tap.test('should route requests by hostname', async () => {
// Test handling of hostname with port number
tap.test('should handle hostname with port number', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const config = createRouteConfig(TEST_DOMAIN);
router.updateRoutes([config]);
const req = createMockRequest(`${TEST_DOMAIN}:443`);
const result = router.routeReq(req);
@ -72,8 +79,8 @@ tap.test('should handle hostname with port number', async () => {
// Test case-insensitive hostname matching
tap.test('should perform case-insensitive hostname matching', async () => {
const config = createProxyConfig(TEST_DOMAIN.toLowerCase());
router.setNewProxyConfigs([config]);
const config = createRouteConfig(TEST_DOMAIN.toLowerCase());
router.updateRoutes([config]);
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
const result = router.routeReq(req);
@ -84,8 +91,8 @@ tap.test('should perform case-insensitive hostname matching', async () => {
// Test handling of unmatched hostnames
tap.test('should return undefined for unmatched hostnames', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const config = createRouteConfig(TEST_DOMAIN);
router.updateRoutes([config]);
const req = createMockRequest('unknown.domain.com');
const result = router.routeReq(req);
@ -95,18 +102,16 @@ tap.test('should return undefined for unmatched hostnames', async () => {
// Test adding path patterns
tap.test('should match requests using path patterns', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
// Add a path pattern to the config
router.setPathPattern(config, '/api/users');
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/users';
router.updateRoutes([config]);
// Test that path matches
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
const result1 = router.routeReqWithDetails(req1);
expect(result1).toBeTruthy();
expect(result1.config).toEqual(config);
expect(result1.route).toEqual(config);
expect(result1.pathMatch).toEqual('/api/users');
// Test that non-matching path doesn't match
@ -118,17 +123,16 @@ tap.test('should match requests using path patterns', async () => {
// Test handling wildcard patterns
tap.test('should support wildcard path patterns', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/api/*');
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/*';
router.updateRoutes([config]);
// Test with path that matches the wildcard pattern
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.route).toEqual(config);
expect(result.pathMatch).toEqual('/api');
// Print the actual value to diagnose issues
@ -139,31 +143,31 @@ tap.test('should support wildcard path patterns', async () => {
// Test extracting path parameters
tap.test('should extract path parameters from URL', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/users/:id/profile');
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/users/:id/profile';
router.updateRoutes([config]);
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.route).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.id).toEqual('123');
});
// Test multiple configs for same hostname with different paths
tap.test('should support multiple configs for same hostname with different paths', async () => {
const apiConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
const webConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
const apiConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
apiConfig.match.path = '/api';
apiConfig.name = 'api-route';
const webConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
webConfig.match.path = '/web';
webConfig.name = 'web-route';
// Add both configs
router.setNewProxyConfigs([apiConfig, webConfig]);
// Set different path patterns
router.setPathPattern(apiConfig, '/api');
router.setPathPattern(webConfig, '/web');
router.updateRoutes([apiConfig, webConfig]);
// Test API path routes to API config
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
@ -186,8 +190,8 @@ tap.test('should support multiple configs for same hostname with different paths
// Test wildcard subdomains
tap.test('should match wildcard subdomains', async () => {
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
router.setNewProxyConfigs([wildcardConfig]);
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
router.updateRoutes([wildcardConfig]);
// Test that subdomain.example.com matches *.example.com
const req = createMockRequest('subdomain.example.com');
@ -199,8 +203,8 @@ tap.test('should match wildcard subdomains', async () => {
// Test TLD wildcards (example.*)
tap.test('should match TLD wildcards', async () => {
const tldWildcardConfig = createProxyConfig('example.*');
router.setNewProxyConfigs([tldWildcardConfig]);
const tldWildcardConfig = createRouteConfig('example.*');
router.updateRoutes([tldWildcardConfig]);
// Test that example.com matches example.*
const req1 = createMockRequest('example.com');
@ -222,8 +226,8 @@ tap.test('should match TLD wildcards', async () => {
// Test complex pattern matching (*.lossless*)
tap.test('should match complex wildcard patterns', async () => {
const complexWildcardConfig = createProxyConfig('*.lossless*');
router.setNewProxyConfigs([complexWildcardConfig]);
const complexWildcardConfig = createRouteConfig('*.lossless*');
router.updateRoutes([complexWildcardConfig]);
// Test that sub.lossless.com matches *.lossless*
const req1 = createMockRequest('sub.lossless.com');
@ -245,10 +249,10 @@ tap.test('should match complex wildcard patterns', async () => {
// Test default configuration fallback
tap.test('should fall back to default configuration', async () => {
const defaultConfig = createProxyConfig('*');
const specificConfig = createProxyConfig(TEST_DOMAIN);
const defaultConfig = createRouteConfig('*');
const specificConfig = createRouteConfig(TEST_DOMAIN);
router.setNewProxyConfigs([defaultConfig, specificConfig]);
router.updateRoutes([defaultConfig, specificConfig]);
// Test specific domain routes to specific config
const specificReq = createMockRequest(TEST_DOMAIN);
@ -265,10 +269,10 @@ tap.test('should fall back to default configuration', async () => {
// Test priority between exact and wildcard matches
tap.test('should prioritize exact hostname over wildcard', async () => {
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
const exactConfig = createProxyConfig(TEST_SUBDOMAIN);
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
const exactConfig = createRouteConfig(TEST_SUBDOMAIN);
router.setNewProxyConfigs([wildcardConfig, exactConfig]);
router.updateRoutes([wildcardConfig, exactConfig]);
// Test that exact match takes priority
const req = createMockRequest(TEST_SUBDOMAIN);
@ -279,11 +283,11 @@ tap.test('should prioritize exact hostname over wildcard', async () => {
// Test adding and removing configurations
tap.test('should manage configurations correctly', async () => {
router.setNewProxyConfigs([]);
router.updateRoutes([]);
// Add a config
const config = createProxyConfig(TEST_DOMAIN);
router.addProxyConfig(config);
const config = createRouteConfig(TEST_DOMAIN);
router.updateRoutes([config]);
// Verify routing works
const req = createMockRequest(TEST_DOMAIN);
@ -292,8 +296,7 @@ tap.test('should manage configurations correctly', async () => {
expect(result).toEqual(config);
// Remove the config and verify it no longer routes
const removed = router.removeProxyConfig(TEST_DOMAIN);
expect(removed).toBeTrue();
router.updateRoutes([]);
result = router.routeReq(req);
expect(result).toBeUndefined();
@ -301,13 +304,16 @@ tap.test('should manage configurations correctly', async () => {
// Test path pattern specificity
tap.test('should prioritize more specific path patterns', async () => {
const genericConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
const specificConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
const genericConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
genericConfig.match.path = '/api/*';
genericConfig.name = 'generic-api';
router.setNewProxyConfigs([genericConfig, specificConfig]);
const specificConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
specificConfig.match.path = '/api/users';
specificConfig.name = 'specific-api';
specificConfig.priority = 10; // Higher priority
router.setPathPattern(genericConfig, '/api/*');
router.setPathPattern(specificConfig, '/api/users');
router.updateRoutes([genericConfig, specificConfig]);
// The more specific '/api/users' should match before the '/api/*' wildcard
const req = createMockRequest(TEST_DOMAIN, '/api/users');
@ -316,24 +322,29 @@ tap.test('should prioritize more specific path patterns', async () => {
expect(result).toEqual(specificConfig);
});
// Test getHostnames method
tap.test('should retrieve all configured hostnames', async () => {
router.setNewProxyConfigs([
createProxyConfig(TEST_DOMAIN),
createProxyConfig(TEST_SUBDOMAIN)
]);
// Test multiple hostnames
tap.test('should handle multiple configured hostnames', async () => {
const routes = [
createRouteConfig(TEST_DOMAIN),
createRouteConfig(TEST_SUBDOMAIN)
];
router.updateRoutes(routes);
const hostnames = router.getHostnames();
// Test first domain routes correctly
const req1 = createMockRequest(TEST_DOMAIN);
const result1 = router.routeReq(req1);
expect(result1).toEqual(routes[0]);
expect(hostnames.length).toEqual(2);
expect(hostnames).toContain(TEST_DOMAIN.toLowerCase());
expect(hostnames).toContain(TEST_SUBDOMAIN.toLowerCase());
// Test second domain routes correctly
const req2 = createMockRequest(TEST_SUBDOMAIN);
const result2 = router.routeReq(req2);
expect(result2).toEqual(routes[1]);
});
// Test handling missing host header
tap.test('should handle missing host header', async () => {
const defaultConfig = createProxyConfig('*');
router.setNewProxyConfigs([defaultConfig]);
const defaultConfig = createRouteConfig('*');
router.updateRoutes([defaultConfig]);
const req = createMockRequest('');
req.headers.host = undefined;
@ -345,16 +356,15 @@ tap.test('should handle missing host header', async () => {
// Test complex path parameters
tap.test('should handle complex path parameters', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/api/:version/users/:userId/posts/:postId');
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/:version/users/:userId/posts/:postId';
router.updateRoutes([config]);
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.route).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.version).toEqual('v1');
expect(result.pathParams.userId).toEqual('123');
@ -367,10 +377,10 @@ tap.test('should handle many configurations efficiently', async () => {
// Create many configs with different hostnames
for (let i = 0; i < 100; i++) {
configs.push(createProxyConfig(`host-${i}.example.com`));
configs.push(createRouteConfig(`host-${i}.example.com`));
}
router.setNewProxyConfigs(configs);
router.updateRoutes(configs);
// Test middle of the list to avoid best/worst case
const req = createMockRequest('host-50.example.com');
@ -382,11 +392,12 @@ tap.test('should handle many configurations efficiently', async () => {
// Test cleanup
tap.test('cleanup proxy router test environment', async () => {
// Clear all configurations
router.setNewProxyConfigs([]);
router.updateRoutes([]);
// Verify empty state
expect(router.getHostnames().length).toEqual(0);
expect(router.getProxyConfigs().length).toEqual(0);
// Verify empty state by testing that no routes match
const req = createMockRequest(TEST_DOMAIN);
const result = router.routeReq(req);
expect(result).toBeUndefined();
});
export default tap.start();

View File

@ -6,3 +6,4 @@ export * from './common-types.js';
export * from './socket-augmentation.js';
export * from './route-context.js';
export * from './wrapped-socket.js';
export * from './socket-types.js';

View File

@ -0,0 +1,21 @@
import * as net from 'net';
import { WrappedSocket } from './wrapped-socket.js';
/**
* Type guard to check if a socket is a WrappedSocket
*/
export function isWrappedSocket(socket: net.Socket | WrappedSocket): socket is WrappedSocket {
return socket instanceof WrappedSocket || 'socket' in socket;
}
/**
* Helper to get the underlying socket from either a Socket or WrappedSocket
*/
export function getUnderlyingSocket(socket: net.Socket | WrappedSocket): net.Socket {
return isWrappedSocket(socket) ? socket.socket : socket;
}
/**
* Type that represents either a regular socket or a wrapped socket
*/
export type AnySocket = net.Socket | WrappedSocket;

View File

@ -1,4 +1,3 @@
import { EventEmitter } from 'events';
import * as plugins from '../../plugins.js';
/**
@ -7,22 +6,66 @@ import * as plugins from '../../plugins.js';
*
* This is the FOUNDATION for all PROXY protocol support and must be implemented
* before any protocol parsing can occur.
*
* This implementation uses a Proxy to delegate all properties and methods
* to the underlying socket while allowing override of specific properties.
*/
export class WrappedSocket extends EventEmitter {
export class WrappedSocket {
public readonly socket: plugins.net.Socket;
private realClientIP?: string;
private realClientPort?: number;
// Make TypeScript happy by declaring the Socket methods that will be proxied
[key: string]: any;
constructor(
public readonly socket: plugins.net.Socket,
socket: plugins.net.Socket,
realClientIP?: string,
realClientPort?: number
) {
super();
this.socket = socket;
this.realClientIP = realClientIP;
this.realClientPort = realClientPort;
// Forward all socket events
this.forwardSocketEvents();
// Create a proxy that delegates everything to the underlying socket
return new Proxy(this, {
get(target, prop, receiver) {
// Override specific properties
if (prop === 'remoteAddress') {
return target.remoteAddress;
}
if (prop === 'remotePort') {
return target.remotePort;
}
if (prop === 'socket') {
return target.socket;
}
if (prop === 'realClientIP') {
return target.realClientIP;
}
if (prop === 'realClientPort') {
return target.realClientPort;
}
if (prop === 'isFromTrustedProxy') {
return target.isFromTrustedProxy;
}
if (prop === 'setProxyInfo') {
return target.setProxyInfo.bind(target);
}
// For all other properties/methods, delegate to the underlying socket
const value = target.socket[prop as keyof plugins.net.Socket];
if (typeof value === 'function') {
return value.bind(target.socket);
}
return value;
},
set(target, prop, value) {
// Set on the underlying socket
(target.socket as any)[prop] = value;
return true;
}
}) as any;
}
/**
@ -39,35 +82,6 @@ export class WrappedSocket extends EventEmitter {
return this.realClientPort || this.socket.remotePort;
}
/**
* Returns the remote family (IPv4 or IPv6)
*/
get remoteFamily(): string | undefined {
// If we have a real client IP, determine the family
if (this.realClientIP) {
if (this.realClientIP.includes(':')) {
return 'IPv6';
} else {
return 'IPv4';
}
}
return this.socket.remoteFamily;
}
/**
* Returns the local address of the socket
*/
get localAddress(): string | undefined {
return this.socket.localAddress;
}
/**
* Returns the local port of the socket
*/
get localPort(): number | undefined {
return this.socket.localPort;
}
/**
* Indicates if this connection came through a trusted proxy
*/
@ -82,178 +96,4 @@ export class WrappedSocket extends EventEmitter {
this.realClientIP = ip;
this.realClientPort = port;
}
// Pass-through all socket methods
write(data: any, encoding?: any, callback?: any): boolean {
return this.socket.write(data, encoding, callback);
}
end(data?: any, encoding?: any, callback?: any): this {
this.socket.end(data, encoding, callback);
return this;
}
destroy(error?: Error): this {
this.socket.destroy(error);
return this;
}
pause(): this {
this.socket.pause();
return this;
}
resume(): this {
this.socket.resume();
return this;
}
setTimeout(timeout: number, callback?: () => void): this {
this.socket.setTimeout(timeout, callback);
return this;
}
setNoDelay(noDelay?: boolean): this {
this.socket.setNoDelay(noDelay);
return this;
}
setKeepAlive(enable?: boolean, initialDelay?: number): this {
this.socket.setKeepAlive(enable, initialDelay);
return this;
}
ref(): this {
this.socket.ref();
return this;
}
unref(): this {
this.socket.unref();
return this;
}
/**
* Pipe to another stream
*/
pipe<T extends NodeJS.WritableStream>(destination: T, options?: {
end?: boolean;
}): T {
return this.socket.pipe(destination, options);
}
/**
* Cork the stream
*/
cork(): void {
if ('cork' in this.socket && typeof this.socket.cork === 'function') {
this.socket.cork();
}
}
/**
* Uncork the stream
*/
uncork(): void {
if ('uncork' in this.socket && typeof this.socket.uncork === 'function') {
this.socket.uncork();
}
}
/**
* Get the number of bytes read
*/
get bytesRead(): number {
return this.socket.bytesRead;
}
/**
* Get the number of bytes written
*/
get bytesWritten(): number {
return this.socket.bytesWritten;
}
/**
* Check if the socket is connecting
*/
get connecting(): boolean {
return this.socket.connecting;
}
/**
* Check if the socket is destroyed
*/
get destroyed(): boolean {
return this.socket.destroyed;
}
/**
* Check if the socket is readable
*/
get readable(): boolean {
return this.socket.readable;
}
/**
* Check if the socket is writable
*/
get writable(): boolean {
return this.socket.writable;
}
/**
* Get pending status
*/
get pending(): boolean {
return this.socket.pending;
}
/**
* Get ready state
*/
get readyState(): string {
return this.socket.readyState;
}
/**
* Address info
*/
address(): plugins.net.AddressInfo | {} | null {
const addr = this.socket.address();
if (addr === null) return null;
if (typeof addr === 'string') return addr as any;
return addr;
}
/**
* Set socket encoding
*/
setEncoding(encoding?: BufferEncoding): this {
this.socket.setEncoding(encoding);
return this;
}
/**
* Connect method (for client sockets)
*/
connect(options: plugins.net.SocketConnectOpts, connectionListener?: () => void): this;
connect(port: number, host?: string, connectionListener?: () => void): this;
connect(path: string, connectionListener?: () => void): this;
connect(...args: any[]): this {
(this.socket as any).connect(...args);
return this;
}
/**
* Forward all events from the underlying socket
*/
private forwardSocketEvents(): void {
const events = ['data', 'end', 'close', 'error', 'drain', 'timeout', 'connect', 'ready', 'lookup'];
events.forEach(event => {
this.socket.on(event, (...args) => {
this.emit(event, ...args);
});
});
}
}

View File

@ -3,6 +3,7 @@ import { HttpProxy } from '../http-proxy/index.js';
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
export class HttpProxyBridge {
private httpProxy: HttpProxy | null = null;
@ -98,7 +99,7 @@ export class HttpProxyBridge {
*/
public async forwardToHttpProxy(
connectionId: string,
socket: plugins.net.Socket,
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
initialChunk: Buffer,
httpProxyPort: number,
@ -125,7 +126,10 @@ export class HttpProxyBridge {
}
// Use centralized bidirectional forwarding
setupBidirectionalForwarding(socket, proxySocket, {
// Extract underlying socket if it's a WrappedSocket
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
onClientData: (chunk) => {
// Update stats if needed
if (record) {

View File

@ -12,6 +12,7 @@ import { TimeoutManager } from './timeout-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
/**
* Handles new connection processing and setup logic with support for route-based configuration
@ -192,7 +193,7 @@ export class RouteConnectionHandler {
// If no routes require TLS handling and it's not port 443, route immediately
if (!needsTlsHandling && localPort !== 443) {
// Extract underlying socket for socket-utils functions
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
const underlyingSocket = getUnderlyingSocket(socket);
// Set up proper socket handlers for immediate routing
setupSocketHandlers(
underlyingSocket,
@ -222,7 +223,7 @@ export class RouteConnectionHandler {
);
// Route immediately for non-TLS connections
this.routeConnection(underlyingSocket, record, '', undefined);
this.routeConnection(socket, record, '', undefined);
return;
}
@ -379,8 +380,7 @@ export class RouteConnectionHandler {
}
// Find the appropriate route for this connection
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
this.routeConnection(underlyingSocket, record, serverName, chunk);
this.routeConnection(socket, record, serverName, chunk);
});
}
@ -388,7 +388,7 @@ export class RouteConnectionHandler {
* Route the connection based on match criteria
*/
private routeConnection(
socket: plugins.net.Socket,
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
serverName: string,
initialChunk?: Buffer
@ -576,7 +576,7 @@ export class RouteConnectionHandler {
* Handle a forward action for a route
*/
private handleForwardAction(
socket: plugins.net.Socket,
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
route: IRouteConfig,
initialChunk?: Buffer
@ -893,7 +893,7 @@ export class RouteConnectionHandler {
* Handle a socket-handler action for a route
*/
private async handleSocketHandlerAction(
socket: plugins.net.Socket,
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
route: IRouteConfig,
initialChunk?: Buffer
@ -957,8 +957,9 @@ export class RouteConnectionHandler {
});
try {
// Call the handler with socket AND context
const result = route.action.socketHandler(socket, routeContext);
// Call the handler with the appropriate socket (extract underlying if needed)
const handlerSocket = getUnderlyingSocket(socket);
const result = route.action.socketHandler(handlerSocket, routeContext);
// Handle async handlers properly
if (result instanceof Promise) {
@ -1012,7 +1013,7 @@ export class RouteConnectionHandler {
* Sets up a direct connection to the target
*/
private setupDirectConnection(
socket: plugins.net.Socket,
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
serverName?: string,
initialChunk?: Buffer,
@ -1162,7 +1163,10 @@ export class RouteConnectionHandler {
}
// Use centralized bidirectional forwarding setup
setupBidirectionalForwarding(socket, targetSocket, {
// Extract underlying sockets for socket-utils functions
const incomingSocket = getUnderlyingSocket(socket);
setupBidirectionalForwarding(incomingSocket, targetSocket, {
onClientData: (chunk) => {
record.bytesReceived += chunk.length;
this.timeoutManager.updateActivity(record);