This commit is contained in:
Juergen Kunz
2025-07-17 15:13:09 +00:00
parent a625675922
commit 82df9a6f52
9 changed files with 581 additions and 370 deletions

View File

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

View File

@@ -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
@@ -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'
};

View File

@@ -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

View File

@@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js';
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
import { 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

View File

@@ -46,11 +46,36 @@ export interface IRouteMatch {
/**
* Target configuration for forwarding
* Target-specific match criteria for sub-routing within a route
*/
export interface ITargetMatch {
ports?: number[]; // Match specific ports from the route
path?: string; // Match specific paths (supports wildcards like /api/*)
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
method?: string[]; // Match specific HTTP methods (GET, POST, etc.)
}
/**
* Target configuration for forwarding with sub-matching and overrides
*/
export interface IRouteTarget {
// 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,19 @@ export interface IRouteAction {
// Basic routing
type: TRouteActionType;
// Target for forwarding
target?: IRouteTarget;
// Targets for forwarding (array supports multiple targets with sub-matching)
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 +276,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;
}

View File

@@ -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

View File

@@ -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 || effectiveTls;
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

View File

@@ -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

View File

@@ -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: