Compare commits

...

4 Commits

Author SHA1 Message Date
Juergen Kunz
aa6ddbc4a6 BREAKING_CHANGE(routing): refactor route configuration to support multiple targets
Some checks failed
Default (tags) / security (push) Successful in 53s
Default (tags) / test (push) Failing after 31m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 08:52:07 +00:00
Juergen Kunz
6aa5f415c1 update 2025-07-17 20:51:50 +00:00
Juergen Kunz
b26abbfd87 update 2025-07-17 15:34:58 +00:00
Juergen Kunz
82df9a6f52 update 2025-07-17 15:13:09 +00:00
68 changed files with 3635 additions and 638 deletions

View File

@@ -1,5 +1,5 @@
{ {
"expiryDate": "2025-10-01T02:31:27.435Z", "expiryDate": "2025-10-18T13:15:48.916Z",
"issueDate": "2025-07-03T02:31:27.435Z", "issueDate": "2025-07-20T13:15:48.916Z",
"savedAt": "2025-07-03T02:31:27.435Z" "savedAt": "2025-07-20T13:15:48.916Z"
} }

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2025-07-20 - 20.0.1 - BREAKING_CHANGE(routing)
Refactor route configuration to support multiple targets
- Changed route action configuration from single `target` to `targets` array
- Enables load balancing and failover capabilities with multiple upstream targets
- Updated all test files to use new `targets` array syntax
- Automatic certificate metadata refresh
## 2025-06-01 - 19.5.19 - fix(smartproxy) ## 2025-06-01 - 19.5.19 - fix(smartproxy)
Fix connection handling and improve route matching edge cases Fix connection handling and improve route matching edge cases

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "19.6.17", "version": "20.0.1",
"private": false, "private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.", "description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -51,7 +51,8 @@
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", "npmextra.json",
"readme.md" "readme.md",
"changelog.md"
], ],
"browserslist": [ "browserslist": [
"last 1 chrome versions" "last 1 chrome versions"

View File

@@ -1,281 +1,154 @@
# SmartProxy Implementation Plan # SmartProxy Enhanced Routing Plan
## Feature: Custom Certificate Provision Function ## Goal
Implement enhanced routing structure with multiple targets per route, sub-matching capabilities, and target-specific overrides to enable more elegant and DRY configurations.
### Summary ## Key Changes
This plan implements the `certProvisionFunction` feature that allows users to provide their own certificate generation logic. The function can either return a custom certificate or delegate back to Let's Encrypt by returning 'http01'.
### Key Changes ### 1. Update Route Target Interface
1. Add `certProvisionFunction` support to CertificateManager - Add `match` property to `IRouteTarget` for sub-matching within routes
2. Modify `provisionAcmeCertificate()` to check custom function first - Add target-specific override properties (tls, websocket, loadBalancing, etc.)
3. Add certificate expiry parsing for custom certificates - Add priority field for controlling match order
4. Support both initial provisioning and renewal
5. Add fallback configuration option
### Overview ### 2. Update Route Action Interface
Implement the `certProvisionFunction` callback that's defined in the interface but currently not implemented. This will allow users to provide custom certificate generation logic while maintaining backward compatibility with the existing Let's Encrypt integration. - Remove singular `target` property
- Use only `targets` array (single target = array with one element)
- Maintain backwards compatibility during migration
### Requirements ### 3. Implementation Steps
1. The function should be called for any new certificate provisioning or renewal
2. Must support returning custom certificates or falling back to Let's Encrypt
3. Should integrate seamlessly with the existing certificate lifecycle
4. Must maintain backward compatibility
### Implementation Steps #### Phase 1: Type Updates
- [x] Update `IRouteTarget` interface in `route-types.ts`
- Add `match?: ITargetMatch` property
- Add override properties (tls, websocket, etc.)
- Add `priority?: number` field
- [x] Create `ITargetMatch` interface for sub-matching criteria
- [x] Update `IRouteAction` to use only `targets: IRouteTarget[]`
#### 1. Update Certificate Manager to Support Custom Provision Function #### Phase 2: Route Resolution Logic
**File**: `ts/proxies/smart-proxy/certificate-manager.ts` - [x] Update route matching logic to handle multiple targets
- [x] Implement target sub-matching algorithm:
1. Sort targets by priority (highest first)
2. For each target with a match property, check if request matches
3. Use first matching target, or fallback to target without match
- [x] Ensure target-specific settings override route-level settings
- [ ] Add `certProvisionFunction` property to CertificateManager class #### Phase 3: Code Migration
- [ ] Pass the function from SmartProxy options during initialization - [x] Find all occurrences of `action.target` and update to use `action.targets`
- [ ] Modify `provisionCertificate()` method to check for custom function first - [x] Update route helpers and utilities
- [x] Update certificate manager to handle multiple targets
- [x] Update connection handlers
#### 2. Implement Custom Certificate Provisioning Logic #### Phase 4: Testing
**Location**: Modify `provisionAcmeCertificate()` method - [x] Update existing tests to use new format
- [ ] Add tests for multi-target scenarios
- [ ] Add tests for sub-matching logic
- [ ] Add tests for setting overrides
#### Phase 5: Documentation
- [ ] Update type documentation
- [ ] Add examples of new routing patterns
- [ ] Document migration path for existing configs
## Example Configurations
### Before (Current)
```typescript ```typescript
private async provisionAcmeCertificate( // Need separate routes for different ports/paths
route: IRouteConfig, [
domains: string[] {
): Promise<void> { match: { domains: ['api.example.com'], ports: [80] },
const primaryDomain = domains[0]; action: {
const routeName = route.name || primaryDomain; type: 'forward',
target: { host: 'backend', port: 8080 },
// Check for custom provision function first tls: { mode: 'terminate' }
if (this.certProvisionFunction) {
try {
logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain });
const result = await this.certProvisionFunction(primaryDomain);
if (result === 'http01') {
logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`);
// Continue with existing ACME logic below
} else {
// Use custom certificate
const customCert = result as plugins.tsclass.network.ICert;
// Convert to internal certificate format
const certData: ICertificateData = {
cert: customCert.cert,
key: customCert.key,
ca: customCert.ca || '',
issueDate: new Date(),
expiryDate: this.extractExpiryDate(customCert.cert)
};
// Store and apply certificate
await this.certStore.saveCertificate(routeName, certData);
await this.applyCertificate(primaryDomain, certData);
this.updateCertStatus(routeName, 'valid', 'custom', certData);
logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
domain: primaryDomain,
expiryDate: certData.expiryDate
});
return;
}
} catch (error) {
logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
domain: primaryDomain,
error: error.message
});
// Configuration option to control fallback behavior
if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) {
logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`);
} else {
throw error;
}
} }
}
// Existing Let's Encrypt logic continues here...
if (!this.smartAcme) {
throw new Error('SmartAcme not initialized...');
}
// ... rest of existing code
}
```
#### 3. Add Helper Method for Certificate Expiry Extraction
**New method**: `extractExpiryDate()`
- [ ] Parse PEM certificate to extract expiry date
- [ ] Use existing certificate parsing utilities
- [ ] Handle parse errors gracefully
```typescript
private extractExpiryDate(certPem: string): Date {
try {
// Use forge or similar library to parse certificate
const cert = forge.pki.certificateFromPem(certPem);
return cert.validity.notAfter;
} catch (error) {
// Default to 90 days if parsing fails
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
}
}
```
#### 4. Update SmartProxy Initialization
**File**: `ts/proxies/smart-proxy/index.ts`
- [ ] Pass `certProvisionFunction` from options to CertificateManager
- [ ] Validate function if provided
#### 5. Add Type Safety and Validation
**Tasks**:
- [ ] Validate returned certificate has required fields (cert, key, ca)
- [ ] Check certificate validity dates
- [ ] Ensure certificate matches requested domain
#### 6. Update Certificate Renewal Logic
**Location**: `checkAndRenewCertificates()`
- [ ] Ensure renewal checks work for both ACME and custom certificates
- [ ] Custom certificates should go through the same `provisionAcmeCertificate()` path
- [ ] The existing renewal logic already calls `provisionCertificate()` which will use our modified flow
```typescript
// No changes needed here - the existing renewal logic will automatically
// use the custom provision function when calling provisionCertificate()
private async checkAndRenewCertificates(): Promise<void> {
// Existing code already handles this correctly
for (const route of routes) {
if (this.shouldRenewCertificate(cert, renewThreshold)) {
// This will call provisionCertificate -> provisionAcmeCertificate
// which now includes our custom function check
await this.provisionCertificate(route);
}
}
}
```
#### 7. Add Integration Tests
**File**: `test/test.certificate-provision.ts`
- [ ] Test custom certificate provision
- [ ] Test fallback to Let's Encrypt ('http01' return)
- [ ] Test error handling
- [ ] Test renewal with custom function
#### 8. Update Documentation
**Files**:
- [ ] Update interface documentation
- [ ] Add examples to README
- [ ] Document ICert structure requirements
### API Design
```typescript
// Example usage
const proxy = new SmartProxy({
certProvisionFunction: async (domain: string) => {
// Option 1: Return custom certificate
const customCert = await myCustomCA.generateCert(domain);
return {
cert: customCert.certificate,
key: customCert.privateKey,
ca: customCert.chain
};
// Option 2: Use Let's Encrypt for certain domains
if (domain.endsWith('.internal')) {
return customCert;
}
return 'http01'; // Fallback to Let's Encrypt
}, },
certProvisionFallbackToAcme: true, // Default: true {
routes: [...] match: { domains: ['api.example.com'], ports: [443] },
}); action: {
type: 'forward',
target: { host: 'backend', port: 8081 },
tls: { mode: 'passthrough' }
}
}
]
``` ```
### Configuration Options to Add ### After (Enhanced)
```typescript ```typescript
interface ISmartProxyOptions { // Single route with multiple targets
// Existing options... {
match: { domains: ['api.example.com'], ports: [80, 443] },
// Custom certificate provision function action: {
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; type: 'forward',
targets: [
// Whether to fallback to ACME if custom provision fails {
certProvisionFallbackToAcme?: boolean; // Default: true match: { ports: [80] },
host: 'backend',
port: 8080,
tls: { mode: 'terminate' }
},
{
match: { ports: [443] },
host: 'backend',
port: 8081,
tls: { mode: 'passthrough' }
}
]
}
} }
``` ```
### Error Handling Strategy ### Advanced Example
```typescript
{
match: { domains: ['app.example.com'], ports: [443] },
action: {
type: 'forward',
tls: { mode: 'terminate', certificate: 'auto' }, // Route-level default
websocket: { enabled: true }, // Route-level default
targets: [
{
match: { path: '/api/v2/*' },
host: 'api-v2',
port: 8082,
priority: 10
},
{
match: { path: '/api/*', headers: { 'X-Version': 'v1' } },
host: 'api-v1',
port: 8081,
priority: 5
},
{
match: { path: '/ws/*' },
host: 'websocket-server',
port: 8090,
websocket: {
enabled: true,
rewritePath: '/' // Strip /ws prefix
}
},
{
// Default target (no match property)
host: 'web-backend',
port: 8080
}
]
}
}
```
1. **Custom Function Errors**: ## Benefits
- Log detailed error with domain context 1. **DRY Configuration**: No need to duplicate common settings across routes
- Option A: Fallback to Let's Encrypt (safer) 2. **Flexibility**: Different backends for different ports/paths within same domain
- Option B: Fail certificate provisioning (stricter) 3. **Clarity**: All routing for a domain in one place
- Make this configurable via option? 4. **Performance**: Single route lookup instead of multiple
5. **Backwards Compatible**: Can migrate gradually
2. **Invalid Certificate Returns**: ## Migration Strategy
- Validate certificate structure 1. Keep support for `target` temporarily with deprecation warning
- Check expiry dates 2. Auto-convert `target` to `targets: [target]` internally
- Verify domain match 3. Update documentation with migration examples
4. Remove `target` support in next major version
### Testing Plan
1. **Unit Tests**:
- Mock certProvisionFunction returns
- Test validation logic
- Test error scenarios
2. **Integration Tests**:
- Real certificate generation
- Renewal cycle testing
- Mixed custom/Let's Encrypt scenarios
### Backward Compatibility
- If no `certProvisionFunction` provided, behavior unchanged
- Existing routes with 'auto' certificates continue using Let's Encrypt
- No breaking changes to existing API
### Future Enhancements
1. **Per-Route Custom Functions**:
- Allow different provision functions per route
- Override global function at route level
2. **Certificate Events**:
- Emit events for custom cert provisioning
- Allow monitoring/logging hooks
3. **Async Certificate Updates**:
- Support updating certificates outside renewal cycle
- Hot-reload certificates without restart
### Implementation Notes
1. **Certificate Status Tracking**:
- The `updateCertStatus()` method needs to support a new type: 'custom'
- Current types are 'acme' and 'static'
- This helps distinguish custom certificates in monitoring/logs
2. **Certificate Store Integration**:
- Custom certificates are stored the same way as ACME certificates
- They participate in the same renewal cycle
- The store handles persistence across restarts
3. **Existing Methods to Reuse**:
- `applyCertificate()` - Already handles applying certs to routes
- `isCertificateValid()` - Can validate custom certificates
- `certStore.saveCertificate()` - Handles storage
### Implementation Priority
1. Core functionality (steps 1-3)
2. Type safety and validation (step 5)
3. Renewal support (step 6)
4. Tests (step 7)
5. Documentation (step 8)
### Estimated Effort
- Core implementation: 4-6 hours
- Testing: 2-3 hours
- Documentation: 1 hour
- Total: ~8-10 hours

