Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e9c753d2a9 | ||
|
6aa5f415c1 | ||
|
b26abbfd87 | ||
|
82df9a6f52 | ||
|
a625675922 | ||
|
eac6075a12 | ||
|
2d2e9e9475 | ||
|
257a5dc319 | ||
|
5d206b9800 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-10-01T02:31:27.435Z",
|
||||
"issueDate": "2025-07-03T02:31:27.435Z",
|
||||
"savedAt": "2025-07-03T02:31:27.435Z"
|
||||
"expiryDate": "2025-10-18T13:15:48.916Z",
|
||||
"issueDate": "2025-07-20T13:15:48.916Z",
|
||||
"savedAt": "2025-07-20T13:15:48.916Z"
|
||||
}
|
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-07-20 - 20.0.0 - 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)
|
||||
Fix connection handling and improve route matching edge cases
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "19.6.16",
|
||||
"version": "20.0.0",
|
||||
"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.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
@@ -296,3 +296,53 @@ You'll see:
|
||||
- If 50+ events occur within 1 second, immediate flush is triggered
|
||||
- Prevents memory buildup during flooding attacks
|
||||
- Maintains real-time visibility during incidents
|
||||
|
||||
## Custom Certificate Provision Function
|
||||
|
||||
The `certProvisionFunction` feature has been implemented to allow users to provide their own certificate generation logic.
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Type Definition**: The function must return `Promise<TSmartProxyCertProvisionObject>` where:
|
||||
- `TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'`
|
||||
- Return `'http01'` to fallback to Let's Encrypt
|
||||
- Return a certificate object for custom certificates
|
||||
|
||||
2. **Certificate Manager Changes**:
|
||||
- Added `certProvisionFunction` property to CertificateManager
|
||||
- Modified `provisionAcmeCertificate()` to check custom function first
|
||||
- Custom certificates are stored with source type 'custom'
|
||||
- Expiry date extraction currently defaults to 90 days
|
||||
|
||||
3. **Configuration Options**:
|
||||
- `certProvisionFunction`: The custom provision function
|
||||
- `certProvisionFallbackToAcme`: Whether to fallback to ACME on error (default: true)
|
||||
|
||||
4. **Usage Example**:
|
||||
```typescript
|
||||
new SmartProxy({
|
||||
certProvisionFunction: async (domain: string) => {
|
||||
if (domain === 'internal.example.com') {
|
||||
return {
|
||||
cert: customCert,
|
||||
key: customKey,
|
||||
ca: customCA
|
||||
} as unknown as TSmartProxyCertProvisionObject;
|
||||
}
|
||||
return 'http01'; // Use Let's Encrypt
|
||||
},
|
||||
certProvisionFallbackToAcme: true
|
||||
})
|
||||
```
|
||||
|
||||
5. **Testing Notes**:
|
||||
- Type assertions through `unknown` are needed in tests due to strict interface typing
|
||||
- Mock certificate objects work for testing but need proper type casting
|
||||
- The actual certificate parsing for expiry dates would need a proper X.509 parser
|
||||
|
||||
### Future Improvements
|
||||
|
||||
1. Implement proper certificate expiry date extraction using X.509 parsing
|
||||
2. Add support for returning expiry date with custom certificates
|
||||
3. Consider adding validation for custom certificate format
|
||||
4. Add events/hooks for certificate provisioning lifecycle
|
107
readme.md
107
readme.md
@@ -2336,14 +2336,117 @@ sequenceDiagram
|
||||
• Efficient SNI extraction
|
||||
• Minimal overhead routing
|
||||
|
||||
## Certificate Hooks & Events
|
||||
## Certificate Management
|
||||
|
||||
### Custom Certificate Provision Function
|
||||
|
||||
SmartProxy supports a custom certificate provision function that allows you to provide your own certificate generation logic while maintaining compatibility with Let's Encrypt:
|
||||
|
||||
```typescript
|
||||
const proxy = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
// Option 1: Return a custom certificate
|
||||
if (domain === 'internal.example.com') {
|
||||
return {
|
||||
cert: customCertPEM,
|
||||
key: customKeyPEM,
|
||||
ca: customCAPEM // Optional CA chain
|
||||
};
|
||||
}
|
||||
|
||||
// Option 2: Fallback to Let's Encrypt
|
||||
return 'http01';
|
||||
},
|
||||
|
||||
// Control fallback behavior when custom provision fails
|
||||
certProvisionFallbackToAcme: true, // Default: true
|
||||
|
||||
routes: [...]
|
||||
});
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Called for any route with `certificate: 'auto'`
|
||||
- Return custom certificate object or `'http01'` to use Let's Encrypt
|
||||
- Participates in automatic renewal cycle (checked every 12 hours)
|
||||
- Custom certificates stored with source type 'custom' for tracking
|
||||
|
||||
**Configuration Options:**
|
||||
- `certProvisionFunction`: Async function that receives domain and returns certificate or 'http01'
|
||||
- `certProvisionFallbackToAcme`: Whether to fallback to Let's Encrypt if custom provision fails (default: true)
|
||||
|
||||
**Advanced Example with Certificate Manager:**
|
||||
|
||||
```typescript
|
||||
const certManager = new MyCertificateManager();
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string) => {
|
||||
try {
|
||||
// Check if we have a custom certificate for this domain
|
||||
if (await certManager.hasCustomCert(domain)) {
|
||||
const cert = await certManager.getCertificate(domain);
|
||||
return {
|
||||
cert: cert.certificate,
|
||||
key: cert.privateKey,
|
||||
ca: cert.chain
|
||||
};
|
||||
}
|
||||
|
||||
// Use Let's Encrypt for public domains
|
||||
if (domain.endsWith('.example.com')) {
|
||||
return 'http01';
|
||||
}
|
||||
|
||||
// Generate self-signed for internal domains
|
||||
if (domain.endsWith('.internal')) {
|
||||
const selfSigned = await certManager.generateSelfSigned(domain);
|
||||
return {
|
||||
cert: selfSigned.cert,
|
||||
key: selfSigned.key,
|
||||
ca: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Default to Let's Encrypt
|
||||
return 'http01';
|
||||
} catch (error) {
|
||||
console.error(`Certificate provision failed for ${domain}:`, error);
|
||||
// Will fallback to Let's Encrypt if certProvisionFallbackToAcme is true
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
certProvisionFallbackToAcme: true,
|
||||
|
||||
routes: [
|
||||
// Routes that use automatic certificates
|
||||
{
|
||||
match: { ports: 443, domains: ['app.example.com', '*.internal'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Certificate Events
|
||||
|
||||
Listen for certificate events via EventEmitter:
|
||||
- **SmartProxy**:
|
||||
- `certificate` (domain, publicKey, privateKey, expiryDate, source, isRenewal)
|
||||
- Events from CertManager are propagated
|
||||
|
||||
Provide a `certProvisionFunction(domain)` in SmartProxy settings to supply static certs or return `'http01'`.
|
||||
```typescript
|
||||
proxy.on('certificate', (domain, cert, key, expiryDate, source, isRenewal) => {
|
||||
console.log(`Certificate ${isRenewal ? 'renewed' : 'provisioned'} for ${domain}`);
|
||||
console.log(`Source: ${source}`); // 'acme', 'static', or 'custom'
|
||||
console.log(`Expires: ${expiryDate}`);
|
||||
});
|
||||
```
|
||||
|
||||
## SmartProxy: Common Use Cases
|
||||
|
||||
|
183
readme.plan.md
183
readme.plan.md
@@ -1,53 +1,154 @@
|
||||
# SmartProxy Connection Limiting Improvements Plan
|
||||
# SmartProxy Enhanced Routing Plan
|
||||
|
||||
Command to re-read CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
|
||||
## Goal
|
||||
Implement enhanced routing structure with multiple targets per route, sub-matching capabilities, and target-specific overrides to enable more elegant and DRY configurations.
|
||||
|
||||
## Issues Identified
|
||||
## Key Changes
|
||||
|
||||
1. **HttpProxy Bypass**: Connections forwarded to HttpProxy for TLS termination only check global limits, not per-IP limits
|
||||
2. **Missing Route-Level Connection Enforcement**: Routes can define `security.maxConnections` but it's never enforced
|
||||
3. **Cleanup Queue Race Condition**: New connections can be added to cleanup queue while processing
|
||||
4. **IP Tracking Memory Optimization**: IP entries remain in map even without active connections
|
||||
### 1. Update Route Target Interface
|
||||
- Add `match` property to `IRouteTarget` for sub-matching within routes
|
||||
- Add target-specific override properties (tls, websocket, loadBalancing, etc.)
|
||||
- Add priority field for controlling match order
|
||||
|
||||
## Implementation Steps
|
||||
### 2. Update Route Action Interface
|
||||
- Remove singular `target` property
|
||||
- Use only `targets` array (single target = array with one element)
|
||||
- Maintain backwards compatibility during migration
|
||||
|
||||
### 1. Fix HttpProxy Per-IP Validation ✓
|
||||
- [x] Pass IP information to HttpProxy when forwarding connections
|
||||
- [x] Add per-IP validation in HttpProxy connection handler
|
||||
- [x] Ensure connection tracking is consistent between SmartProxy and HttpProxy
|
||||
### 3. Implementation Steps
|
||||
|
||||
### 2. Implement Route-Level Connection Limits ✓
|
||||
- [x] Add connection count tracking per route in ConnectionManager
|
||||
- [x] Update SharedSecurityManager.isAllowed() to check route-specific maxConnections
|
||||
- [x] Add route connection limit validation in route-connection-handler.ts
|
||||
#### 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[]`
|
||||
|
||||
### 3. Fix Cleanup Queue Race Condition ✓
|
||||
- [x] Implement proper queue snapshotting before processing
|
||||
- [x] Ensure new connections added during processing aren't missed
|
||||
- [x] Add proper synchronization for cleanup operations
|
||||
#### Phase 2: Route Resolution Logic
|
||||
- [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
|
||||
|
||||
### 4. Optimize IP Tracking Memory Usage ✓
|
||||
- [x] Add periodic cleanup for IPs with no active connections
|
||||
- [x] Implement expiry for rate limit timestamps
|
||||
- [x] Add memory-efficient data structures for IP tracking
|
||||
#### Phase 3: Code Migration
|
||||
- [x] Find all occurrences of `action.target` and update to use `action.targets`
|
||||
- [x] Update route helpers and utilities
|
||||
- [x] Update certificate manager to handle multiple targets
|
||||
- [x] Update connection handlers
|
||||
|
||||
### 5. Add Comprehensive Tests ✓
|
||||
- [x] Test per-IP limits with HttpProxy forwarding
|
||||
- [x] Test route-level connection limits
|
||||
- [x] Test cleanup queue edge cases
|
||||
- [x] Test memory usage with many unique IPs
|
||||
#### Phase 4: Testing
|
||||
- [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
|
||||
|
||||
### 6. Log Deduplication for High-Volume Scenarios ✓
|
||||
- [x] Implement LogDeduplicator utility for batching similar events
|
||||
- [x] Add deduplication for connection rejections, terminations, and cleanups
|
||||
- [x] Include rejection reasons in IP rejection summaries
|
||||
- [x] Provide aggregated summaries with meaningful context
|
||||
#### Phase 5: Documentation
|
||||
- [ ] Update type documentation
|
||||
- [ ] Add examples of new routing patterns
|
||||
- [ ] Document migration path for existing configs
|
||||
|
||||
## Notes
|
||||
## Example Configurations
|
||||
|
||||
- All connection limiting is now consistent across SmartProxy and HttpProxy
|
||||
- Route-level limits provide additional granular control
|
||||
- Memory usage is optimized for high-traffic scenarios
|
||||
- Comprehensive test coverage ensures reliability
|
||||
- Log deduplication reduces spam during attacks or high-traffic periods
|
||||
- IP rejection summaries now include rejection reasons in main message
|
||||
### Before (Current)
|
||||
```typescript
|
||||
// Need separate routes for different ports/paths
|
||||
[
|
||||
{
|
||||
match: { domains: ['api.example.com'], ports: [80] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend', port: 8080 },
|
||||
tls: { mode: 'terminate' }
|
||||
}
|
||||
},
|
||||
{
|
||||
match: { domains: ['api.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend', port: 8081 },
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### After (Enhanced)
|
||||
```typescript
|
||||
// Single route with multiple targets
|
||||
{
|
||||
match: { domains: ['api.example.com'], ports: [80, 443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{
|
||||
match: { ports: [80] },
|
||||
host: 'backend',
|
||||
port: 8080,
|
||||
tls: { mode: 'terminate' }
|
||||
},
|
||||
{
|
||||
match: { ports: [443] },
|
||||
host: 'backend',
|
||||
port: 8081,
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
1. **DRY Configuration**: No need to duplicate common settings across routes
|
||||
2. **Flexibility**: Different backends for different ports/paths within same domain
|
||||
3. **Clarity**: All routing for a domain in one place
|
||||
4. **Performance**: Single route lookup instead of multiple
|
||||
5. **Backwards Compatible**: Can migrate gradually
|
||||
|
||||
## Migration Strategy
|
||||
1. Keep support for `target` temporarily with deprecation warning
|
||||
2. Auto-convert `target` to `targets: [target]` internally
|
||||
3. Update documentation with migration examples
|
||||
4. Remove `target` support in next major version
|
2749
test-output.log
Normal file
2749
test-output.log
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,14 +32,14 @@ tap.test('PathMatcher - wildcard matching', async () => {
|
||||
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
|
||||
expect(result.matches).toEqual(true);
|
||||
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 () => {
|
||||
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
|
||||
expect(result.matches).toEqual(true);
|
||||
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 () => {
|
||||
|
@@ -58,7 +58,7 @@ tap.test('Shared Security Manager', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'target.com', port: 443 }
|
||||
targets: [{ host: 'target.com', port: 443 }]
|
||||
},
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.*', '192.168.1.*'],
|
||||
@@ -113,7 +113,7 @@ tap.test('Shared Security Manager', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'target.com', port: 443 }
|
||||
targets: [{ host: 'target.com', port: 443 }]
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
|
@@ -59,7 +59,7 @@ tap.test('should create ACME challenge route', async (tools) => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 8080 }
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
},
|
||||
challengeRoute
|
||||
|
@@ -18,7 +18,7 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8181 },
|
||||
targets: [{ host: 'localhost', port: 8181 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -30,7 +30,7 @@ tap.test('should defer certificate provisioning until after ports are listening'
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8181 },
|
||||
targets: [{ host: 'localhost', port: 8181 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
@@ -126,7 +126,7 @@ tap.test('should have ACME challenge route ready before certificate provisioning
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8181 },
|
||||
targets: [{ host: 'localhost', port: 8181 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
|
@@ -16,10 +16,10 @@ tap.test('SmartCertManager should call getCertificateForDomain with wildcard opt
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
360
test/test.certificate-provision.ts
Normal file
360
test/test.certificate-provision.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import type { TSmartProxyCertProvisionObject } from '../ts/index.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
let testProxy: SmartProxy;
|
||||
|
||||
// Load test certificates from helpers
|
||||
const testCert = fs.readFileSync(path.join(__dirname, 'helpers/test-cert.pem'), 'utf8');
|
||||
const testKey = fs.readFileSync(path.join(__dirname, 'helpers/test-key.pem'), 'utf8');
|
||||
|
||||
tap.test('SmartProxy should support custom certificate provision function', async () => {
|
||||
// Create test certificate object matching ICert interface
|
||||
const testCertObject = {
|
||||
id: 'test-cert-1',
|
||||
domainName: 'test.example.com',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
|
||||
privateKey: testKey,
|
||||
publicKey: testCert,
|
||||
csr: ''
|
||||
};
|
||||
|
||||
// Custom certificate store for testing
|
||||
const customCerts = new Map<string, typeof testCertObject>();
|
||||
customCerts.set('test.example.com', testCertObject);
|
||||
|
||||
// Create proxy with custom certificate provision
|
||||
testProxy = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
console.log(`Custom cert provision called for domain: ${domain}`);
|
||||
|
||||
// Return custom cert for known domains
|
||||
if (customCerts.has(domain)) {
|
||||
console.log(`Returning custom certificate for ${domain}`);
|
||||
return customCerts.get(domain)!;
|
||||
}
|
||||
|
||||
// Fallback to Let's Encrypt for other domains
|
||||
console.log(`Falling back to Let's Encrypt for ${domain}`);
|
||||
return 'http01';
|
||||
},
|
||||
certProvisionFallbackToAcme: true,
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'test-route',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['test.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(testProxy).toBeInstanceOf(SmartProxy);
|
||||
});
|
||||
|
||||
tap.test('Custom certificate provision function should be called', async () => {
|
||||
let provisionCalled = false;
|
||||
const provisionedDomains: string[] = [];
|
||||
|
||||
const testProxy2 = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
provisionCalled = true;
|
||||
provisionedDomains.push(domain);
|
||||
|
||||
// Return a test certificate matching ICert interface
|
||||
return {
|
||||
id: `test-cert-${domain}`,
|
||||
domainName: domain,
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: testKey,
|
||||
publicKey: testCert,
|
||||
csr: ''
|
||||
};
|
||||
},
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 9080
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'custom-cert-route',
|
||||
match: {
|
||||
ports: [9443],
|
||||
domains: ['custom.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Mock the certificate manager to test our custom provision function
|
||||
let certManagerCalled = false;
|
||||
const origCreateCertManager = (testProxy2 as any).createCertificateManager;
|
||||
(testProxy2 as any).createCertificateManager = async function(...args: any[]) {
|
||||
const certManager = await origCreateCertManager.apply(testProxy2, args);
|
||||
|
||||
// Override provisionAllCertificates to track calls
|
||||
const origProvisionAll = certManager.provisionAllCertificates;
|
||||
certManager.provisionAllCertificates = async function() {
|
||||
certManagerCalled = true;
|
||||
await origProvisionAll.call(certManager);
|
||||
};
|
||||
|
||||
return certManager;
|
||||
};
|
||||
|
||||
// Start the proxy (this will trigger certificate provisioning)
|
||||
await testProxy2.start();
|
||||
|
||||
expect(certManagerCalled).toBeTrue();
|
||||
expect(provisionCalled).toBeTrue();
|
||||
expect(provisionedDomains).toContain('custom.example.com');
|
||||
|
||||
await testProxy2.stop();
|
||||
});
|
||||
|
||||
tap.test('Should fallback to ACME when custom provision fails', async () => {
|
||||
const failedDomains: string[] = [];
|
||||
let acmeAttempted = false;
|
||||
|
||||
const testProxy3 = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
failedDomains.push(domain);
|
||||
throw new Error('Custom provision failed for testing');
|
||||
},
|
||||
certProvisionFallbackToAcme: true,
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 9080
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'fallback-route',
|
||||
match: {
|
||||
ports: [9444],
|
||||
domains: ['fallback.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Mock to track ACME attempts
|
||||
const origCreateCertManager = (testProxy3 as any).createCertificateManager;
|
||||
(testProxy3 as any).createCertificateManager = async function(...args: any[]) {
|
||||
const certManager = await origCreateCertManager.apply(testProxy3, args);
|
||||
|
||||
// Mock SmartAcme to avoid real ACME calls
|
||||
(certManager as any).smartAcme = {
|
||||
getCertificateForDomain: async () => {
|
||||
acmeAttempted = true;
|
||||
throw new Error('Mocked ACME failure');
|
||||
}
|
||||
};
|
||||
|
||||
return certManager;
|
||||
};
|
||||
|
||||
// Start the proxy
|
||||
await testProxy3.start();
|
||||
|
||||
// Custom provision should have failed
|
||||
expect(failedDomains).toContain('fallback.example.com');
|
||||
|
||||
// ACME should have been attempted as fallback
|
||||
expect(acmeAttempted).toBeTrue();
|
||||
|
||||
await testProxy3.stop();
|
||||
});
|
||||
|
||||
tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => {
|
||||
let errorThrown = false;
|
||||
let errorMessage = '';
|
||||
|
||||
const testProxy4 = new SmartProxy({
|
||||
certProvisionFunction: async (_domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
throw new Error('Custom provision failed for testing');
|
||||
},
|
||||
certProvisionFallbackToAcme: false,
|
||||
routes: [
|
||||
{
|
||||
name: 'no-fallback-route',
|
||||
match: {
|
||||
ports: [9445],
|
||||
domains: ['no-fallback.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Mock certificate manager to capture errors
|
||||
const origCreateCertManager = (testProxy4 as any).createCertificateManager;
|
||||
(testProxy4 as any).createCertificateManager = async function(...args: any[]) {
|
||||
const certManager = await origCreateCertManager.apply(testProxy4, args);
|
||||
|
||||
// Override provisionAllCertificates to capture errors
|
||||
const origProvisionAll = certManager.provisionAllCertificates;
|
||||
certManager.provisionAllCertificates = async function() {
|
||||
try {
|
||||
await origProvisionAll.call(certManager);
|
||||
} catch (e) {
|
||||
errorThrown = true;
|
||||
errorMessage = e.message;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
return certManager;
|
||||
};
|
||||
|
||||
try {
|
||||
await testProxy4.start();
|
||||
} catch (e) {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
expect(errorMessage).toInclude('Custom provision failed for testing');
|
||||
|
||||
await testProxy4.stop();
|
||||
});
|
||||
|
||||
tap.test('Should return http01 for unknown domains', async () => {
|
||||
let returnedHttp01 = false;
|
||||
let acmeAttempted = false;
|
||||
|
||||
const testProxy5 = new SmartProxy({
|
||||
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
|
||||
if (domain === 'known.example.com') {
|
||||
return {
|
||||
id: `test-cert-${domain}`,
|
||||
domainName: domain,
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: testKey,
|
||||
publicKey: testCert,
|
||||
csr: ''
|
||||
};
|
||||
}
|
||||
returnedHttp01 = true;
|
||||
return 'http01';
|
||||
},
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 9081
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'unknown-domain-route',
|
||||
match: {
|
||||
ports: [9446],
|
||||
domains: ['unknown.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Mock to track ACME attempts
|
||||
const origCreateCertManager = (testProxy5 as any).createCertificateManager;
|
||||
(testProxy5 as any).createCertificateManager = async function(...args: any[]) {
|
||||
const certManager = await origCreateCertManager.apply(testProxy5, args);
|
||||
|
||||
// Mock SmartAcme to track attempts
|
||||
(certManager as any).smartAcme = {
|
||||
getCertificateForDomain: async () => {
|
||||
acmeAttempted = true;
|
||||
throw new Error('Mocked ACME failure');
|
||||
}
|
||||
};
|
||||
|
||||
return certManager;
|
||||
};
|
||||
|
||||
await testProxy5.start();
|
||||
|
||||
// Should have returned http01 for unknown domain
|
||||
expect(returnedHttp01).toBeTrue();
|
||||
|
||||
// ACME should have been attempted
|
||||
expect(acmeAttempted).toBeTrue();
|
||||
|
||||
await testProxy5.stop();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
// Clean up any test proxies
|
||||
if (testProxy) {
|
||||
await testProxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -7,7 +7,7 @@ const testProxy = new SmartProxy({
|
||||
match: { ports: 9443, domains: 'test.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
@@ -67,7 +67,7 @@ tap.test('should handle static certificates', async () => {
|
||||
match: { ports: 9444, domains: 'static.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
@@ -96,7 +96,7 @@ tap.test('should handle ACME challenge routes', async () => {
|
||||
match: { ports: 9445, domains: 'acme.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
@@ -112,7 +112,7 @@ tap.test('should handle ACME challenge routes', async () => {
|
||||
match: { ports: 9081, domains: 'acme.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 }
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
}],
|
||||
acme: {
|
||||
@@ -167,7 +167,7 @@ tap.test('should renew certificates', async () => {
|
||||
match: { ports: 9446, domains: 'renew.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -8,7 +8,7 @@ tap.test('should create SmartProxy with certificate routes', async () => {
|
||||
match: { ports: 8443, domains: 'test.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -13,7 +13,7 @@ tap.test('cleanup queue bug - verify queue processing handles more than batch si
|
||||
match: { ports: 8588 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9996 }
|
||||
targets: [{ host: 'localhost', port: 9996 }]
|
||||
}
|
||||
}],
|
||||
enableDetailedLogging: false,
|
||||
|
@@ -18,10 +18,10 @@ tap.test('should handle clients that connect and immediately disconnect without
|
||||
match: { ports: 8560 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -173,10 +173,10 @@ tap.test('should handle clients that error during connection', async () => {
|
||||
match: { ports: 8561 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -20,10 +20,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
match: { ports: 8570 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -31,10 +31,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
match: { ports: 8571 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
@@ -215,10 +215,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -65,10 +65,10 @@ tap.test('should forward TCP connections correctly', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 7001,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -118,10 +118,10 @@ tap.test('should handle TLS passthrough correctly', async () => {
|
||||
tls: {
|
||||
mode: 'passthrough',
|
||||
},
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 7002,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -179,10 +179,10 @@ tap.test('should handle SNI-based forwarding', async () => {
|
||||
tls: {
|
||||
mode: 'passthrough',
|
||||
},
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 7002,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -197,10 +197,10 @@ tap.test('should handle SNI-based forwarding', async () => {
|
||||
tls: {
|
||||
mode: 'passthrough',
|
||||
},
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 7002,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@@ -90,10 +90,10 @@ tap.test('Setup test environment', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: TEST_SERVER_PORT
|
||||
}
|
||||
}]
|
||||
},
|
||||
security: {
|
||||
maxConnections: 5 // Low limit for testing
|
||||
@@ -198,10 +198,10 @@ tap.test('HttpProxy per-IP validation', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: TEST_SERVER_PORT
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate'
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
|
||||
match: { ports: [18443], domains: ['test.local'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 },
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
@@ -63,7 +63,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
|
||||
match: { ports: [18444], domains: ['test2.local'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3001 },
|
||||
targets: [{ host: 'localhost', port: 3001 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -37,7 +37,7 @@ tap.test('regular forward route should work correctly', async () => {
|
||||
match: { ports: 7890 },
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
target: { host: 'localhost', port: 6789 }
|
||||
targets: [{ host: 'localhost', port: 6789 }]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -39,10 +39,10 @@ tap.test('forward connections should not be immediately closed', async (t) => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 9090,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@@ -39,7 +39,7 @@ tap.test('Route Helpers - Create HTTP routes', async () => {
|
||||
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
|
||||
expect(route.action.type).toEqual('forward');
|
||||
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 () => {
|
||||
|
@@ -20,7 +20,7 @@ tap.test('should forward non-TLS connections on HttpProxy ports', async (tapTest
|
||||
match: { ports: testPort },
|
||||
action: {
|
||||
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
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8080 }
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
@@ -14,7 +14,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
||||
match: { ports: 8080 },
|
||||
action: {
|
||||
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 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 8443 },
|
||||
targets: [{ host: 'localhost', port: 8443 }],
|
||||
tls: { mode: 'terminate' }
|
||||
}
|
||||
}]
|
||||
|
@@ -17,7 +17,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
match: { ports: 8081 },
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: targetPort }
|
||||
targets: [{ host: 'localhost', port: targetPort }]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -42,7 +42,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
|
||||
},
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: targetPort }
|
||||
targets: [{ host: 'localhost', port: targetPort }]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -67,7 +67,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: targetPort },
|
||||
targets: [{ host: 'localhost', port: targetPort }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto' // Use ACME for certificate
|
||||
@@ -83,7 +83,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
||||
},
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: targetPort }
|
||||
targets: [{ host: 'localhost', port: targetPort }]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@@ -95,10 +95,10 @@ tap.test('should support static host/port routes', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: serverPort
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -135,13 +135,13 @@ tap.test('should support function-based host', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
// Return localhost always in this test
|
||||
return 'localhost';
|
||||
},
|
||||
port: serverPort
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -178,13 +178,13 @@ tap.test('should support function-based port', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: (context: IRouteContext) => {
|
||||
// Return test server port
|
||||
return serverPort;
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -221,14 +221,14 @@ tap.test('should support function-based host AND port', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
return 'localhost';
|
||||
},
|
||||
port: (context: IRouteContext) => {
|
||||
return serverPort;
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -265,7 +265,7 @@ tap.test('should support context-based routing with path', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
// Use path to determine host
|
||||
if (context.path?.startsWith('/api')) {
|
||||
@@ -275,7 +275,7 @@ tap.test('should support context-based routing with path', async () => {
|
||||
}
|
||||
},
|
||||
port: serverPort
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@@ -232,10 +232,10 @@ tap.test('should start the proxy server', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3100
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate'
|
||||
},
|
||||
|
@@ -40,7 +40,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
match: { ports: 8590 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9998 }
|
||||
targets: [{ host: 'localhost', port: 9998 }]
|
||||
}
|
||||
}],
|
||||
keepAlive: true,
|
||||
@@ -117,7 +117,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
match: { ports: 8591 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9998 }
|
||||
targets: [{ host: 'localhost', port: 9998 }]
|
||||
}
|
||||
}],
|
||||
keepAlive: true,
|
||||
@@ -178,7 +178,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
match: { ports: 8592 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9998 }
|
||||
targets: [{ host: 'localhost', port: 9998 }]
|
||||
}
|
||||
}],
|
||||
keepAlive: true,
|
||||
|
@@ -39,10 +39,10 @@ tap.test('setup test environment', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9876
|
||||
}
|
||||
}]
|
||||
// No TLS configuration - just plain TCP forwarding
|
||||
}
|
||||
}],
|
||||
|
@@ -29,7 +29,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
|
||||
match: { ports: 8700 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9995 }
|
||||
targets: [{ host: 'localhost', port: 9995 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -37,7 +37,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
|
||||
match: { ports: 8701 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9995 }
|
||||
targets: [{ host: 'localhost', port: 9995 }]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@@ -36,10 +36,10 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: echoServerPort
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
|
@@ -34,10 +34,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8001,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
// Also add regular forwarding route for comparison
|
||||
@@ -49,10 +49,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8001,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@@ -42,10 +42,10 @@ const sampleRoute: IRouteConfig = {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8000
|
||||
},
|
||||
}],
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: 'tcp',
|
||||
@@ -115,10 +115,10 @@ tap.skip.test('NFTablesManager route updating test', async () => {
|
||||
...sampleRoute,
|
||||
action: {
|
||||
...sampleRoute.action,
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9000 // Different port
|
||||
},
|
||||
}],
|
||||
nftables: {
|
||||
...sampleRoute.action.nftables,
|
||||
protocol: 'all' // Different protocol
|
||||
@@ -147,10 +147,10 @@ tap.skip.test('NFTablesManager route deprovisioning test', async () => {
|
||||
...sampleRoute,
|
||||
action: {
|
||||
...sampleRoute.action,
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9000 // Different port from original test
|
||||
},
|
||||
}],
|
||||
nftables: {
|
||||
...sampleRoute.action.nftables,
|
||||
protocol: 'all' // Different protocol from original test
|
||||
|
@@ -91,7 +91,7 @@ testFn('SmartProxy getNfTablesStatus functionality', async () => {
|
||||
match: { ports: 3004 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3005 }
|
||||
targets: [{ host: 'localhost', port: 3005 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@@ -29,7 +29,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
|
||||
match: { ports: 9999 },
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
tls: { mode: 'passthrough' },
|
||||
target: { host: 'localhost', port: 443 }
|
||||
targets: [{ host: 'localhost', port: 443 }]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -214,12 +214,12 @@ tap.test('should handle errors in port mapping functions', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: () => {
|
||||
throw new Error('Test error in port mapping function');
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: 'Error Route'
|
||||
};
|
||||
|
@@ -21,7 +21,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
||||
},
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 3001 },
|
||||
targets: [{ host: 'localhost', port: 3001 }],
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto' as const
|
||||
@@ -153,7 +153,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
||||
},
|
||||
action: {
|
||||
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: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 3001 },
|
||||
targets: [{ host: 'localhost', port: 3001 }],
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto' as const
|
||||
|
@@ -15,10 +15,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'httpbin.org',
|
||||
port: 443
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -45,10 +45,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8002
|
||||
},
|
||||
}],
|
||||
sendProxyProtocol: true
|
||||
}
|
||||
}
|
||||
|
@@ -32,10 +32,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
match: { ports: 8591 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9998 // Backend that closes immediately
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -50,10 +50,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
match: { ports: 8590 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8591 // Forward to proxy2
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -19,10 +19,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
||||
match: { ports: 8581 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent backend
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -37,10 +37,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
||||
match: { ports: 8580 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8581 // Forward to proxy2
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -270,10 +270,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
||||
match: { ports: 8583 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent backend
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -289,10 +289,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
||||
match: { ports: 8582 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8583 // Forward to proxy2
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -19,10 +19,10 @@ tap.test('should handle rapid connection retries without leaking connections', a
|
||||
match: { ports: 8550 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port to force connection failures
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -17,7 +17,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 },
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
@@ -95,7 +95,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3001 },
|
||||
targets: [{ host: 'localhost', port: 3001 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -56,8 +56,8 @@ tap.test('Routes: Should create basic HTTP route', async () => {
|
||||
expect(httpRoute.match.ports).toEqual(80);
|
||||
expect(httpRoute.match.domains).toEqual('example.com');
|
||||
expect(httpRoute.action.type).toEqual('forward');
|
||||
expect(httpRoute.action.target?.host).toEqual('localhost');
|
||||
expect(httpRoute.action.target?.port).toEqual(3000);
|
||||
expect(httpRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(httpRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
expect(httpRoute.name).toEqual('Basic HTTP Route');
|
||||
});
|
||||
|
||||
@@ -74,8 +74,8 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||
expect(httpsRoute.action.type).toEqual('forward');
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(httpsRoute.action.tls?.certificate).toEqual('auto');
|
||||
expect(httpsRoute.action.target?.host).toEqual('localhost');
|
||||
expect(httpsRoute.action.target?.port).toEqual(8080);
|
||||
expect(httpsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(httpsRoute.action.targets?.[0]?.port).toEqual(8080);
|
||||
expect(httpsRoute.name).toEqual('HTTPS Route');
|
||||
});
|
||||
|
||||
@@ -131,10 +131,10 @@ tap.test('Routes: Should create load balancer route', async () => {
|
||||
// Validate the route configuration
|
||||
expect(lbRoute.match.domains).toEqual('app.example.com');
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
expect(Array.isArray(lbRoute.action.target?.host)).toBeTrue();
|
||||
expect((lbRoute.action.target?.host as string[]).length).toEqual(3);
|
||||
expect((lbRoute.action.target?.host as string[])[0]).toEqual('10.0.0.1');
|
||||
expect(lbRoute.action.target?.port).toEqual(8080);
|
||||
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
|
||||
expect((lbRoute.action.targets?.[0]?.host as string[]).length).toEqual(3);
|
||||
expect((lbRoute.action.targets?.[0]?.host as string[])[0]).toEqual('10.0.0.1');
|
||||
expect(lbRoute.action.targets?.[0]?.port).toEqual(8080);
|
||||
expect(lbRoute.action.tls?.mode).toEqual('terminate');
|
||||
});
|
||||
|
||||
@@ -152,8 +152,8 @@ tap.test('Routes: Should create API route with CORS', async () => {
|
||||
expect(apiRoute.match.path).toEqual('/v1/*');
|
||||
expect(apiRoute.action.type).toEqual('forward');
|
||||
expect(apiRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(apiRoute.action.target?.host).toEqual('localhost');
|
||||
expect(apiRoute.action.target?.port).toEqual(3000);
|
||||
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check CORS headers
|
||||
expect(apiRoute.headers).toBeDefined();
|
||||
@@ -177,8 +177,8 @@ tap.test('Routes: Should create WebSocket route', async () => {
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(wsRoute.action.target?.host).toEqual('localhost');
|
||||
expect(wsRoute.action.target?.port).toEqual(5000);
|
||||
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
// Check WebSocket configuration
|
||||
expect(wsRoute.action.websocket).toBeDefined();
|
||||
@@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
})
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
},
|
||||
}],
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '192.168.0.*'],
|
||||
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 });
|
||||
expect(bestMatch).not.toBeUndefined();
|
||||
if (bestMatch) {
|
||||
expect(bestMatch.action.target.port).toEqual(3001); // Should match the exact domain route
|
||||
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
|
||||
}
|
||||
|
||||
// Test with a different subdomain - should only match the wildcard route
|
||||
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
||||
expect(otherMatches.length).toEqual(1);
|
||||
expect(otherMatches[0].action.target.port).toEqual(3000); // Should match the wildcard domain route
|
||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Disabled Routes', async () => {
|
||||
@@ -316,7 +316,7 @@ tap.test('Edge Case - Disabled Routes', async () => {
|
||||
|
||||
// Should only find the enabled route
|
||||
expect(matches.length).toEqual(1);
|
||||
expect(matches[0].action.target.port).toEqual(3000);
|
||||
expect(matches[0].action.targets[0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
@@ -333,10 +333,10 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'internal-api',
|
||||
port: 8080
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
@@ -376,10 +376,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'backend',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: 'Port Range Route'
|
||||
};
|
||||
@@ -404,10 +404,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'backend',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: 'Multi Range Route'
|
||||
};
|
||||
@@ -452,7 +452,7 @@ tap.test('Wildcard Domain Handling', async () => {
|
||||
expect(bestSpecificMatch).not.toBeUndefined();
|
||||
if (bestSpecificMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestSpecificMatch.action.target.port;
|
||||
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the specific subdomain route (with highest priority)
|
||||
@@ -465,7 +465,7 @@ tap.test('Wildcard Domain Handling', async () => {
|
||||
expect(bestWildcardMatch).not.toBeUndefined();
|
||||
if (bestWildcardMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestWildcardMatch.action.target.port;
|
||||
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the wildcard subdomain route (with medium priority)
|
||||
@@ -513,7 +513,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(webServerMatch).not.toBeUndefined();
|
||||
if (webServerMatch) {
|
||||
expect(webServerMatch.action.type).toEqual('forward');
|
||||
expect(webServerMatch.action.target.host).toEqual('web-server');
|
||||
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
|
||||
}
|
||||
|
||||
// Web server (HTTP redirect via socket handler)
|
||||
@@ -532,7 +532,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(apiMatch).not.toBeUndefined();
|
||||
if (apiMatch) {
|
||||
expect(apiMatch.action.type).toEqual('forward');
|
||||
expect(apiMatch.action.target.host).toEqual('api-server');
|
||||
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
||||
}
|
||||
|
||||
// WebSocket server
|
||||
@@ -544,7 +544,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(wsMatch).not.toBeUndefined();
|
||||
if (wsMatch) {
|
||||
expect(wsMatch.action.type).toEqual('forward');
|
||||
expect(wsMatch.action.target.host).toEqual('websocket-server');
|
||||
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
|
||||
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
||||
}
|
||||
|
||||
|
@@ -28,10 +28,10 @@ tap.test('route security should block connections from unauthorized IPs', async
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 9990
|
||||
}
|
||||
}]
|
||||
},
|
||||
security: {
|
||||
// Only allow a non-existent IP
|
||||
@@ -142,10 +142,10 @@ tap.test('route security with block list should work', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 9992
|
||||
}
|
||||
}]
|
||||
},
|
||||
security: { // Security at route level, not action level
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 9994
|
||||
}
|
||||
}]
|
||||
}
|
||||
// No security defined
|
||||
}];
|
||||
|
@@ -10,10 +10,10 @@ tap.test('route security should be correctly configured', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8991
|
||||
},
|
||||
}],
|
||||
security: {
|
||||
ipAllowList: ['192.168.1.1'],
|
||||
ipBlockList: ['10.0.0.1']
|
||||
|
@@ -26,10 +26,10 @@ tap.test('route-specific security should be enforced', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8877
|
||||
}
|
||||
}]
|
||||
},
|
||||
security: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8879
|
||||
}
|
||||
}]
|
||||
},
|
||||
security: {
|
||||
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: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: 8881
|
||||
}
|
||||
}]
|
||||
// No security section - should allow all
|
||||
}
|
||||
}];
|
||||
|
@@ -13,10 +13,10 @@ const createRoute = (id: number, domain: string, port: number = 8443) => ({
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000 + id
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto' as const,
|
||||
@@ -209,10 +209,10 @@ tap.test('should handle route updates when cert manager is not initialized', asy
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -134,10 +134,10 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
// Valid forward action
|
||||
const validForwardAction: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
};
|
||||
const validForwardResult = validateRouteAction(validForwardAction);
|
||||
expect(validForwardResult.valid).toBeTrue();
|
||||
@@ -154,14 +154,14 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
expect(validSocketResult.valid).toBeTrue();
|
||||
expect(validSocketResult.errors.length).toEqual(0);
|
||||
|
||||
// Invalid action (missing target)
|
||||
// Invalid action (missing targets)
|
||||
const invalidAction: IRouteAction = {
|
||||
type: 'forward'
|
||||
};
|
||||
const invalidResult = validateRouteAction(invalidAction);
|
||||
expect(invalidResult.valid).toBeFalse();
|
||||
expect(invalidResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidResult.errors[0]).toInclude('Target is required');
|
||||
expect(invalidResult.errors[0]).toInclude('Targets array is required');
|
||||
|
||||
// Invalid action (missing socket handler)
|
||||
const invalidSocketAction: IRouteAction = {
|
||||
@@ -180,7 +180,7 @@ tap.test('Route Validation - validateRouteConfig', async () => {
|
||||
expect(validResult.valid).toBeTrue();
|
||||
expect(validResult.errors.length).toEqual(0);
|
||||
|
||||
// Invalid route config (missing target)
|
||||
// Invalid route config (missing targets)
|
||||
const invalidRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -309,16 +309,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
const actionOverride: Partial<IRouteConfig> = {
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'new-host.local',
|
||||
port: 5000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
|
||||
expect(actionMergedRoute.action.target.host).toEqual('new-host.local');
|
||||
expect(actionMergedRoute.action.target.port).toEqual(5000);
|
||||
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
|
||||
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
// Test replacing action with socket handler
|
||||
const typeChangeOverride: Partial<IRouteConfig> = {
|
||||
@@ -336,7 +336,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
|
||||
expect(typeChangedRoute.action.type).toEqual('socket-handler');
|
||||
expect(typeChangedRoute.action.socketHandler).toBeDefined();
|
||||
expect(typeChangedRoute.action.target).toBeUndefined();
|
||||
expect(typeChangedRoute.action.targets).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('Route Matching - routeMatchesDomain', async () => {
|
||||
@@ -379,10 +379,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,10 +393,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,10 +427,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,10 +443,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -458,10 +458,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -494,10 +494,10 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -641,7 +641,7 @@ tap.test('Route Utilities - cloneRoute', async () => {
|
||||
expect(clonedRoute.name).toEqual(originalRoute.name);
|
||||
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
|
||||
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
|
||||
expect(clonedRoute.action.target.port).toEqual(originalRoute.action.target.port);
|
||||
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
|
||||
|
||||
// Modify the clone and check that the original is unchanged
|
||||
clonedRoute.name = 'Modified Clone';
|
||||
@@ -656,8 +656,8 @@ tap.test('Route Helpers - createHttpRoute', async () => {
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(80);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.target.host).toEqual('localhost');
|
||||
expect(route.action.target.port).toEqual(3000);
|
||||
expect(route.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(route.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
@@ -790,11 +790,11 @@ tap.test('Route Helpers - createLoadBalancerRoute', async () => {
|
||||
expect(route.match.domains).toEqual('loadbalancer.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(Array.isArray(route.action.target.host)).toBeTrue();
|
||||
if (Array.isArray(route.action.target.host)) {
|
||||
expect(route.action.target.host.length).toEqual(3);
|
||||
expect(route.action.targets).toBeDefined();
|
||||
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
|
||||
expect((route.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
expect(route.action.target.port).toEqual(8080);
|
||||
expect(route.action.targets?.[0]?.port).toEqual(8080);
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
@@ -819,7 +819,7 @@ tap.test('Route Patterns - createApiGatewayRoute', async () => {
|
||||
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
|
||||
expect(apiGatewayRoute.match.path).toInclude('/v1');
|
||||
expect(apiGatewayRoute.action.type).toEqual('forward');
|
||||
expect(apiGatewayRoute.action.target.port).toEqual(3000);
|
||||
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (apiGatewayRoute.action.tls) {
|
||||
@@ -854,7 +854,7 @@ tap.test('Route Patterns - createWebSocketPattern', async () => {
|
||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.target.port).toEqual(3000);
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (wsRoute.action.tls) {
|
||||
@@ -891,8 +891,8 @@ tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
|
||||
// Check target hosts
|
||||
if (Array.isArray(lbRoute.action.target.host)) {
|
||||
expect(lbRoute.action.target.host.length).toEqual(3);
|
||||
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
|
||||
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
|
||||
// Check TLS configuration
|
||||
|
@@ -37,10 +37,10 @@ function createRouteConfig(
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: destinationIp,
|
||||
port: destinationPort
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -38,15 +38,17 @@ tap.test('Per-IP connection limits validation', async () => {
|
||||
|
||||
// Track connections up to limit
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
securityManager.trackConnectionByIP(testIP, `conn${i}`);
|
||||
// Validate BEFORE tracking the connection (checking if we can add a new connection)
|
||||
const result = securityManager.validateIP(testIP);
|
||||
expect(result.allowed).toBeTrue();
|
||||
// Now track the connection
|
||||
securityManager.trackConnectionByIP(testIP, `conn${i}`);
|
||||
}
|
||||
|
||||
// Verify we're at the limit
|
||||
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
|
||||
|
||||
// Next connection should be rejected
|
||||
// Next connection should be rejected (we're already at 5)
|
||||
const result = securityManager.validateIP(testIP);
|
||||
expect(result.allowed).toBeFalse();
|
||||
expect(result.reason).toInclude('Maximum connections per IP');
|
||||
@@ -61,28 +63,23 @@ tap.test('Connection rate limiting', async () => {
|
||||
const testIP = '192.168.1.102';
|
||||
|
||||
// Make connections at the rate limit
|
||||
// Note: validateIP() already tracks timestamps internally for rate limiting
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = securityManager.validateIP(testIP);
|
||||
expect(result.allowed).toBeTrue();
|
||||
securityManager.trackConnectionByIP(testIP, `conn${i}`);
|
||||
}
|
||||
|
||||
// Next connection should exceed rate limit
|
||||
const result = securityManager.validateIP(testIP);
|
||||
expect(result.allowed).toBeFalse();
|
||||
expect(result.reason).toInclude('Connection rate limit');
|
||||
|
||||
// Clean up connections
|
||||
for (let i = 0; i < 10; i++) {
|
||||
securityManager.removeConnectionByIP(testIP, `conn${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Route-level connection limits', async () => {
|
||||
const route: IRouteConfig = {
|
||||
name: 'test-route',
|
||||
match: { ports: 443 },
|
||||
action: { type: 'forward', target: { host: 'localhost', port: 8080 } },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
||||
security: {
|
||||
maxConnections: 3
|
||||
}
|
||||
@@ -93,7 +90,8 @@ tap.test('Route-level connection limits', async () => {
|
||||
clientIp: '192.168.1.103',
|
||||
serverIp: '0.0.0.0',
|
||||
timestamp: Date.now(),
|
||||
connectionId: 'test-conn'
|
||||
connectionId: 'test-conn',
|
||||
isTls: true
|
||||
};
|
||||
|
||||
// Test with connection counts below limit
|
||||
|
@@ -15,10 +15,10 @@ tap.test('should create a SmartCertManager instance', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
|
@@ -73,10 +73,10 @@ tap.test('setup port proxy test environment', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: TEST_SERVER_PORT
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -112,10 +112,10 @@ tap.test('should forward TCP connections to custom host', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: TEST_SERVER_PORT
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -157,10 +157,10 @@ tap.test('should forward connections to custom IP', async () => {
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: '127.0.0.1',
|
||||
port: targetServerPort
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -252,10 +252,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: PROXY_PORT + 5
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -273,10 +273,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: TEST_SERVER_PORT
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -311,10 +311,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: PROXY_PORT + 7
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -334,10 +334,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: TEST_SERVER_PORT
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -377,10 +377,10 @@ tap.test('should use round robin for multiple target hosts in domain config', as
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: {
|
||||
targets: [{
|
||||
host: ['hostA', 'hostB'], // Array of hosts for round-robin
|
||||
port: 80
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -400,9 +400,9 @@ tap.test('should use round robin for multiple target hosts in domain config', as
|
||||
|
||||
// For route-based approach, the actual round-robin logic happens in connection handling
|
||||
// Just make sure our config has the expected hosts
|
||||
expect(Array.isArray(routeConfig.action.target.host)).toBeTrue();
|
||||
expect(routeConfig.action.target.host).toContain('hostA');
|
||||
expect(routeConfig.action.target.host).toContain('hostB');
|
||||
expect(Array.isArray(routeConfig.action.targets![0].host)).toBeTrue();
|
||||
expect(routeConfig.action.targets![0].host).toContain('hostA');
|
||||
expect(routeConfig.action.targets![0].host).toContain('hostB');
|
||||
});
|
||||
|
||||
// CLEANUP: Tear down all servers and proxies
|
||||
|
@@ -30,7 +30,7 @@ tap.test('stuck connection cleanup - verify connections to hanging backends are
|
||||
match: { ports: 8589 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9997 }
|
||||
targets: [{ host: 'localhost', port: 9997 }]
|
||||
}
|
||||
}],
|
||||
keepAlive: true,
|
||||
|
@@ -17,7 +17,7 @@ tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
|
||||
match: { ports: 8443, domains: 'test.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9443 },
|
||||
targets: [{ host: 'localhost', port: 9443 }],
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ tap.test('long-lived connection survival test', async (tools) => {
|
||||
match: { ports: 8444 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 9444 }
|
||||
targets: [{ host: 'localhost', port: 9444 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@@ -52,10 +52,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
||||
match: { ports: 8591 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 9998
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
@@ -71,10 +71,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
||||
match: { ports: 8590 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: 8591
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@@ -13,7 +13,8 @@ import {
|
||||
trackConnection,
|
||||
removeConnection,
|
||||
cleanupExpiredRateLimits,
|
||||
parseBasicAuthHeader
|
||||
parseBasicAuthHeader,
|
||||
normalizeIP
|
||||
} from './security-utils.js';
|
||||
|
||||
/**
|
||||
@@ -78,7 +79,15 @@ export class SharedSecurityManager {
|
||||
* @returns Number of connections from this IP
|
||||
*/
|
||||
public getConnectionCountByIP(ip: string): number {
|
||||
return this.connectionsByIP.get(ip)?.connections.size || 0;
|
||||
// Check all normalized variants of the IP
|
||||
const variants = normalizeIP(ip);
|
||||
for (const variant of variants) {
|
||||
const info = this.connectionsByIP.get(variant);
|
||||
if (info) {
|
||||
return info.connections.size;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +97,19 @@ export class SharedSecurityManager {
|
||||
* @param connectionId - The connection ID to associate
|
||||
*/
|
||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||
trackConnection(ip, connectionId, this.connectionsByIP);
|
||||
// Check if any variant already exists
|
||||
const variants = normalizeIP(ip);
|
||||
let existingKey: string | null = null;
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
existingKey = variant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing key or the original IP
|
||||
trackConnection(existingKey || ip, connectionId, this.connectionsByIP);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +119,15 @@ export class SharedSecurityManager {
|
||||
* @param connectionId - The connection ID to remove
|
||||
*/
|
||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||
removeConnection(ip, connectionId, this.connectionsByIP);
|
||||
// Check all variants to find where the connection is tracked
|
||||
const variants = normalizeIP(ip);
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
removeConnection(variant, connectionId, this.connectionsByIP);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
import { HttpRequestHandler } from './http-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 { toBaseContext } from '../../core/models/route-context.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
@@ -99,6 +99,80 @@ export class RequestHandler {
|
||||
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
|
||||
* 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 (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) {
|
||||
// If we found a matching route with forward action, select appropriate target
|
||||
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
|
||||
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
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// 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)
|
||||
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
@@ -502,7 +590,7 @@ export class RequestHandler {
|
||||
this.logger.debug(`Using cached host value for ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext));
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
|
||||
// Cache the result
|
||||
@@ -511,16 +599,16 @@ export class RequestHandler {
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedHost = matchingRoute.action.target.host(routeContext);
|
||||
const resolvedHost = selectedTarget.host(routeContext);
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
targetHost = matchingRoute.action.target.host;
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// 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
|
||||
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
@@ -532,7 +620,7 @@ export class RequestHandler {
|
||||
this.logger.debug(`Using cached port value for ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext));
|
||||
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
targetPort = resolvedPort;
|
||||
|
||||
// Cache the result
|
||||
@@ -541,12 +629,12 @@ export class RequestHandler {
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedPort = matchingRoute.action.target.port(routeContext);
|
||||
const resolvedPort = selectedTarget.port(routeContext);
|
||||
targetPort = resolvedPort;
|
||||
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} 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
|
||||
@@ -626,17 +714,32 @@ export class RequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching route with function-based targets, use it
|
||||
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) {
|
||||
// If we found a matching route with forward action, select appropriate 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'}`);
|
||||
|
||||
// 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
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// 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)
|
||||
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}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext));
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
|
||||
// Cache the result
|
||||
@@ -657,16 +760,16 @@ export class RequestHandler {
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedHost = matchingRoute.action.target.host(routeContext);
|
||||
const resolvedHost = selectedTarget.host(routeContext);
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
targetHost = matchingRoute.action.target.host;
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// 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
|
||||
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}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext));
|
||||
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
targetPort = resolvedPort;
|
||||
|
||||
// Cache the result
|
||||
@@ -687,12 +790,12 @@ export class RequestHandler {
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedPort = matchingRoute.action.target.port(routeContext);
|
||||
const resolvedPort = selectedTarget.port(routeContext);
|
||||
targetPort = resolvedPort;
|
||||
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} 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
|
||||
|
@@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js';
|
||||
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
|
||||
import { ConnectionPool } from './connection-pool.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 { toBaseContext } from '../../core/models/route-context.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
@@ -53,6 +53,80 @@ export class WebSocketHandler {
|
||||
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
|
||||
*/
|
||||
@@ -146,9 +220,23 @@ export class WebSocketHandler {
|
||||
let destination: { host: string; port: number };
|
||||
|
||||
// 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'}`);
|
||||
|
||||
// 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
|
||||
if (route.action.websocket?.enabled === false) {
|
||||
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
|
||||
@@ -192,20 +280,20 @@ export class WebSocketHandler {
|
||||
|
||||
try {
|
||||
// Resolve host if it's a function
|
||||
if (typeof route.action.target.host === 'function') {
|
||||
const resolvedHost = route.action.target.host(toBaseContext(routeContext));
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
} else {
|
||||
targetHost = route.action.target.host;
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// Resolve port if it's a function
|
||||
if (typeof route.action.target.port === 'function') {
|
||||
targetPort = route.action.target.port(toBaseContext(routeContext));
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
targetPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
|
||||
} 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
|
||||
|
@@ -12,7 +12,7 @@ export interface ICertStatus {
|
||||
status: 'valid' | 'pending' | 'expired' | 'error';
|
||||
expiryDate?: Date;
|
||||
issueDate?: Date;
|
||||
source: 'static' | 'acme';
|
||||
source: 'static' | 'acme' | 'custom';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ICertificateData {
|
||||
ca?: string;
|
||||
expiryDate: Date;
|
||||
issueDate: Date;
|
||||
source?: 'static' | 'acme' | 'custom';
|
||||
}
|
||||
|
||||
export class SmartCertManager {
|
||||
@@ -50,6 +51,12 @@ export class SmartCertManager {
|
||||
// ACME state manager reference
|
||||
private acmeStateManager: AcmeStateManager | null = null;
|
||||
|
||||
// Custom certificate provision function
|
||||
private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
|
||||
|
||||
// Whether to fallback to ACME if custom provision fails
|
||||
private certProvisionFallbackToAcme: boolean = true;
|
||||
|
||||
constructor(
|
||||
private routes: IRouteConfig[],
|
||||
private certDir: string = './certs',
|
||||
@@ -89,6 +96,20 @@ export class SmartCertManager {
|
||||
this.globalAcmeDefaults = defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom certificate provision function
|
||||
*/
|
||||
public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): void {
|
||||
this.certProvisionFunction = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to fallback to ACME if custom provision fails
|
||||
*/
|
||||
public setCertProvisionFallbackToAcme(fallback: boolean): void {
|
||||
this.certProvisionFallbackToAcme = fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for updating routes (used for challenge routes)
|
||||
*/
|
||||
@@ -212,15 +233,6 @@ export class SmartCertManager {
|
||||
route: IRouteConfig,
|
||||
domains: string[]
|
||||
): Promise<void> {
|
||||
if (!this.smartAcme) {
|
||||
throw new Error(
|
||||
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
||||
'Please ensure you have configured ACME with an email address either:\n' +
|
||||
'1. In the top-level "acme" configuration\n' +
|
||||
'2. In the route\'s "tls.acme" configuration'
|
||||
);
|
||||
}
|
||||
|
||||
const primaryDomain = domains[0];
|
||||
const routeName = route.name || primaryDomain;
|
||||
|
||||
@@ -229,10 +241,68 @@ export class SmartCertManager {
|
||||
if (existingCert && this.isCertificateValid(existingCert)) {
|
||||
logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
await this.applyCertificate(primaryDomain, existingCert);
|
||||
this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
|
||||
this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for custom provision function first
|
||||
if (this.certProvisionFunction) {
|
||||
try {
|
||||
logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
const result = await this.certProvisionFunction(primaryDomain);
|
||||
|
||||
if (result === 'http01') {
|
||||
logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
// 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.publicKey,
|
||||
key: customCert.privateKey,
|
||||
ca: '',
|
||||
issueDate: new Date(),
|
||||
expiryDate: this.extractExpiryDate(customCert.publicKey),
|
||||
source: 'custom'
|
||||
};
|
||||
|
||||
// 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,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
|
||||
domain: primaryDomain,
|
||||
error: error.message,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
// Check if we should fallback to ACME
|
||||
if (!this.certProvisionFallbackToAcme) {
|
||||
throw error;
|
||||
}
|
||||
logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.smartAcme) {
|
||||
throw new Error(
|
||||
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
||||
'Please ensure you have configured ACME with an email address either:\n' +
|
||||
'1. In the top-level "acme" configuration\n' +
|
||||
'2. In the route\'s "tls.acme" configuration'
|
||||
);
|
||||
}
|
||||
|
||||
// Apply renewal threshold from global defaults or route config
|
||||
const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
|
||||
this.globalAcmeDefaults?.renewThresholdDays ||
|
||||
@@ -280,7 +350,8 @@ export class SmartCertManager {
|
||||
key: cert.privateKey,
|
||||
ca: cert.publicKey, // Use same as cert for now
|
||||
expiryDate: new Date(cert.validUntil),
|
||||
issueDate: new Date(cert.created)
|
||||
issueDate: new Date(cert.created),
|
||||
source: 'acme'
|
||||
};
|
||||
|
||||
await this.certStore.saveCertificate(routeName, certData);
|
||||
@@ -328,7 +399,8 @@ export class SmartCertManager {
|
||||
cert,
|
||||
key,
|
||||
expiryDate: certInfo.validTo,
|
||||
issueDate: certInfo.validFrom
|
||||
issueDate: certInfo.validFrom,
|
||||
source: 'static'
|
||||
};
|
||||
|
||||
// Save to store for consistency
|
||||
@@ -399,6 +471,19 @@ export class SmartCertManager {
|
||||
return cert.expiryDate > expiryThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract expiry date from a PEM certificate
|
||||
*/
|
||||
private extractExpiryDate(_certPem: string): Date {
|
||||
// For now, we'll default to 90 days for custom certificates
|
||||
// In production, you might want to use a proper X.509 parser
|
||||
// or require the custom cert provider to include expiry info
|
||||
logger.log('info', 'Using default 90-day expiry for custom certificate', {
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add challenge route to SmartProxy
|
||||
|
@@ -135,6 +135,12 @@ export interface ISmartProxyOptions {
|
||||
* or a static certificate object for immediate provisioning.
|
||||
*/
|
||||
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
|
||||
|
||||
/**
|
||||
* Whether to fallback to ACME if custom certificate provision fails.
|
||||
* Default: true
|
||||
*/
|
||||
certProvisionFallbackToAcme?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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 {
|
||||
// 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
|
||||
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
|
||||
type: TRouteActionType;
|
||||
|
||||
// Target for forwarding
|
||||
target?: IRouteTarget;
|
||||
// Targets for forwarding (array supports multiple targets with sub-matching)
|
||||
// Required for 'forward' action type
|
||||
targets?: IRouteTarget[];
|
||||
|
||||
// TLS handling
|
||||
// TLS handling (default for all targets, can be overridden per target)
|
||||
tls?: IRouteTls;
|
||||
|
||||
// WebSocket support
|
||||
// WebSocket support (default for all targets, can be overridden per target)
|
||||
websocket?: IRouteWebSocket;
|
||||
|
||||
// Load balancing options
|
||||
// Load balancing options (default for all targets, can be overridden per target)
|
||||
loadBalancing?: IRouteLoadBalancing;
|
||||
|
||||
// Advanced options
|
||||
// Advanced options (default for all targets, can be overridden per target)
|
||||
advanced?: IRouteAdvanced;
|
||||
|
||||
// Additional options for backend-specific settings
|
||||
@@ -251,7 +277,7 @@ export interface IRouteAction {
|
||||
// Socket handler function (when type is 'socket-handler')
|
||||
socketHandler?: TSocketHandler;
|
||||
|
||||
// PROXY protocol support
|
||||
// PROXY protocol support (default for all targets, can be overridden per target)
|
||||
sendProxyProtocol?: boolean;
|
||||
}
|
||||
|
||||
|
@@ -123,39 +123,43 @@ export class NFTablesManager {
|
||||
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
|
||||
const { action } = route;
|
||||
|
||||
// Ensure we have a target
|
||||
if (!action.target) {
|
||||
throw new Error('Route must have a target to use NFTables forwarding');
|
||||
// Ensure we have targets
|
||||
if (!action.targets || action.targets.length === 0) {
|
||||
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
|
||||
const fromPorts = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Determine target port
|
||||
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
|
||||
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
|
||||
// Use the "preserve" approach and let NFTables handle it
|
||||
toPorts = fromPorts;
|
||||
} else {
|
||||
toPorts = action.target.port;
|
||||
toPorts = defaultTarget.port;
|
||||
}
|
||||
|
||||
// Determine target host
|
||||
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
|
||||
// and rely on run-time handling
|
||||
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
|
||||
toHost = action.target.host[0];
|
||||
toHost = defaultTarget.host[0];
|
||||
} else {
|
||||
toHost = action.target.host;
|
||||
toHost = defaultTarget.host;
|
||||
}
|
||||
|
||||
// Create options
|
||||
|
@@ -3,7 +3,7 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
||||
// 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 { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.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
|
||||
*/
|
||||
@@ -731,14 +805,37 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// We should have a target configuration for forwarding
|
||||
if (!action.target) {
|
||||
logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, {
|
||||
// Select the appropriate target from the targets array
|
||||
if (!action.targets || action.targets.length === 0) {
|
||||
logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, {
|
||||
connectionId,
|
||||
component: 'route-handler'
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -759,9 +856,9 @@ export class RouteConnectionHandler {
|
||||
|
||||
// Determine host using function or static value
|
||||
let targetHost: string | string[];
|
||||
if (typeof action.target.host === 'function') {
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
try {
|
||||
targetHost = action.target.host(routeContext);
|
||||
targetHost = selectedTarget.host(routeContext);
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, {
|
||||
connectionId,
|
||||
@@ -780,7 +877,7 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
targetHost = action.target.host;
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// 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
|
||||
let targetPort: number;
|
||||
if (typeof action.target.port === 'function') {
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
try {
|
||||
targetPort = action.target.port(routeContext);
|
||||
targetPort = selectedTarget.port(routeContext);
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, {
|
||||
connectionId,
|
||||
@@ -813,20 +910,27 @@ export class RouteConnectionHandler {
|
||||
this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error');
|
||||
return;
|
||||
}
|
||||
} else if (action.target.port === 'preserve') {
|
||||
} else if (selectedTarget.port === 'preserve') {
|
||||
// Use incoming port if port is 'preserve'
|
||||
targetPort = record.localPort;
|
||||
} else {
|
||||
// Use static port from configuration
|
||||
targetPort = action.target.port;
|
||||
targetPort = selectedTarget.port;
|
||||
}
|
||||
|
||||
// Store the resolved host in the context
|
||||
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
|
||||
if (action.tls) {
|
||||
switch (action.tls.mode) {
|
||||
if (effectiveTls) {
|
||||
switch (effectiveTls.mode) {
|
||||
case 'passthrough':
|
||||
// For TLS passthrough, just forward directly
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
@@ -853,9 +957,9 @@ export class RouteConnectionHandler {
|
||||
// For TLS termination, use HttpProxy
|
||||
if (this.smartProxy.httpProxyBridge.getHttpProxy()) {
|
||||
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,
|
||||
targetHost: action.target.host,
|
||||
targetHost: selectedTarget.host,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
@@ -929,10 +1033,10 @@ export class RouteConnectionHandler {
|
||||
} else {
|
||||
// Basic forwarding
|
||||
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,
|
||||
targetHost: action.target.host,
|
||||
targetPort: action.target.port,
|
||||
targetHost: selectedTarget.host,
|
||||
targetPort: selectedTarget.port,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
@@ -940,27 +1044,27 @@ export class RouteConnectionHandler {
|
||||
// Get the appropriate host value
|
||||
let targetHost: string;
|
||||
|
||||
if (typeof action.target.host === 'function') {
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
// 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)
|
||||
? hostResult[Math.floor(Math.random() * hostResult.length)]
|
||||
: hostResult;
|
||||
} else {
|
||||
// For static host value
|
||||
targetHost = Array.isArray(action.target.host)
|
||||
? action.target.host[Math.floor(Math.random() * action.target.host.length)]
|
||||
: action.target.host;
|
||||
targetHost = Array.isArray(selectedTarget.host)
|
||||
? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)]
|
||||
: selectedTarget.host;
|
||||
}
|
||||
|
||||
// Determine port - either function-based, static, or preserve incoming port
|
||||
let targetPort: number;
|
||||
if (typeof action.target.port === 'function') {
|
||||
targetPort = action.target.port(routeContext);
|
||||
} else if (action.target.port === 'preserve') {
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
targetPort = selectedTarget.port(routeContext);
|
||||
} else if (selectedTarget.port === 'preserve') {
|
||||
targetPort = record.localPort;
|
||||
} else {
|
||||
targetPort = action.target.port;
|
||||
targetPort = selectedTarget.port;
|
||||
}
|
||||
|
||||
// Update the connection record and context with resolved values
|
||||
|
@@ -243,6 +243,16 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
certManager.setGlobalAcmeDefaults(this.settings.acme);
|
||||
}
|
||||
|
||||
// Pass down the custom certificate provision function if available
|
||||
if (this.settings.certProvisionFunction) {
|
||||
certManager.setCertProvisionFunction(this.settings.certProvisionFunction);
|
||||
}
|
||||
|
||||
// Pass down the fallback to ACME setting
|
||||
if (this.settings.certProvisionFallbackToAcme !== undefined) {
|
||||
certManager.setCertProvisionFallbackToAcme(this.settings.certProvisionFallbackToAcme);
|
||||
}
|
||||
|
||||
await certManager.initialize();
|
||||
return certManager;
|
||||
}
|
||||
|
@@ -42,7 +42,7 @@ export function createHttpRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
@@ -82,7 +82,7 @@ export function createHttpsTerminateRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target,
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
@@ -152,7 +152,7 @@ export function createHttpsPassthroughRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target,
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
@@ -243,7 +243,7 @@ export function createLoadBalancerRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if provided
|
||||
@@ -303,7 +303,7 @@ export function createApiRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if using HTTPS
|
||||
@@ -374,7 +374,7 @@ export function createWebSocketRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target,
|
||||
targets: [target],
|
||||
websocket: {
|
||||
enabled: true,
|
||||
pingInterval: options.pingInterval || 30000, // 30 seconds
|
||||
@@ -432,10 +432,10 @@ export function createPortMappingRoute(options: {
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
@@ -500,10 +500,10 @@ export function createDynamicRoute(options: {
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
@@ -548,10 +548,10 @@ export function createSmartLoadBalancer(options: {
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: hostSelector,
|
||||
port: options.portMapper
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
@@ -609,10 +609,10 @@ export function createNfTablesRoute(
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
},
|
||||
}],
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: options.protocol || 'tcp',
|
||||
|
@@ -24,10 +24,10 @@ export function createHttpRoute(
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: options.name || `HTTP: ${Array.isArray(domains) ? domains.join(', ') : domains}`
|
||||
};
|
||||
@@ -53,10 +53,10 @@ export function createHttpsTerminateRoute(
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
@@ -83,10 +83,10 @@ export function createHttpsPassthroughRoute(
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
},
|
||||
}],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
|
@@ -66,12 +66,9 @@ export function mergeRouteConfigs(
|
||||
// Otherwise merge the action properties
|
||||
mergedRoute.action = { ...mergedRoute.action };
|
||||
|
||||
// Merge target
|
||||
if (overrideRoute.action.target) {
|
||||
mergedRoute.action.target = {
|
||||
...mergedRoute.action.target,
|
||||
...overrideRoute.action.target
|
||||
};
|
||||
// Merge targets
|
||||
if (overrideRoute.action.targets) {
|
||||
mergedRoute.action.targets = overrideRoute.action.targets;
|
||||
}
|
||||
|
||||
// Merge TLS options
|
||||
|
@@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
|
||||
errors.push(`Invalid action type: ${action.type}`);
|
||||
}
|
||||
|
||||
// Validate target for 'forward' action
|
||||
// Validate targets for 'forward' action
|
||||
if (action.type === 'forward') {
|
||||
if (!action.target) {
|
||||
errors.push('Target is required for forward action');
|
||||
if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
|
||||
errors.push('Targets array is required for forward action');
|
||||
} else {
|
||||
// Validate target host
|
||||
if (!action.target.host) {
|
||||
errors.push('Target host is required');
|
||||
} else if (typeof action.target.host !== 'string' &&
|
||||
!Array.isArray(action.target.host) &&
|
||||
typeof action.target.host !== 'function') {
|
||||
errors.push('Target host must be a string, array of strings, or function');
|
||||
}
|
||||
// Validate each target
|
||||
action.targets.forEach((target, index) => {
|
||||
// Validate target host
|
||||
if (!target.host) {
|
||||
errors.push(`Target[${index}] host is required`);
|
||||
} else if (typeof target.host !== 'string' &&
|
||||
!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
|
||||
if (action.target.port === undefined) {
|
||||
errors.push('Target port is required');
|
||||
} else if (typeof action.target.port !== 'number' &&
|
||||
typeof action.target.port !== 'function') {
|
||||
errors.push('Target port must be a number or a function');
|
||||
} else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) {
|
||||
errors.push('Target port must be between 1 and 65535');
|
||||
}
|
||||
// Validate target port
|
||||
if (target.port === undefined) {
|
||||
errors.push(`Target[${index}] port is required`);
|
||||
} else if (typeof target.port !== 'number' &&
|
||||
typeof target.port !== 'function' &&
|
||||
target.port !== 'preserve') {
|
||||
errors.push(`Target[${index}] port must be a number, 'preserve', or a function`);
|
||||
} 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
|
||||
@@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
|
||||
|
||||
switch (actionType) {
|
||||
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':
|
||||
return !!route.action.socketHandler && typeof route.action.socketHandler === 'function';
|
||||
default:
|
||||
|
Reference in New Issue
Block a user