update
This commit is contained in:
393
readme.plan.md
393
readme.plan.md
@@ -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
|
||||||
|
- [ ] Update `IRouteTarget` interface in `route-types.ts`
|
||||||
|
- Add `match?: ITargetMatch` property
|
||||||
|
- Add override properties (tls, websocket, etc.)
|
||||||
|
- Add `priority?: number` field
|
||||||
|
- [ ] Create `ITargetMatch` interface for sub-matching criteria
|
||||||
|
- [ ] 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`
|
- [ ] Update route matching logic to handle multiple targets
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 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
|
- [ ] Find all occurrences of `action.target` and update to `action.targets[0]`
|
||||||
- [ ] Modify `provisionCertificate()` method to check for custom function first
|
- [ ] Update route helpers and utilities
|
||||||
|
- [ ] Update certificate manager to handle multiple targets
|
||||||
|
- [ ] Update connection handlers
|
||||||
|
|
||||||
#### 2. Implement Custom Certificate Provisioning Logic
|
#### Phase 4: Testing
|
||||||
**Location**: Modify `provisionAcmeCertificate()` method
|
- [ ] 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
|
|
@@ -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
|
||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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,19 @@ 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;
|
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 +276,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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 || effectiveTls;
|
||||||
|
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
|
||||||
|
@@ -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
|
||||||
|
@@ -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 each target
|
||||||
|
action.targets.forEach((target, index) => {
|
||||||
// Validate target host
|
// Validate target host
|
||||||
if (!action.target.host) {
|
if (!target.host) {
|
||||||
errors.push('Target host is required');
|
errors.push(`Target[${index}] host is required`);
|
||||||
} else if (typeof action.target.host !== 'string' &&
|
} else if (typeof target.host !== 'string' &&
|
||||||
!Array.isArray(action.target.host) &&
|
!Array.isArray(target.host) &&
|
||||||
typeof action.target.host !== 'function') {
|
typeof target.host !== 'function') {
|
||||||
errors.push('Target host must be a string, array of strings, or 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:
|
||||||
|
Reference in New Issue
Block a user