2749
test-output.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,14 +32,14 @@ tap.test('PathMatcher - wildcard matching', async () => {
const result = PathMatcher.match('/api/*', '/api/users/123/profile'); const result = PathMatcher.match('/api/*', '/api/users/123/profile');
expect(result.matches).toEqual(true); expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
expect(result.pathRemainder).toEqual('users/123/profile'); expect(result.pathRemainder).toEqual('/users/123/profile');
}); });
tap.test('PathMatcher - mixed parameters and wildcards', async () => { tap.test('PathMatcher - mixed parameters and wildcards', async () => {
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123'); const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
expect(result.matches).toEqual(true); expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v1' }); expect(result.params).toEqual({ version: 'v1' });
expect(result.pathRemainder).toEqual('users/123'); expect(result.pathRemainder).toEqual('/users/123');
}); });
tap.test('PathMatcher - trailing slash normalization', async () => { tap.test('PathMatcher - trailing slash normalization', async () => {

View File

@@ -58,7 +58,7 @@ tap.test('Shared Security Manager', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'target.com', port: 443 } targets: [{ host: 'target.com', port: 443 }]
}, },
security: { security: {
ipAllowList: ['10.0.0.*', '192.168.1.*'], ipAllowList: ['10.0.0.*', '192.168.1.*'],
@@ -113,7 +113,7 @@ tap.test('Shared Security Manager', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'target.com', port: 443 } targets: [{ host: 'target.com', port: 443 }]
}, },
security: { security: {
rateLimit: { rateLimit: {

View File

@@ -59,7 +59,7 @@ tap.test('should create ACME challenge route', async (tools) => {
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}, },
challengeRoute challengeRoute

View File

@@ -18,7 +18,7 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -30,7 +30,7 @@ tap.test('should defer certificate provisioning until after ports are listening'
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -126,7 +126,7 @@ tap.test('should have ACME challenge route ready before certificate provisioning
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'

View File

@@ -16,10 +16,10 @@ tap.test('SmartCertManager should call getCertificateForDomain with wildcard opt
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -59,10 +59,10 @@ tap.test('SmartProxy should support custom certificate provision function', asyn
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'
@@ -109,10 +109,10 @@ tap.test('Custom certificate provision function should be called', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'
@@ -172,10 +172,10 @@ tap.test('Should fallback to ACME when custom provision fails', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'
@@ -231,10 +231,10 @@ tap.test('Should not fallback when certProvisionFallbackToAcme is false', async
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'
@@ -310,10 +310,10 @@ tap.test('Should return http01 for unknown domains', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'

View File

@@ -7,7 +7,7 @@ const testProxy = new SmartProxy({
match: { ports: 9443, domains: 'test.local' }, match: { ports: 9443, domains: 'test.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -67,7 +67,7 @@ tap.test('should handle static certificates', async () => {
match: { ports: 9444, domains: 'static.example.com' }, match: { ports: 9444, domains: 'static.example.com' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: { certificate: {
@@ -96,7 +96,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9445, domains: 'acme.local' }, match: { ports: 9445, domains: 'acme.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -112,7 +112,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9081, domains: 'acme.local' }, match: { ports: 9081, domains: 'acme.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}], }],
acme: { acme: {
@@ -167,7 +167,7 @@ tap.test('should renew certificates', async () => {
match: { ports: 9446, domains: 'renew.local' }, match: { ports: 9446, domains: 'renew.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -8,7 +8,7 @@ tap.test('should create SmartProxy with certificate routes', async () => {
match: { ports: 8443, domains: 'test.example.com' }, match: { ports: 8443, domains: 'test.example.com' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -13,7 +13,7 @@ tap.test('cleanup queue bug - verify queue processing handles more than batch si
match: { ports: 8588 }, match: { ports: 8588 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9996 } targets: [{ host: 'localhost', port: 9996 }]
} }
}], }],
enableDetailedLogging: false, enableDetailedLogging: false,

View File

@@ -18,10 +18,10 @@ tap.test('should handle clients that connect and immediately disconnect without
match: { ports: 8560 }, match: { ports: 8560 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
} }]
} }
}] }]
}); });
@@ -173,10 +173,10 @@ tap.test('should handle clients that error during connection', async () => {
match: { ports: 8561 }, match: { ports: 8561 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 port: 9999
} }]
} }
}] }]
}); });

