This commit is contained in:
Juergen Kunz
2025-07-17 20:51:50 +00:00
parent b26abbfd87
commit 6aa5f415c1
7 changed files with 116 additions and 89 deletions

View File

@@ -232,10 +232,10 @@ tap.test('should start the proxy server', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3100
},
}],
tls: {
mode: 'terminate'
},

View File

@@ -29,7 +29,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
match: { ports: 8700 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9995 }
targets: [{ host: 'localhost', port: 9995 }]
}
},
{
@@ -37,7 +37,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
match: { ports: 8701 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9995 }
targets: [{ host: 'localhost', port: 9995 }]
}
}
],

View File

@@ -56,8 +56,8 @@ tap.test('Routes: Should create basic HTTP route', async () => {
expect(httpRoute.match.ports).toEqual(80);
expect(httpRoute.match.domains).toEqual('example.com');
expect(httpRoute.action.type).toEqual('forward');
expect(httpRoute.action.target?.host).toEqual('localhost');
expect(httpRoute.action.target?.port).toEqual(3000);
expect(httpRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpRoute.action.targets?.[0]?.port).toEqual(3000);
expect(httpRoute.name).toEqual('Basic HTTP Route');
});
@@ -74,8 +74,8 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
expect(httpsRoute.action.tls?.certificate).toEqual('auto');
expect(httpsRoute.action.target?.host).toEqual('localhost');
expect(httpsRoute.action.target?.port).toEqual(8080);
expect(httpsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpsRoute.action.targets?.[0]?.port).toEqual(8080);
expect(httpsRoute.name).toEqual('HTTPS Route');
});
@@ -131,10 +131,10 @@ tap.test('Routes: Should create load balancer route', async () => {
// Validate the route configuration
expect(lbRoute.match.domains).toEqual('app.example.com');
expect(lbRoute.action.type).toEqual('forward');
expect(Array.isArray(lbRoute.action.target?.host)).toBeTrue();
expect((lbRoute.action.target?.host as string[]).length).toEqual(3);
expect((lbRoute.action.target?.host as string[])[0]).toEqual('10.0.0.1');
expect(lbRoute.action.target?.port).toEqual(8080);
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
expect((lbRoute.action.targets?.[0]?.host as string[]).length).toEqual(3);
expect((lbRoute.action.targets?.[0]?.host as string[])[0]).toEqual('10.0.0.1');
expect(lbRoute.action.targets?.[0]?.port).toEqual(8080);
expect(lbRoute.action.tls?.mode).toEqual('terminate');
});
@@ -152,8 +152,8 @@ tap.test('Routes: Should create API route with CORS', async () => {
expect(apiRoute.match.path).toEqual('/v1/*');
expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.action.tls?.mode).toEqual('terminate');
expect(apiRoute.action.target?.host).toEqual('localhost');
expect(apiRoute.action.target?.port).toEqual(3000);
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
// Check CORS headers
expect(apiRoute.headers).toBeDefined();
@@ -177,8 +177,8 @@ tap.test('Routes: Should create WebSocket route', async () => {
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.tls?.mode).toEqual('terminate');
expect(wsRoute.action.target?.host).toEqual('localhost');
expect(wsRoute.action.target?.port).toEqual(5000);
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
// Check WebSocket configuration
expect(wsRoute.action.websocket).toBeDefined();
@@ -294,13 +294,13 @@ tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
expect(bestMatch).not.toBeUndefined();
if (bestMatch) {
expect(bestMatch.action.target.port).toEqual(3001); // Should match the exact domain route
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
}
// Test with a different subdomain - should only match the wildcard route
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
expect(otherMatches.length).toEqual(1);
expect(otherMatches[0].action.target.port).toEqual(3000); // Should match the wildcard domain route
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
});
tap.test('Edge Case - Disabled Routes', async () => {
@@ -316,7 +316,7 @@ tap.test('Edge Case - Disabled Routes', async () => {
// Should only find the enabled route
expect(matches.length).toEqual(1);
expect(matches[0].action.target.port).toEqual(3000);
expect(matches[0].action.targets[0].port).toEqual(3000);
});
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
@@ -452,7 +452,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestSpecificMatch).not.toBeUndefined();
if (bestSpecificMatch) {
// Find which route was matched
const matchedPort = bestSpecificMatch.action.target.port;
const matchedPort = bestSpecificMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the specific subdomain route (with highest priority)
@@ -465,7 +465,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestWildcardMatch).not.toBeUndefined();
if (bestWildcardMatch) {
// Find which route was matched
const matchedPort = bestWildcardMatch.action.target.port;
const matchedPort = bestWildcardMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the wildcard subdomain route (with medium priority)
@@ -513,7 +513,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(webServerMatch).not.toBeUndefined();
if (webServerMatch) {
expect(webServerMatch.action.type).toEqual('forward');
expect(webServerMatch.action.target.host).toEqual('web-server');
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
}
// Web server (HTTP redirect via socket handler)
@@ -532,7 +532,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(apiMatch).not.toBeUndefined();
if (apiMatch) {
expect(apiMatch.action.type).toEqual('forward');
expect(apiMatch.action.target.host).toEqual('api-server');
expect(apiMatch.action.targets[0].host).toEqual('api-server');
}
// WebSocket server
@@ -544,7 +544,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(wsMatch).not.toBeUndefined();
if (wsMatch) {
expect(wsMatch.action.type).toEqual('forward');
expect(wsMatch.action.target.host).toEqual('websocket-server');
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
expect(wsMatch.action.websocket?.enabled).toBeTrue();
}

View File

@@ -134,10 +134,10 @@ tap.test('Route Validation - validateRouteAction', async () => {
// Valid forward action
const validForwardAction: IRouteAction = {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
};
const validForwardResult = validateRouteAction(validForwardAction);
expect(validForwardResult.valid).toBeTrue();
@@ -154,14 +154,14 @@ tap.test('Route Validation - validateRouteAction', async () => {
expect(validSocketResult.valid).toBeTrue();
expect(validSocketResult.errors.length).toEqual(0);
// Invalid action (missing target)
// Invalid action (missing targets)
const invalidAction: IRouteAction = {
type: 'forward'
};
const invalidResult = validateRouteAction(invalidAction);
expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Target is required');
expect(invalidResult.errors[0]).toInclude('Targets array is required');
// Invalid action (missing socket handler)
const invalidSocketAction: IRouteAction = {
@@ -180,7 +180,7 @@ tap.test('Route Validation - validateRouteConfig', async () => {
expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0);
// Invalid route config (missing target)
// Invalid route config (missing targets)
const invalidRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -309,16 +309,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const actionOverride: Partial<IRouteConfig> = {
action: {
type: 'forward',
target: {
targets: [{
host: 'new-host.local',
port: 5000
}
}]
}
};
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
expect(actionMergedRoute.action.target.host).toEqual('new-host.local');
expect(actionMergedRoute.action.target.port).toEqual(5000);
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
// Test replacing action with socket handler
const typeChangeOverride: Partial<IRouteConfig> = {
@@ -336,7 +336,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
expect(typeChangedRoute.action.type).toEqual('socket-handler');
expect(typeChangedRoute.action.socketHandler).toBeDefined();
expect(typeChangedRoute.action.target).toBeUndefined();
expect(typeChangedRoute.action.targets).toBeUndefined();
});
tap.test('Route Matching - routeMatchesDomain', async () => {
@@ -379,10 +379,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -393,10 +393,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -427,10 +427,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -443,10 +443,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -458,10 +458,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -494,10 +494,10 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -641,7 +641,7 @@ tap.test('Route Utilities - cloneRoute', async () => {
expect(clonedRoute.name).toEqual(originalRoute.name);
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
expect(clonedRoute.action.target.port).toEqual(originalRoute.action.target.port);
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
// Modify the clone and check that the original is unchanged
clonedRoute.name = 'Modified Clone';
@@ -656,8 +656,8 @@ tap.test('Route Helpers - createHttpRoute', async () => {
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('forward');
expect(route.action.target.host).toEqual('localhost');
expect(route.action.target.port).toEqual(3000);
expect(route.action.targets?.[0]?.host).toEqual('localhost');
expect(route.action.targets?.[0]?.port).toEqual(3000);
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
@@ -790,11 +790,11 @@ tap.test('Route Helpers - createLoadBalancerRoute', async () => {
expect(route.match.domains).toEqual('loadbalancer.example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(Array.isArray(route.action.target.host)).toBeTrue();
if (Array.isArray(route.action.target.host)) {
expect(route.action.target.host.length).toEqual(3);
expect(route.action.targets).toBeDefined();
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
expect((route.action.targets[0].host as string[]).length).toEqual(3);
}
expect(route.action.target.port).toEqual(8080);
expect(route.action.targets?.[0]?.port).toEqual(8080);
expect(route.action.tls.mode).toEqual('terminate');
const validationResult = validateRouteConfig(route);
@@ -819,7 +819,7 @@ tap.test('Route Patterns - createApiGatewayRoute', async () => {
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
expect(apiGatewayRoute.match.path).toInclude('/v1');
expect(apiGatewayRoute.action.type).toEqual('forward');
expect(apiGatewayRoute.action.target.port).toEqual(3000);
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (apiGatewayRoute.action.tls) {
@@ -854,7 +854,7 @@ tap.test('Route Patterns - createWebSocketPattern', async () => {
expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.target.port).toEqual(3000);
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (wsRoute.action.tls) {
@@ -891,8 +891,8 @@ tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
expect(lbRoute.action.type).toEqual('forward');
// Check target hosts
if (Array.isArray(lbRoute.action.target.host)) {
expect(lbRoute.action.target.host.length).toEqual(3);
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
}
// Check TLS configuration

View File

@@ -38,15 +38,17 @@ tap.test('Per-IP connection limits validation', async () => {
// Track connections up to limit
for (let i = 1; i <= 5; i++) {
securityManager.trackConnectionByIP(testIP, `conn${i}`);
// Validate BEFORE tracking the connection (checking if we can add a new connection)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Now track the connection
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
// Next connection should be rejected
// Next connection should be rejected (we're already at 5)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP');
@@ -61,21 +63,16 @@ tap.test('Connection rate limiting', async () => {
const testIP = '192.168.1.102';
// Make connections at the rate limit
// Note: validateIP() already tracks timestamps internally for rate limiting
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Next connection should exceed rate limit
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit');
// Clean up connections
for (let i = 0; i < 10; i++) {
securityManager.removeConnectionByIP(testIP, `conn${i}`);
}
});
tap.test('Route-level connection limits', async () => {
@@ -93,7 +90,8 @@ tap.test('Route-level connection limits', async () => {
clientIp: '192.168.1.103',
serverIp: '0.0.0.0',
timestamp: Date.now(),
connectionId: 'test-conn'
connectionId: 'test-conn',
isTls: true
};
// Test with connection counts below limit

View File

@@ -73,10 +73,10 @@ tap.test('setup port proxy test environment', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -112,10 +112,10 @@ tap.test('should forward TCP connections to custom host', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -157,10 +157,10 @@ tap.test('should forward connections to custom IP', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: targetServerPort
}
}]
}
}
],
@@ -252,10 +252,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: PROXY_PORT + 5
}
}]
}
}
],
@@ -273,10 +273,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -311,10 +311,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: PROXY_PORT + 7
}
}]
}
}
],
@@ -334,10 +334,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -377,10 +377,10 @@ tap.test('should use round robin for multiple target hosts in domain config', as
},
action: {
type: 'forward' as const,
target: {
targets: [{
host: ['hostA', 'hostB'], // Array of hosts for round-robin
port: 80
}
}]
}
};
@@ -400,9 +400,9 @@ tap.test('should use round robin for multiple target hosts in domain config', as
// For route-based approach, the actual round-robin logic happens in connection handling
// Just make sure our config has the expected hosts
expect(Array.isArray(routeConfig.action.targets[0].host)).toBeTrue();
expect(routeConfig.action.targets[0].host).toContain('hostA');
expect(routeConfig.action.targets[0].host).toContain('hostB');
expect(Array.isArray(routeConfig.action.targets![0].host)).toBeTrue();
expect(routeConfig.action.targets![0].host).toContain('hostA');
expect(routeConfig.action.targets![0].host).toContain('hostB');
});
// CLEANUP: Tear down all servers and proxies

View File

@@ -13,7 +13,8 @@ import {
trackConnection,
removeConnection,
cleanupExpiredRateLimits,
parseBasicAuthHeader
parseBasicAuthHeader,
normalizeIP
} from './security-utils.js';
/**
@@ -78,7 +79,15 @@ export class SharedSecurityManager {
* @returns Number of connections from this IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.connections.size || 0;
// Check all normalized variants of the IP
const variants = normalizeIP(ip);
for (const variant of variants) {
const info = this.connectionsByIP.get(variant);
if (info) {
return info.connections.size;
}
}
return 0;
}
/**
@@ -88,7 +97,19 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to associate
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
trackConnection(ip, connectionId, this.connectionsByIP);
// Check if any variant already exists
const variants = normalizeIP(ip);
let existingKey: string | null = null;
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
existingKey = variant;
break;
}
}
// Use existing key or the original IP
trackConnection(existingKey || ip, connectionId, this.connectionsByIP);
}
/**
@@ -98,7 +119,15 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to remove
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
removeConnection(ip, connectionId, this.connectionsByIP);
// Check all variants to find where the connection is tracked
const variants = normalizeIP(ip);
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
removeConnection(variant, connectionId, this.connectionsByIP);
break;
}
}
}
/**