View File

@@ -20,10 +20,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8570 }, match: { ports: 8570 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
} }]
} }
}, },
{ {
@@ -31,10 +31,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8571 }, match: { ports: 8571 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }
@@ -215,10 +215,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 port: 9999
} }]
} }
}] }]
}); });

View File

@@ -65,10 +65,10 @@ tap.test('should forward TCP connections correctly', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7001, port: 7001,
}, }],
}, },
}, },
], ],
@@ -118,10 +118,10 @@ tap.test('should handle TLS passthrough correctly', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
], ],
@@ -179,10 +179,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
{ {
@@ -197,10 +197,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
], ],

View File

@@ -90,10 +90,10 @@ tap.test('Setup test environment', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
} }]
}, },
security: { security: {
maxConnections: 5 // Low limit for testing maxConnections: 5 // Low limit for testing
@@ -198,10 +198,10 @@ tap.test('HttpProxy per-IP validation', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
}, }],
tls: { tls: {
mode: 'terminate' mode: 'terminate'
} }

View File

@@ -9,7 +9,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18443], domains: ['test.local'] }, match: { ports: [18443], domains: ['test.local'] },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3000 }, targets: [{ host: 'localhost', port: 3000 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -63,7 +63,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18444], domains: ['test2.local'] }, match: { ports: [18444], domains: ['test2.local'] },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -37,7 +37,7 @@ tap.test('regular forward route should work correctly', async () => {
match: { ports: 7890 }, match: { ports: 7890 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 6789 } targets: [{ host: 'localhost', port: 6789 }]
} }
}] }]
}); });
@@ -106,7 +106,7 @@ tap.skip.test('NFTables forward route should not terminate connections (requires
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { host: 'localhost', port: 6789 } targets: [{ host: 'localhost', port: 6789 }]
} }
}] }]
}); });

View File

@@ -39,10 +39,10 @@ tap.test('forward connections should not be immediately closed', async (t) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9090, port: 9090,
}, }],
}, },
}, },
], ],

View File

@@ -39,7 +39,7 @@ tap.test('Route Helpers - Create HTTP routes', async () => {
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 }); const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('example.com'); expect(route.match.domains).toEqual('example.com');
expect(route.action.target).toEqual({ host: 'localhost', port: 3000 }); expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
}); });
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => { tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {

View File

@@ -20,7 +20,7 @@ tap.test('should forward non-TLS connections on HttpProxy ports', async (tapTest
match: { ports: testPort }, match: { ports: testPort },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -81,7 +81,7 @@ tap.test('should use direct connection for non-HttpProxy ports', async (tapTest)
match: { ports: 8080 }, // Not in useHttpProxy match: { ports: 8080 }, // Not in useHttpProxy
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -142,7 +142,7 @@ tap.test('should handle ACME HTTP-01 challenges on port 80 with HttpProxy', asyn
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}] }]
}; };

View File

@@ -14,7 +14,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
match: { ports: 8080 }, match: { ports: 8080 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -140,7 +140,7 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
match: { ports: 443 }, match: { ports: 443 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8443 }, targets: [{ host: 'localhost', port: 8443 }],
tls: { mode: 'terminate' } tls: { mode: 'terminate' }
} }
}] }]

View File

@@ -17,7 +17,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
match: { ports: 8081 }, match: { ports: 8081 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}); });
@@ -120,7 +120,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });

View File

@@ -42,7 +42,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });
@@ -131,7 +131,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });

View File

@@ -67,7 +67,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort }, targets: [{ host: 'localhost', port: targetPort }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' // Use ACME for certificate certificate: 'auto' // Use ACME for certificate
@@ -83,7 +83,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
} }
], ],
@@ -191,7 +191,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
} }
]; ];

View File

@@ -95,10 +95,10 @@ tap.test('should support static host/port routes', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: serverPort port: serverPort
} }]
} }
} }
]; ];
@@ -135,13 +135,13 @@ tap.test('should support function-based host', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
// Return localhost always in this test // Return localhost always in this test
return 'localhost'; return 'localhost';
}, },
port: serverPort port: serverPort
} }]
} }
} }
]; ];
@@ -178,13 +178,13 @@ tap.test('should support function-based port', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: (context: IRouteContext) => { port: (context: IRouteContext) => {
// Return test server port // Return test server port
return serverPort; return serverPort;
} }
} }]
} }
} }
]; ];
@@ -221,14 +221,14 @@ tap.test('should support function-based host AND port', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
return 'localhost'; return 'localhost';
}, },
port: (context: IRouteContext) => { port: (context: IRouteContext) => {
return serverPort; return serverPort;
} }
} }]
} }
} }
]; ];
@@ -265,7 +265,7 @@ tap.test('should support context-based routing with path', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
// Use path to determine host // Use path to determine host
if (context.path?.startsWith('/api')) { if (context.path?.startsWith('/api')) {
@@ -275,7 +275,7 @@ tap.test('should support context-based routing with path', async () => {
} }
}, },
port: serverPort port: serverPort
} }]
} }
} }
]; ];

View File

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

View File

@@ -40,7 +40,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,
@@ -117,7 +117,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,
@@ -178,7 +178,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8592 }, match: { ports: 8592 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,

View File

@@ -39,10 +39,10 @@ tap.test('setup test environment', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9876 port: 9876
} }]
// No TLS configuration - just plain TCP forwarding // No TLS configuration - just plain TCP forwarding
} }
}], }],

View File

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

View File

@@ -36,10 +36,10 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: echoServerPort port: echoServerPort
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }

View File

@@ -34,10 +34,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8001, port: 8001,
}, }],
}, },
}, },
// Also add regular forwarding route for comparison // Also add regular forwarding route for comparison
@@ -49,10 +49,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8001, port: 8001,
}, }],
}, },
}, },
], ],

View File

@@ -42,10 +42,10 @@ const sampleRoute: IRouteConfig = {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8000 port: 8000
}, }],
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
nftables: { nftables: {
protocol: 'tcp', protocol: 'tcp',
@@ -115,10 +115,10 @@ tap.skip.test('NFTablesManager route updating test', async () => {
...sampleRoute, ...sampleRoute,
action: { action: {
...sampleRoute.action, ...sampleRoute.action,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9000 // Different port port: 9000 // Different port
}, }],
nftables: { nftables: {
...sampleRoute.action.nftables, ...sampleRoute.action.nftables,
protocol: 'all' // Different protocol protocol: 'all' // Different protocol
@@ -147,10 +147,10 @@ tap.skip.test('NFTablesManager route deprovisioning test', async () => {
...sampleRoute, ...sampleRoute,
action: { action: {
...sampleRoute.action, ...sampleRoute.action,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9000 // Different port from original test port: 9000 // Different port from original test
}, }],
nftables: { nftables: {
...sampleRoute.action.nftables, ...sampleRoute.action.nftables,
protocol: 'all' // Different protocol from original test protocol: 'all' // Different protocol from original test

View File

@@ -91,7 +91,7 @@ testFn('SmartProxy getNfTablesStatus functionality', async () => {
match: { ports: 3004 }, match: { ports: 3004 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3005 } targets: [{ host: 'localhost', port: 3005 }]
} }
} }
] ]

View File

@@ -29,7 +29,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
match: { ports: 9999 }, match: { ports: 9999 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8888 } targets: [{ host: 'localhost', port: 8888 }]
} }
}] }]
}); });
@@ -63,7 +63,7 @@ tap.test('TLS passthrough should work correctly', async () => {
action: { action: {
type: 'forward', type: 'forward',
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
target: { host: 'localhost', port: 443 } targets: [{ host: 'localhost', port: 443 }]
} }
}] }]
}); });

View File

@@ -214,12 +214,12 @@ tap.test('should handle errors in port mapping functions', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: () => { port: () => {
throw new Error('Test error in port mapping function'); throw new Error('Test error in port mapping function');
} }
} }]
}, },
name: 'Error Route' name: 'Error Route'
}; };

View File

@@ -21,7 +21,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3000 } targets: [{ host: 'localhost', port: 3000 }]
} }
}, },
{ {
@@ -31,7 +31,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const certificate: 'auto' as const
@@ -153,7 +153,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3000 } targets: [{ host: 'localhost', port: 3000 }]
} }
}, },
{ {
@@ -163,7 +163,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const certificate: 'auto' as const

View File

@@ -15,10 +15,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'httpbin.org', host: 'httpbin.org',
port: 443 port: 443
} }]
} }
} }
], ],
@@ -45,10 +45,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8002 port: 8002
}, }],
sendProxyProtocol: true sendProxyProtocol: true
} }
} }

View File

@@ -32,10 +32,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9998 // Backend that closes immediately port: 9998 // Backend that closes immediately
} }]
} }
}] }]
}); });
@@ -50,10 +50,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8591 // Forward to proxy2 port: 8591 // Forward to proxy2
} }]
} }
}] }]
}); });

View File

@@ -19,10 +19,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8581 }, match: { ports: 8581 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent backend port: 9999 // Non-existent backend
} }]
} }
}] }]
}); });
@@ -37,10 +37,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8580 }, match: { ports: 8580 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8581 // Forward to proxy2 port: 8581 // Forward to proxy2
} }]
} }
}] }]
}); });
@@ -270,10 +270,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8583 }, match: { ports: 8583 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent backend port: 9999 // Non-existent backend
} }]
} }
}] }]
}); });
@@ -289,10 +289,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8582 }, match: { ports: 8582 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8583 // Forward to proxy2 port: 8583 // Forward to proxy2
} }]
} }
}] }]
}); });

View File

@@ -19,10 +19,10 @@ tap.test('should handle rapid connection retries without leaking connections', a
match: { ports: 8550 }, match: { ports: 8550 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port to force connection failures port: 9999 // Non-existent port to force connection failures
} }]
} }
}] }]
}); });

View File

@@ -17,7 +17,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3000 }, targets: [{ host: 'localhost', port: 3000 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -95,7 +95,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

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

View File

@@ -28,10 +28,10 @@ tap.test('route security should block connections from unauthorized IPs', async
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9990 port: 9990
} }]
}, },
security: { security: {
// Only allow a non-existent IP // Only allow a non-existent IP
@@ -142,10 +142,10 @@ tap.test('route security with block list should work', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9992 port: 9992
} }]
}, },
security: { // Security at route level, not action level security: { // Security at route level, not action level
ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'] ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -234,10 +234,10 @@ tap.test('route without security should allow all connections', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9994 port: 9994
} }]
} }
// No security defined // No security defined
}]; }];

View File

@@ -10,10 +10,10 @@ tap.test('route security should be correctly configured', async () => {
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8991 port: 8991
}, }],
security: { security: {
ipAllowList: ['192.168.1.1'], ipAllowList: ['192.168.1.1'],
ipBlockList: ['10.0.0.1'] ipBlockList: ['10.0.0.1']

View File

@@ -26,10 +26,10 @@ tap.test('route-specific security should be enforced', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8877 port: 8877
} }]
}, },
security: { security: {
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'] ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -108,10 +108,10 @@ tap.test('route-specific IP block list should be enforced', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8879 port: 8879
} }]
}, },
security: { security: {
ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs
@@ -215,10 +215,10 @@ tap.test('routes without security should allow all connections', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8881 port: 8881
} }]
// No security section - should allow all // No security section - should allow all
} }
}]; }];

View File

@@ -13,10 +13,10 @@ const createRoute = (id: number, domain: string, port: number = 8443) => ({
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 + id port: 3000 + id
}, }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const, certificate: 'auto' as const,
@@ -209,10 +209,10 @@ tap.test('should handle route updates when cert manager is not initialized', asy
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}] }]
}); });

View File

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

View File

@@ -37,10 +37,10 @@ function createRouteConfig(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: destinationIp, host: destinationIp,
port: destinationPort port: destinationPort
} }]
} }
}; };
} }

View File

@@ -38,15 +38,17 @@ tap.test('Per-IP connection limits validation', async () => {
// Track connections up to limit // Track connections up to limit
for (let i = 1; i <= 5; i++) { 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); const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue(); expect(result.allowed).toBeTrue();
// Now track the connection
securityManager.trackConnectionByIP(testIP, `conn${i}`);
} }
// Verify we're at the limit // Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5); 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); const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse(); expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP'); expect(result.reason).toInclude('Maximum connections per IP');
@@ -61,28 +63,23 @@ tap.test('Connection rate limiting', async () => {
const testIP = '192.168.1.102'; const testIP = '192.168.1.102';
// Make connections at the rate limit // Make connections at the rate limit
// Note: validateIP() already tracks timestamps internally for rate limiting
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP); const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue(); expect(result.allowed).toBeTrue();
securityManager.trackConnectionByIP(testIP, `conn${i}`);
} }
// Next connection should exceed rate limit // Next connection should exceed rate limit
const result = securityManager.validateIP(testIP); const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse(); expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit'); 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 () => { tap.test('Route-level connection limits', async () => {
const route: IRouteConfig = { const route: IRouteConfig = {
name: 'test-route', name: 'test-route',
match: { ports: 443 }, match: { ports: 443 },
action: { type: 'forward', target: { host: 'localhost', port: 8080 } }, action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
security: { security: {
maxConnections: 3 maxConnections: 3
} }
@@ -93,7 +90,8 @@ tap.test('Route-level connection limits', async () => {
clientIp: '192.168.1.103', clientIp: '192.168.1.103',
serverIp: '0.0.0.0', serverIp: '0.0.0.0',
timestamp: Date.now(), timestamp: Date.now(),
connectionId: 'test-conn' connectionId: 'test-conn',
isTls: true
}; };
// Test with connection counts below limit // Test with connection counts below limit

View File

@@ -15,10 +15,10 @@ tap.test('should create a SmartCertManager instance', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

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

View File

@@ -30,7 +30,7 @@ tap.test('stuck connection cleanup - verify connections to hanging backends are
match: { ports: 8589 }, match: { ports: 8589 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9997 } targets: [{ host: 'localhost', port: 9997 }]
} }
}], }],
keepAlive: true, keepAlive: true,

View File

@@ -17,7 +17,7 @@ tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
match: { ports: 8443, domains: 'test.local' }, match: { ports: 8443, domains: 'test.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9443 }, targets: [{ host: 'localhost', port: 9443 }],
tls: { mode: 'passthrough' } tls: { mode: 'passthrough' }
} }
} }
@@ -108,7 +108,7 @@ tap.test('long-lived connection survival test', async (tools) => {
match: { ports: 8444 }, match: { ports: 8444 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9444 } targets: [{ host: 'localhost', port: 9444 }]
} }
} }
] ]

View File

@@ -52,10 +52,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9998 port: 9998
} }]
} }
}] }]
}); });
@@ -71,10 +71,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8591 port: 8591
} }]
} }
}] }]
}); });

View File

@@ -13,7 +13,8 @@ import {
trackConnection, trackConnection,
removeConnection, removeConnection,
cleanupExpiredRateLimits, cleanupExpiredRateLimits,
parseBasicAuthHeader parseBasicAuthHeader,
normalizeIP
} from './security-utils.js'; } from './security-utils.js';
/** /**
@@ -78,7 +79,15 @@ export class SharedSecurityManager {
* @returns Number of connections from this IP * @returns Number of connections from this IP
*/ */
public getConnectionCountByIP(ip: string): number { 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 * @param connectionId - The connection ID to associate
*/ */
public trackConnectionByIP(ip: string, connectionId: string): void { 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 * @param connectionId - The connection ID to remove
*/ */
public removeConnectionByIP(ip: string, connectionId: string): void { 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;
}
}
} }
/** /**

View File

@@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js';
import { ContextCreator } from './context-creator.js'; import { ContextCreator } from './context-creator.js';
import { HttpRequestHandler } from './http-request-handler.js'; import { HttpRequestHandler } from './http-request-handler.js';
import { Http2RequestHandler } from './http2-request-handler.js'; import { Http2RequestHandler } from './http2-request-handler.js';
import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
import { toBaseContext } from '../../core/models/route-context.js'; import { toBaseContext } from '../../core/models/route-context.js';
import { TemplateUtils } from '../../core/utils/template-utils.js'; import { TemplateUtils } from '../../core/utils/template-utils.js';
@@ -99,6 +99,80 @@ export class RequestHandler {
return { ...this.defaultHeaders }; return { ...this.defaultHeaders };
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Apply CORS headers to response if configured * Apply CORS headers to response if configured
* Implements Phase 5.5: Context-aware CORS handling * Implements Phase 5.5: Context-aware CORS handling
@@ -480,17 +554,31 @@ export class RequestHandler {
} }
} }
// If we found a matching route with function-based targets, use it // If we found a matching route with forward action, select appropriate target
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`); this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
req.socket.end();
return;
}
// Extract target information, resolving functions if needed // Extract target information, resolving functions if needed
let targetHost: string | string[]; let targetHost: string | string[];
let targetPort: number; let targetPort: number;
try { try {
// Check function cache for host and resolve or use cached value // Check function cache for host and resolve or use cached value
if (typeof matchingRoute.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// Generate a function ID for caching (use route name or ID if available) // Generate a function ID for caching (use route name or ID if available)
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -502,7 +590,7 @@ export class RequestHandler {
this.logger.debug(`Using cached host value for ${functionId}`); this.logger.debug(`Using cached host value for ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
// Cache the result // Cache the result
@@ -511,16 +599,16 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedHost = matchingRoute.action.target.host(routeContext); const resolvedHost = selectedTarget.host(routeContext);
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} }
} else { } else {
targetHost = matchingRoute.action.target.host; targetHost = selectedTarget.host;
} }
// Check function cache for port and resolve or use cached value // Check function cache for port and resolve or use cached value
if (typeof matchingRoute.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
// Generate a function ID for caching // Generate a function ID for caching
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -532,7 +620,7 @@ export class RequestHandler {
this.logger.debug(`Using cached port value for ${functionId}`); this.logger.debug(`Using cached port value for ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
targetPort = resolvedPort; targetPort = resolvedPort;
// Cache the result // Cache the result
@@ -541,12 +629,12 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedPort = matchingRoute.action.target.port(routeContext); const resolvedPort = selectedTarget.port(routeContext);
targetPort = resolvedPort; targetPort = resolvedPort;
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
} }
} else { } else {
targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided
@@ -626,17 +714,32 @@ export class RequestHandler {
} }
} }
// If we found a matching route with function-based targets, use it // If we found a matching route with forward action, select appropriate target
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`); this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
stream.respond({ ':status': 502 });
stream.end();
return;
}
// Extract target information, resolving functions if needed // Extract target information, resolving functions if needed
let targetHost: string | string[]; let targetHost: string | string[];
let targetPort: number; let targetPort: number;
try { try {
// Check function cache for host and resolve or use cached value // Check function cache for host and resolve or use cached value
if (typeof matchingRoute.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// Generate a function ID for caching (use route name or ID if available) // Generate a function ID for caching (use route name or ID if available)
const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -648,7 +751,7 @@ export class RequestHandler {
this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`); this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
// Cache the result // Cache the result
@@ -657,16 +760,16 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedHost = matchingRoute.action.target.host(routeContext); const resolvedHost = selectedTarget.host(routeContext);
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} }
} else { } else {
targetHost = matchingRoute.action.target.host; targetHost = selectedTarget.host;
} }
// Check function cache for port and resolve or use cached value // Check function cache for port and resolve or use cached value
if (typeof matchingRoute.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
// Generate a function ID for caching // Generate a function ID for caching
const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -678,7 +781,7 @@ export class RequestHandler {
this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`); this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
targetPort = resolvedPort; targetPort = resolvedPort;
// Cache the result // Cache the result
@@ -687,12 +790,12 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedPort = matchingRoute.action.target.port(routeContext); const resolvedPort = selectedTarget.port(routeContext);
targetPort = resolvedPort; targetPort = resolvedPort;
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
} }
} else { } else {
targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided

View File

@@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js';
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js'; import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
import { ConnectionPool } from './connection-pool.js'; import { ConnectionPool } from './connection-pool.js';
import { HttpRouter } from '../../routing/router/index.js'; import { HttpRouter } from '../../routing/router/index.js';
import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext } from '../../core/models/route-context.js';
import { toBaseContext } from '../../core/models/route-context.js'; import { toBaseContext } from '../../core/models/route-context.js';
import { ContextCreator } from './context-creator.js'; import { ContextCreator } from './context-creator.js';
@@ -53,6 +53,80 @@ export class WebSocketHandler {
this.securityManager.setRoutes(routes); this.securityManager.setRoutes(routes);
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Initialize WebSocket server on an existing HTTPS server * Initialize WebSocket server on an existing HTTPS server
*/ */
@@ -146,9 +220,23 @@ export class WebSocketHandler {
let destination: { host: string; port: number }; let destination: { host: string; port: number };
// If we found a route with the modern router, use it // If we found a route with the modern router, use it
if (route && route.action.type === 'forward' && route.action.target) { if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) {
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`); this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(route.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${route.name}`);
wsIncoming.close(1003, 'No matching target');
return;
}
// Check if WebSockets are enabled for this route // Check if WebSockets are enabled for this route
if (route.action.websocket?.enabled === false) { if (route.action.websocket?.enabled === false) {
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
@@ -192,20 +280,20 @@ export class WebSocketHandler {
try { try {
// Resolve host if it's a function // Resolve host if it's a function
if (typeof route.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
const resolvedHost = route.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} else { } else {
targetHost = route.action.target.host; targetHost = selectedTarget.host;
} }
// Resolve port if it's a function // Resolve port if it's a function
if (typeof route.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
targetPort = route.action.target.port(toBaseContext(routeContext)); targetPort = selectedTarget.port(toBaseContext(routeContext));
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
} else { } else {
targetPort = route.action.target.port === 'preserve' ? routeContext.port : route.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided

View File

@@ -46,11 +46,36 @@ export interface IRouteMatch {
/** /**
* Target configuration for forwarding * Target-specific match criteria for sub-routing within a route
*/
export interface ITargetMatch {
ports?: number[]; // Match specific ports from the route
path?: string; // Match specific paths (supports wildcards like /api/*)
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
method?: string[]; // Match specific HTTP methods (GET, POST, etc.)
}
/**
* Target configuration for forwarding with sub-matching and overrides
*/ */
export interface IRouteTarget { export interface IRouteTarget {
// Optional sub-matching criteria within the route
match?: ITargetMatch;
// Target destination
host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution
port: number | 'preserve' | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port) port: number | 'preserve' | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port)
// Optional target-specific overrides (these override route-level settings)
tls?: IRouteTls; // Override route-level TLS settings
websocket?: IRouteWebSocket; // Override route-level WebSocket settings
loadBalancing?: IRouteLoadBalancing; // Override route-level load balancing
sendProxyProtocol?: boolean; // Override route-level proxy protocol setting
headers?: IRouteHeaders; // Override route-level headers
advanced?: IRouteAdvanced; // Override route-level advanced settings
// Priority for matching (higher values are checked first, default: 0)
priority?: number;
} }
/** /**
@@ -221,19 +246,20 @@ export interface IRouteAction {
// Basic routing // Basic routing
type: TRouteActionType; type: TRouteActionType;
// Target for forwarding // Targets for forwarding (array supports multiple targets with sub-matching)
target?: IRouteTarget; // Required for 'forward' action type
targets?: IRouteTarget[];
// TLS handling // TLS handling (default for all targets, can be overridden per target)
tls?: IRouteTls; tls?: IRouteTls;
// WebSocket support // WebSocket support (default for all targets, can be overridden per target)
websocket?: IRouteWebSocket; websocket?: IRouteWebSocket;
// Load balancing options // Load balancing options (default for all targets, can be overridden per target)
loadBalancing?: IRouteLoadBalancing; loadBalancing?: IRouteLoadBalancing;
// Advanced options // Advanced options (default for all targets, can be overridden per target)
advanced?: IRouteAdvanced; advanced?: IRouteAdvanced;
// Additional options for backend-specific settings // Additional options for backend-specific settings
@@ -251,7 +277,7 @@ export interface IRouteAction {
// Socket handler function (when type is 'socket-handler') // Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler; socketHandler?: TSocketHandler;
// PROXY protocol support // PROXY protocol support (default for all targets, can be overridden per target)
sendProxyProtocol?: boolean; sendProxyProtocol?: boolean;
} }

View File

@@ -123,39 +123,43 @@ export class NFTablesManager {
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
const { action } = route; const { action } = route;
// Ensure we have a target // Ensure we have targets
if (!action.target) { if (!action.targets || action.targets.length === 0) {
throw new Error('Route must have a target to use NFTables forwarding'); throw new Error('Route must have targets to use NFTables forwarding');
} }
// NFTables can only handle a single target, so we use the first target without match criteria
// or the first target if all have match criteria
const defaultTarget = action.targets.find(t => !t.match) || action.targets[0];
// Convert port specifications // Convert port specifications
const fromPorts = this.expandPortRange(route.match.ports); const fromPorts = this.expandPortRange(route.match.ports);
// Determine target port // Determine target port
let toPorts: number | PortRange | Array<number | PortRange>; let toPorts: number | PortRange | Array<number | PortRange>;
if (action.target.port === 'preserve') { if (defaultTarget.port === 'preserve') {
// 'preserve' means use the same ports as the source // 'preserve' means use the same ports as the source
toPorts = fromPorts; toPorts = fromPorts;
} else if (typeof action.target.port === 'function') { } else if (typeof defaultTarget.port === 'function') {
// For function-based ports, we can't determine at setup time // For function-based ports, we can't determine at setup time
// Use the "preserve" approach and let NFTables handle it // Use the "preserve" approach and let NFTables handle it
toPorts = fromPorts; toPorts = fromPorts;
} else { } else {
toPorts = action.target.port; toPorts = defaultTarget.port;
} }
// Determine target host // Determine target host
let toHost: string; let toHost: string;
if (typeof action.target.host === 'function') { if (typeof defaultTarget.host === 'function') {
// Can't determine at setup time, use localhost as a placeholder // Can't determine at setup time, use localhost as a placeholder
// and rely on run-time handling // and rely on run-time handling
toHost = 'localhost'; toHost = 'localhost';
} else if (Array.isArray(action.target.host)) { } else if (Array.isArray(defaultTarget.host)) {
// Use first host for now - NFTables will do simple round-robin // Use first host for now - NFTables will do simple round-robin
toHost = action.target.host[0]; toHost = defaultTarget.host[0];
} else { } else {
toHost = action.target.host; toHost = defaultTarget.host;
} }
// Create options // Create options

View File

@@ -3,7 +3,7 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.
import { logger } from '../../core/utils/logger.js'; import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
// Route checking functions have been removed // Route checking functions have been removed
import type { IRouteConfig, IRouteAction } from './models/route-types.js'; import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext } from '../../core/models/route-context.js';
import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js'; import { WrappedSocket } from '../../core/models/wrapped-socket.js';
@@ -657,6 +657,80 @@ export class RouteConnectionHandler {
} }
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Handle a forward action for a route * Handle a forward action for a route
*/ */
@@ -731,14 +805,37 @@ export class RouteConnectionHandler {
return; return;
} }
// We should have a target configuration for forwarding // Select the appropriate target from the targets array
if (!action.target) { if (!action.targets || action.targets.length === 0) {
logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, { logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, {
connectionId, connectionId,
component: 'route-handler' component: 'route-handler'
}); });
socket.end(); socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'missing_target'); this.smartProxy.connectionManager.cleanupConnection(record, 'missing_targets');
return;
}
// Create context for target selection
const targetSelectionContext = {
port: record.localPort,
path: undefined, // Will be populated from HTTP headers if available
headers: undefined, // Will be populated from HTTP headers if available
method: undefined // Will be populated from HTTP headers if available
};
// TODO: Extract path, headers, and method from initialChunk if it's HTTP
// For now, we'll select based on port only
const selectedTarget = this.selectTarget(action.targets, targetSelectionContext);
if (!selectedTarget) {
logger.log('error', `No matching target found for connection ${connectionId}`, {
connectionId,
port: targetSelectionContext.port,
component: 'route-handler'
});
socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'no_matching_target');
return; return;
} }
@@ -759,9 +856,9 @@ export class RouteConnectionHandler {
// Determine host using function or static value // Determine host using function or static value
let targetHost: string | string[]; let targetHost: string | string[];
if (typeof action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
try { try {
targetHost = action.target.host(routeContext); targetHost = selectedTarget.host(routeContext);
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, {
connectionId, connectionId,
@@ -780,7 +877,7 @@ export class RouteConnectionHandler {
return; return;
} }
} else { } else {
targetHost = action.target.host; targetHost = selectedTarget.host;
} }
// If an array of hosts, select one randomly for load balancing // If an array of hosts, select one randomly for load balancing
@@ -790,9 +887,9 @@ export class RouteConnectionHandler {
// Determine port using function or static value // Determine port using function or static value
let targetPort: number; let targetPort: number;
if (typeof action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
try { try {
targetPort = action.target.port(routeContext); targetPort = selectedTarget.port(routeContext);
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, {
connectionId, connectionId,
@@ -813,20 +910,27 @@ export class RouteConnectionHandler {
this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error');
return; return;
} }
} else if (action.target.port === 'preserve') { } else if (selectedTarget.port === 'preserve') {
// Use incoming port if port is 'preserve' // Use incoming port if port is 'preserve'
targetPort = record.localPort; targetPort = record.localPort;
} else { } else {
// Use static port from configuration // Use static port from configuration
targetPort = action.target.port; targetPort = selectedTarget.port;
} }
// Store the resolved host in the context // Store the resolved host in the context
routeContext.targetHost = selectedHost; routeContext.targetHost = selectedHost;
// Get effective settings (target overrides route-level settings)
const effectiveTls = selectedTarget.tls || action.tls;
const effectiveWebsocket = selectedTarget.websocket || action.websocket;
const effectiveSendProxyProtocol = selectedTarget.sendProxyProtocol !== undefined
? selectedTarget.sendProxyProtocol
: action.sendProxyProtocol;
// Determine if this needs TLS handling // Determine if this needs TLS handling
if (action.tls) { if (effectiveTls) {
switch (action.tls.mode) { switch (effectiveTls.mode) {
case 'passthrough': case 'passthrough':
// For TLS passthrough, just forward directly // For TLS passthrough, just forward directly
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
@@ -853,9 +957,9 @@ export class RouteConnectionHandler {
// For TLS termination, use HttpProxy // For TLS termination, use HttpProxy
if (this.smartProxy.httpProxyBridge.getHttpProxy()) { if (this.smartProxy.httpProxyBridge.getHttpProxy()) {
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host} for connection ${connectionId}`, { logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host} for connection ${connectionId}`, {
connectionId, connectionId,
targetHost: action.target.host, targetHost: selectedTarget.host,
component: 'route-handler' component: 'route-handler'
}); });
} }
@@ -929,10 +1033,10 @@ export class RouteConnectionHandler {
} else { } else {
// Basic forwarding // Basic forwarding
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using basic forwarding to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host}:${action.target.port} for connection ${connectionId}`, { logger.log('info', `Using basic forwarding to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host}:${selectedTarget.port} for connection ${connectionId}`, {
connectionId, connectionId,
targetHost: action.target.host, targetHost: selectedTarget.host,
targetPort: action.target.port, targetPort: selectedTarget.port,
component: 'route-handler' component: 'route-handler'
}); });
} }
@@ -940,27 +1044,27 @@ export class RouteConnectionHandler {
// Get the appropriate host value // Get the appropriate host value
let targetHost: string; let targetHost: string;
if (typeof action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// For function-based host, use the same routeContext created earlier // For function-based host, use the same routeContext created earlier
const hostResult = action.target.host(routeContext); const hostResult = selectedTarget.host(routeContext);
targetHost = Array.isArray(hostResult) targetHost = Array.isArray(hostResult)
? hostResult[Math.floor(Math.random() * hostResult.length)] ? hostResult[Math.floor(Math.random() * hostResult.length)]
: hostResult; : hostResult;
} else { } else {
// For static host value // For static host value
targetHost = Array.isArray(action.target.host) targetHost = Array.isArray(selectedTarget.host)
? action.target.host[Math.floor(Math.random() * action.target.host.length)] ? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)]
: action.target.host; : selectedTarget.host;
} }
// Determine port - either function-based, static, or preserve incoming port // Determine port - either function-based, static, or preserve incoming port
let targetPort: number; let targetPort: number;
if (typeof action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
targetPort = action.target.port(routeContext); targetPort = selectedTarget.port(routeContext);
} else if (action.target.port === 'preserve') { } else if (selectedTarget.port === 'preserve') {
targetPort = record.localPort; targetPort = record.localPort;
} else { } else {
targetPort = action.target.port; targetPort = selectedTarget.port;
} }
// Update the connection record and context with resolved values // Update the connection record and context with resolved values

View File

@@ -42,7 +42,7 @@ export function createHttpRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Create the route config // Create the route config
@@ -82,7 +82,7 @@ export function createHttpsTerminateRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
tls: { tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto' certificate: options.certificate || 'auto'
@@ -152,7 +152,7 @@ export function createHttpsPassthroughRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }
@@ -243,7 +243,7 @@ export function createLoadBalancerRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Add TLS configuration if provided // Add TLS configuration if provided
@@ -303,7 +303,7 @@ export function createApiRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Add TLS configuration if using HTTPS // Add TLS configuration if using HTTPS
@@ -374,7 +374,7 @@ export function createWebSocketRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
websocket: { websocket: {
enabled: true, enabled: true,
pingInterval: options.pingInterval || 30000, // 30 seconds pingInterval: options.pingInterval || 30000, // 30 seconds
@@ -432,10 +432,10 @@ export function createPortMappingRoute(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: options.targetHost, host: options.targetHost,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -500,10 +500,10 @@ export function createDynamicRoute(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: options.targetHost, host: options.targetHost,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -548,10 +548,10 @@ export function createSmartLoadBalancer(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: hostSelector, host: hostSelector,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -609,10 +609,10 @@ export function createNfTablesRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
nftables: { nftables: {
protocol: options.protocol || 'tcp', protocol: options.protocol || 'tcp',

View File

@@ -24,10 +24,10 @@ export function createHttpRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
} }]
}, },
name: options.name || `HTTP: ${Array.isArray(domains) ? domains.join(', ') : domains}` name: options.name || `HTTP: ${Array.isArray(domains) ? domains.join(', ') : domains}`
}; };
@@ -53,10 +53,10 @@ export function createHttpsTerminateRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
tls: { tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto' certificate: options.certificate || 'auto'
@@ -83,10 +83,10 @@ export function createHttpsPassthroughRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }

View File

@@ -66,12 +66,9 @@ export function mergeRouteConfigs(
// Otherwise merge the action properties // Otherwise merge the action properties
mergedRoute.action = { ...mergedRoute.action }; mergedRoute.action = { ...mergedRoute.action };
// Merge target // Merge targets
if (overrideRoute.action.target) { if (overrideRoute.action.targets) {
mergedRoute.action.target = { mergedRoute.action.targets = overrideRoute.action.targets;
...mergedRoute.action.target,
...overrideRoute.action.target
};
} }
// Merge TLS options // Merge TLS options

View File

@@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
errors.push(`Invalid action type: ${action.type}`); errors.push(`Invalid action type: ${action.type}`);
} }
// Validate target for 'forward' action // Validate targets for 'forward' action
if (action.type === 'forward') { if (action.type === 'forward') {
if (!action.target) { if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
errors.push('Target is required for forward action'); errors.push('Targets array is required for forward action');
} else { } else {
// Validate target host // Validate each target
if (!action.target.host) { action.targets.forEach((target, index) => {
errors.push('Target host is required'); // Validate target host
} else if (typeof action.target.host !== 'string' && if (!target.host) {
!Array.isArray(action.target.host) && errors.push(`Target[${index}] host is required`);
typeof action.target.host !== 'function') { } else if (typeof target.host !== 'string' &&
errors.push('Target host must be a string, array of strings, or function'); !Array.isArray(target.host) &&
} typeof target.host !== 'function') {
errors.push(`Target[${index}] host must be a string, array of strings, or function`);
}
// Validate target port // Validate target port
if (action.target.port === undefined) { if (target.port === undefined) {
errors.push('Target port is required'); errors.push(`Target[${index}] port is required`);
} else if (typeof action.target.port !== 'number' && } else if (typeof target.port !== 'number' &&
typeof action.target.port !== 'function') { typeof target.port !== 'function' &&
errors.push('Target port must be a number or a function'); target.port !== 'preserve') {
} else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) { errors.push(`Target[${index}] port must be a number, 'preserve', or a function`);
errors.push('Target port must be between 1 and 65535'); } else if (typeof target.port === 'number' && !isValidPort(target.port)) {
} errors.push(`Target[${index}] port must be between 1 and 65535`);
}
// Validate match criteria if present
if (target.match) {
if (target.match.ports && !Array.isArray(target.match.ports)) {
errors.push(`Target[${index}] match.ports must be an array`);
}
if (target.match.method && !Array.isArray(target.match.method)) {
errors.push(`Target[${index}] match.method must be an array`);
}
}
});
} }
// Validate TLS options for forward actions // Validate TLS options for forward actions
@@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
switch (actionType) { switch (actionType) {
case 'forward': case 'forward':
return !!route.action.target && !!route.action.target.host && !!route.action.target.port; return !!route.action.targets &&
Array.isArray(route.action.targets) &&
route.action.targets.length > 0 &&
route.action.targets.every(t => t.host && t.port !== undefined);
case 'socket-handler': case 'socket-handler':
return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; return !!route.action.socketHandler && typeof route.action.socketHandler === 'function';
default: default: