Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b23a8e7e | |||
| c4b9d7eb72 | |||
| be3ac75422 | |||
| ad44274075 | |||
| 3efd9c72ba | |||
| b96e0cd48e | |||
| c909d3db3e |
Binary file not shown.
@@ -1,68 +0,0 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed)on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "smartproxy"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-11-12T14:20:10.043Z",
|
||||
"issueDate": "2025-08-14T14:20:10.043Z",
|
||||
"savedAt": "2025-08-14T14:20:10.044Z"
|
||||
"expiryDate": "2026-03-09T00:26:32.907Z",
|
||||
"issueDate": "2025-12-09T00:26:32.907Z",
|
||||
"savedAt": "2025-12-09T00:26:32.907Z"
|
||||
}
|
||||
27
changelog.md
27
changelog.md
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-09 - 22.0.0 - BREAKING CHANGE(smart-proxy/utils/route-validator)
|
||||
Consolidate and refactor route validators; move to class-based API and update usages
|
||||
|
||||
Replaced legacy route-validators.ts with a unified route-validator.ts that provides a class-based RouteValidator plus the previous functional API (isValidPort, isValidDomain, validateRouteMatch, validateRouteAction, validateRouteConfig, validateRoutes, hasRequiredPropertiesForAction, assertValidRoute) for backwards compatibility. Updated utils exports and all imports/tests to reference the new module. Also switched static file loading in certificate manager to use SmartFileFactory.nodeFs(), and added @push.rocks/smartserve to devDependencies.
|
||||
|
||||
- Rename and consolidate validator module: route-validators.ts removed; route-validator.ts added with RouteValidator class and duplicated functional API for compatibility.
|
||||
- Updated exports in ts/proxies/smart-proxy/utils/index.ts and all internal imports/tests to reference './route-validator.js' instead of './route-validators.js'.
|
||||
- Certificate manager now uses plugins.smartfile.SmartFileFactory.nodeFs() to load key/cert files (safer factory usage instead of direct static calls).
|
||||
- Added @push.rocks/smartserve to devDependencies in package.json.
|
||||
- Because the validator filename and some import paths changed, this is a breaking change for consumers importing the old module path.
|
||||
|
||||
## 2025-08-19 - 21.1.7 - fix(route-validator)
|
||||
Relax domain validation to accept 'localhost', prefix wildcards (e.g. *example.com) and IP literals; add comprehensive domain validation tests
|
||||
|
||||
- Allow 'localhost' as a valid domain pattern in route validation
|
||||
- Support prefix wildcard patterns like '*example.com' in addition to '*.example.com'
|
||||
- Accept IPv4 and IPv6 literal addresses in domain validation
|
||||
- Add test coverage: new test/test.domain-validation.ts with many real-world and edge-case patterns
|
||||
|
||||
## 2025-08-19 - 21.1.6 - fix(ip-utils)
|
||||
Fix IP wildcard/shorthand handling and add validation test
|
||||
|
||||
- Support shorthand IPv4 wildcard patterns (e.g. '10.*', '192.168.*') by expanding them to full 4-octet patterns before matching
|
||||
- Normalize and expand patterns in IpUtils.isGlobIPMatch and SharedSecurityManager IP checks to ensure consistent minimatch comparisons
|
||||
- Relax route validator wildcard checks to accept 1-4 octet wildcard specifications for IPv4 patterns
|
||||
- Add test harness test-ip-validation.ts to exercise common wildcard/shorthand IP patterns
|
||||
|
||||
## 2025-08-19 - 21.1.5 - fix(core)
|
||||
Prepare patch release: documentation, tests and stability fixes (metrics, ACME, connection cleanup)
|
||||
|
||||
|
||||
35
package.json
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "21.1.5",
|
||||
"version": "22.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",
|
||||
@@ -15,11 +15,12 @@
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.8.3",
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@push.rocks/smartserve": "^1.4.0",
|
||||
"@types/node": "^24.10.2",
|
||||
"typescript": "^5.9.3",
|
||||
"why-is-node-running": "^3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -27,20 +28,20 @@
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartfile": "^11.2.5",
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smartnetwork": "^4.0.2",
|
||||
"@push.rocks/smartfile": "^13.1.0",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^2.1.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"minimatch": "^10.0.1",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"ws": "^8.18.2"
|
||||
"minimatch": "^10.1.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
7165
pnpm-lock.yaml
generated
7165
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ tap.test('should handle clients that connect and immediately disconnect without
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8560],
|
||||
enableDetailedLogging: false,
|
||||
initialDataTimeout: 5000, // 5 second timeout for initial data
|
||||
routes: [{
|
||||
@@ -166,7 +165,6 @@ tap.test('should handle clients that error during connection', async () => {
|
||||
console.log('\n=== Testing Connection Error Cleanup ===');
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8561],
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
|
||||
@@ -10,7 +10,6 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8570, 8571], // One for immediate routing, one for TLS
|
||||
enableDetailedLogging: false,
|
||||
initialDataTimeout: 2000,
|
||||
socketTimeout: 5000,
|
||||
@@ -207,7 +206,6 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
// Test 5: NFTables route (should cleanup properly)
|
||||
console.log('\n--- Test 5: NFTables route cleanup ---');
|
||||
const nftProxy = new SmartProxy({
|
||||
ports: [8572],
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'nftables-route',
|
||||
|
||||
@@ -120,7 +120,7 @@ tap.test('Per-IP connection limits', async () => {
|
||||
// Try to create one more connection - should fail
|
||||
try {
|
||||
await createConcurrentConnections(PROXY_PORT, 1);
|
||||
expect.fail('Should not allow more than 3 connections per IP');
|
||||
throw new Error('Should not allow more than 3 connections per IP');
|
||||
} catch (err) {
|
||||
expect(err.message).toInclude('ECONNRESET');
|
||||
}
|
||||
@@ -144,7 +144,7 @@ tap.test('Route-level connection limits', async () => {
|
||||
// Try to exceed route limit
|
||||
try {
|
||||
await createConcurrentConnections(PROXY_PORT, 1);
|
||||
expect.fail('Should not allow more than 5 connections for this route');
|
||||
throw new Error('Should not allow more than 5 connections for this route');
|
||||
} catch (err) {
|
||||
expect(err.message).toInclude('ECONNRESET');
|
||||
}
|
||||
@@ -221,7 +221,7 @@ tap.test('HttpProxy per-IP validation', async () => {
|
||||
// Should reject additional connections
|
||||
try {
|
||||
await createConcurrentConnections(PROXY_PORT + 10, 1);
|
||||
expect.fail('HttpProxy should enforce per-IP limits');
|
||||
throw new Error('HttpProxy should enforce per-IP limits');
|
||||
} catch (err) {
|
||||
expect(err.message).toInclude('ECONNRESET');
|
||||
}
|
||||
|
||||
189
test/test.domain-validation.ts
Normal file
189
test/test.domain-validation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RouteValidator } from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
tap.test('Domain Validation - Standard wildcard patterns', async () => {
|
||||
const testPatterns = [
|
||||
{ pattern: '*.example.com', shouldPass: true, description: 'Standard wildcard subdomain' },
|
||||
{ pattern: '*.sub.example.com', shouldPass: true, description: 'Nested wildcard subdomain' },
|
||||
{ pattern: 'example.com', shouldPass: true, description: 'Plain domain' },
|
||||
{ pattern: 'sub.example.com', shouldPass: true, description: 'Subdomain' },
|
||||
{ pattern: '*', shouldPass: true, description: 'Catch-all wildcard' },
|
||||
{ pattern: 'localhost', shouldPass: true, description: 'Localhost' },
|
||||
{ pattern: '192.168.1.1', shouldPass: true, description: 'IPv4 address' },
|
||||
];
|
||||
|
||||
for (const { pattern, shouldPass, description } of testPatterns) {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: pattern
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
|
||||
if (shouldPass) {
|
||||
expect(result.valid).toEqual(true);
|
||||
console.log(`✅ Domain '${pattern}' correctly accepted (${description})`);
|
||||
} else {
|
||||
expect(result.valid).toEqual(false);
|
||||
console.log(`✅ Domain '${pattern}' correctly rejected (${description})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Domain Validation - Prefix wildcard patterns (*domain)', async () => {
|
||||
const testPatterns = [
|
||||
{ pattern: '*nevermind.cloud', shouldPass: true, description: 'Prefix wildcard without dot' },
|
||||
{ pattern: '*example.com', shouldPass: true, description: 'Prefix wildcard for TLD' },
|
||||
{ pattern: '*sub.example.com', shouldPass: true, description: 'Prefix wildcard for subdomain' },
|
||||
{ pattern: '*api.service.io', shouldPass: true, description: 'Prefix wildcard for nested domain' },
|
||||
];
|
||||
|
||||
for (const { pattern, shouldPass, description } of testPatterns) {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: pattern
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
|
||||
if (shouldPass) {
|
||||
expect(result.valid).toEqual(true);
|
||||
console.log(`✅ Domain '${pattern}' correctly accepted (${description})`);
|
||||
} else {
|
||||
expect(result.valid).toEqual(false);
|
||||
console.log(`✅ Domain '${pattern}' correctly rejected (${description})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Domain Validation - Invalid patterns', async () => {
|
||||
const invalidPatterns = [
|
||||
// Note: Empty string validation is handled differently in the validator
|
||||
// { pattern: '', description: 'Empty string' },
|
||||
{ pattern: '*.', description: 'Wildcard with trailing dot' },
|
||||
{ pattern: '.example.com', description: 'Leading dot' },
|
||||
{ pattern: 'example..com', description: 'Double dots' },
|
||||
{ pattern: 'exam ple.com', description: 'Space in domain' },
|
||||
{ pattern: 'example-.com', description: 'Hyphen at end of label' },
|
||||
{ pattern: '-example.com', description: 'Hyphen at start of label' },
|
||||
];
|
||||
|
||||
for (const { pattern, description } of invalidPatterns) {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: pattern
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
if (result.valid === false) {
|
||||
console.log(`✅ Domain '${pattern}' correctly rejected (${description})`);
|
||||
} else {
|
||||
console.log(`❌ Domain '${pattern}' was unexpectedly accepted! (${description})`);
|
||||
console.log(` Errors: ${result.errors.join(', ')}`);
|
||||
}
|
||||
expect(result.valid).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Domain Validation - Multiple domains in array', async () => {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: [
|
||||
'*.example.com',
|
||||
'*nevermind.cloud',
|
||||
'api.service.io',
|
||||
'localhost'
|
||||
]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
expect(result.valid).toEqual(true);
|
||||
console.log('✅ Multiple valid domains in array correctly accepted');
|
||||
});
|
||||
|
||||
tap.test('Domain Validation - Mixed valid and invalid domains', async () => {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: [
|
||||
'*.example.com', // valid
|
||||
'', // invalid - empty
|
||||
'localhost' // valid
|
||||
]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
expect(result.valid).toEqual(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid domain pattern'))).toEqual(true);
|
||||
console.log('✅ Mixed valid/invalid domains correctly rejected');
|
||||
});
|
||||
|
||||
tap.test('Domain Validation - Real-world patterns from email routes', async () => {
|
||||
// These are the patterns that were failing from the email conversion
|
||||
const realWorldPatterns = [
|
||||
{ pattern: '*nevermind.cloud', shouldPass: true, description: 'nevermind.cloud wildcard' },
|
||||
{ pattern: '*push.email', shouldPass: true, description: 'push.email wildcard' },
|
||||
{ pattern: '*.bleu.de', shouldPass: true, description: 'bleu.de subdomain wildcard' },
|
||||
{ pattern: '*bleu.de', shouldPass: true, description: 'bleu.de prefix wildcard' },
|
||||
];
|
||||
|
||||
for (const { pattern, shouldPass, description } of realWorldPatterns) {
|
||||
const route = {
|
||||
name: 'email-route',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: pattern
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'mail.server.com', port: 8080 }]
|
||||
}
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
|
||||
if (shouldPass) {
|
||||
expect(result.valid).toEqual(true);
|
||||
console.log(`✅ Real-world domain '${pattern}' correctly accepted (${description})`);
|
||||
} else {
|
||||
expect(result.valid).toEqual(false);
|
||||
console.log(`✅ Real-world domain '${pattern}' correctly rejected (${description})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -46,7 +46,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
|
||||
expect(httpsPassthroughRoute).toBeTruthy();
|
||||
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
|
||||
expect(Array.isArray(httpsPassthroughRoute.action.target?.host)).toBeTrue();
|
||||
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 3: HTTPS Termination to HTTP Backend
|
||||
const terminateToHttpRoute = createHttpsTerminateRoute(
|
||||
@@ -90,7 +90,7 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
|
||||
expect(loadBalancerRoute).toBeTruthy();
|
||||
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
expect(Array.isArray(loadBalancerRoute.action.target?.host)).toBeTrue();
|
||||
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 5: API Route
|
||||
const apiRoute = createApiRoute(
|
||||
|
||||
128
test/test.ip-validation.ts
Normal file
128
test/test.ip-validation.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
import { RouteValidator } from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
import { IpUtils } from '../ts/core/utils/ip-utils.js';
|
||||
|
||||
tap.test('IP Validation - Shorthand patterns', async () => {
|
||||
|
||||
// Test shorthand patterns are now accepted
|
||||
const testPatterns = [
|
||||
{ pattern: '192.168.*', shouldPass: true },
|
||||
{ pattern: '192.168.*.*', shouldPass: true },
|
||||
{ pattern: '10.*', shouldPass: true },
|
||||
{ pattern: '10.*.*.*', shouldPass: true },
|
||||
{ pattern: '172.16.*', shouldPass: true },
|
||||
{ pattern: '10.0.0.0/8', shouldPass: true },
|
||||
{ pattern: '192.168.0.0/16', shouldPass: true },
|
||||
{ pattern: '192.168.1.100', shouldPass: true },
|
||||
{ pattern: '*', shouldPass: true },
|
||||
{ pattern: '192.168.1.1-192.168.1.100', shouldPass: true },
|
||||
];
|
||||
|
||||
for (const { pattern, shouldPass } of testPatterns) {
|
||||
const route = {
|
||||
name: 'test',
|
||||
match: { ports: 80 },
|
||||
action: { type: 'forward' as const, targets: [{ host: 'localhost', port: 8080 }] },
|
||||
security: { ipAllowList: [pattern] }
|
||||
};
|
||||
|
||||
const result = RouteValidator.validateRoute(route);
|
||||
|
||||
if (shouldPass) {
|
||||
expect(result.valid).toEqual(true);
|
||||
console.log(`✅ Pattern '${pattern}' correctly accepted`);
|
||||
} else {
|
||||
expect(result.valid).toEqual(false);
|
||||
console.log(`✅ Pattern '${pattern}' correctly rejected`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('IP Matching - Runtime shorthand pattern matching', async () => {
|
||||
|
||||
// Test runtime matching with shorthand patterns
|
||||
const testCases = [
|
||||
{ ip: '192.168.1.100', patterns: ['192.168.*'], expected: true },
|
||||
{ ip: '192.168.1.100', patterns: ['192.168.1.*'], expected: true },
|
||||
{ ip: '192.168.1.100', patterns: ['192.168.2.*'], expected: false },
|
||||
{ ip: '10.0.0.1', patterns: ['10.*'], expected: true },
|
||||
{ ip: '10.1.2.3', patterns: ['10.*'], expected: true },
|
||||
{ ip: '172.16.0.1', patterns: ['10.*'], expected: false },
|
||||
{ ip: '192.168.1.1', patterns: ['192.168.*.*'], expected: true },
|
||||
];
|
||||
|
||||
for (const { ip, patterns, expected } of testCases) {
|
||||
const result = IpUtils.isGlobIPMatch(ip, patterns);
|
||||
expect(result).toEqual(expected);
|
||||
console.log(`✅ IP ${ip} with pattern ${patterns[0]} = ${result} (expected ${expected})`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('IP Matching - CIDR notation', async () => {
|
||||
|
||||
// Test CIDR notation matching
|
||||
const cidrTests = [
|
||||
{ ip: '10.0.0.1', cidr: '10.0.0.0/8', expected: true },
|
||||
{ ip: '10.255.255.255', cidr: '10.0.0.0/8', expected: true },
|
||||
{ ip: '11.0.0.1', cidr: '10.0.0.0/8', expected: false },
|
||||
{ ip: '192.168.1.1', cidr: '192.168.0.0/16', expected: true },
|
||||
{ ip: '192.168.255.255', cidr: '192.168.0.0/16', expected: true },
|
||||
{ ip: '192.169.0.1', cidr: '192.168.0.0/16', expected: false },
|
||||
{ ip: '192.168.1.100', cidr: '192.168.1.0/24', expected: true },
|
||||
{ ip: '192.168.2.100', cidr: '192.168.1.0/24', expected: false },
|
||||
];
|
||||
|
||||
for (const { ip, cidr, expected } of cidrTests) {
|
||||
const result = IpUtils.isGlobIPMatch(ip, [cidr]);
|
||||
expect(result).toEqual(expected);
|
||||
console.log(`✅ IP ${ip} in CIDR ${cidr} = ${result} (expected ${expected})`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('IP Matching - Range notation', async () => {
|
||||
|
||||
// Test range notation matching
|
||||
const rangeTests = [
|
||||
{ ip: '192.168.1.1', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||
{ ip: '192.168.1.50', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||
{ ip: '192.168.1.100', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||
{ ip: '192.168.1.101', range: '192.168.1.1-192.168.1.100', expected: false },
|
||||
{ ip: '192.168.2.50', range: '192.168.1.1-192.168.1.100', expected: false },
|
||||
];
|
||||
|
||||
for (const { ip, range, expected } of rangeTests) {
|
||||
const result = IpUtils.isGlobIPMatch(ip, [range]);
|
||||
expect(result).toEqual(expected);
|
||||
console.log(`✅ IP ${ip} in range ${range} = ${result} (expected ${expected})`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('IP Matching - Mixed patterns', async () => {
|
||||
|
||||
// Test with mixed pattern types
|
||||
const allowList = [
|
||||
'10.0.0.0/8', // CIDR
|
||||
'192.168.*', // Shorthand glob
|
||||
'172.16.1.*', // Specific subnet glob
|
||||
'8.8.8.8', // Single IP
|
||||
'1.1.1.1-1.1.1.10' // Range
|
||||
];
|
||||
|
||||
const tests = [
|
||||
{ ip: '10.1.2.3', expected: true }, // Matches CIDR
|
||||
{ ip: '192.168.100.1', expected: true }, // Matches shorthand glob
|
||||
{ ip: '172.16.1.5', expected: true }, // Matches specific glob
|
||||
{ ip: '8.8.8.8', expected: true }, // Matches single IP
|
||||
{ ip: '1.1.1.5', expected: true }, // Matches range
|
||||
{ ip: '9.9.9.9', expected: false }, // Doesn't match any
|
||||
];
|
||||
|
||||
for (const { ip, expected } of tests) {
|
||||
const result = IpUtils.isGlobIPMatch(ip, allowList);
|
||||
expect(result).toEqual(expected);
|
||||
console.log(`✅ IP ${ip} in mixed patterns = ${result} (expected ${expected})`);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -18,7 +18,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
socket.on('error', (err: NodeJS.ErrnoException) => {
|
||||
// Ignore errors from backend sockets
|
||||
console.log(`Backend socket error (expected during cleanup): ${err.code}`);
|
||||
});
|
||||
@@ -56,7 +56,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
const client1 = net.connect(8590, 'localhost');
|
||||
|
||||
// Add error handler to prevent unhandled errors
|
||||
client1.on('error', (err) => {
|
||||
client1.on('error', (err: NodeJS.ErrnoException) => {
|
||||
console.log(`Client1 error (expected during cleanup): ${err.code}`);
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
const client2 = net.connect(8591, 'localhost');
|
||||
|
||||
// Add error handler to prevent unhandled errors
|
||||
client2.on('error', (err) => {
|
||||
client2.on('error', (err: NodeJS.ErrnoException) => {
|
||||
console.log(`Client2 error (expected during cleanup): ${err.code}`);
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
const client3 = net.connect(8592, 'localhost');
|
||||
|
||||
// Add error handler to prevent unhandled errors
|
||||
client3.on('error', (err) => {
|
||||
client3.on('error', (err: NodeJS.ErrnoException) => {
|
||||
console.log(`Client3 error (expected during cleanup): ${err.code}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ tap.test('should not have memory leaks in long-running operations', async (tools
|
||||
routes[0].match.ports = 8080;
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8080], // Use non-privileged port
|
||||
routes: routes
|
||||
});
|
||||
await proxy.start();
|
||||
@@ -143,7 +142,7 @@ tap.test('should not have memory leaks in long-running operations', async (tools
|
||||
|
||||
// Cleanup
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => targetServer.close(resolve));
|
||||
await new Promise<void>((resolve) => targetServer.close(() => resolve()));
|
||||
|
||||
console.log('Memory leak test completed successfully');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ tap.test('memory leak fixes verification', async () => {
|
||||
// Test 1: MetricsCollector requestTimestamps cleanup
|
||||
console.log('\n=== Test 1: MetricsCollector requestTimestamps cleanup ===');
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8081],
|
||||
routes: [
|
||||
createHttpRoute('test.local', { host: 'localhost', port: 3200 }, {
|
||||
match: {
|
||||
@@ -40,7 +39,7 @@ tap.test('memory leak fixes verification', async () => {
|
||||
|
||||
// Check RequestHandler has destroy method
|
||||
const { RequestHandler } = await import('../ts/proxies/http-proxy/request-handler.js');
|
||||
const requestHandler = new RequestHandler({}, null as any);
|
||||
const requestHandler = new RequestHandler({ port: 8080 }, null as any);
|
||||
expect(typeof requestHandler.destroy).toEqual('function');
|
||||
console.log('✓ RequestHandler has destroy method');
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ tap.test('memory leak fixes - unit tests', async () => {
|
||||
|
||||
// Add 6000 timestamps
|
||||
for (let i = 0; i < 6000; i++) {
|
||||
collector.recordRequest();
|
||||
collector.recordRequest(`conn-${i}`, 'test-route', '127.0.0.1');
|
||||
}
|
||||
|
||||
// Access private property for testing
|
||||
@@ -37,7 +37,7 @@ tap.test('memory leak fixes - unit tests', async () => {
|
||||
console.log(`Timestamps after 6000 requests: ${timestamps.length}`);
|
||||
|
||||
// Force one more request to trigger cleanup
|
||||
collector.recordRequest();
|
||||
collector.recordRequest('conn-final', 'test-route', '127.0.0.1');
|
||||
timestamps = (collector as any).requestTimestamps;
|
||||
console.log(`Timestamps after cleanup trigger: ${timestamps.length}`);
|
||||
|
||||
@@ -64,7 +64,7 @@ tap.test('memory leak fixes - unit tests', async () => {
|
||||
|
||||
// Add new timestamps to exceed limit
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
collector.recordRequest();
|
||||
collector.recordRequest(`conn-new-${i}`, 'test-route', '127.0.0.1');
|
||||
}
|
||||
|
||||
timestamps = (collector as any).requestTimestamps;
|
||||
@@ -110,7 +110,7 @@ tap.test('memory leak fixes - unit tests', async () => {
|
||||
};
|
||||
|
||||
const handler = new RequestHandler(
|
||||
{ logLevel: 'error' },
|
||||
{ port: 8080, logLevel: 'error' },
|
||||
mockConnectionPool as any
|
||||
);
|
||||
|
||||
|
||||
@@ -29,10 +29,8 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
match: {
|
||||
matchType: 'startsWith',
|
||||
matchAgainst: 'domain',
|
||||
value: ['*'],
|
||||
ports: [proxyPort] // Add the port to match on
|
||||
ports: [proxyPort],
|
||||
domains: '*'
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -45,9 +43,11 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
|
||||
}
|
||||
}
|
||||
}],
|
||||
defaultTarget: {
|
||||
host: 'localhost',
|
||||
port: echoServerPort
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: echoServerPort
|
||||
}
|
||||
},
|
||||
metrics: {
|
||||
enabled: true,
|
||||
|
||||
@@ -71,8 +71,12 @@ const SKIP_TESTS = true;
|
||||
tap.skip.test('NFTablesManager setup test', async () => {
|
||||
// Test will be skipped if not running as root due to tap.skip.test
|
||||
|
||||
// Create a SmartProxy instance first
|
||||
const { SmartProxy } = await import('../ts/proxies/smart-proxy/smart-proxy.js');
|
||||
const proxy = new SmartProxy(sampleOptions);
|
||||
|
||||
// Create a new instance of NFTablesManager
|
||||
manager = new NFTablesManager(sampleOptions);
|
||||
manager = new NFTablesManager(proxy);
|
||||
|
||||
// Verify the instance was created successfully
|
||||
expect(manager).toBeTruthy();
|
||||
|
||||
@@ -32,7 +32,9 @@ if (!isRoot) {
|
||||
const testFn = isRoot ? tap.test : tap.skip.test;
|
||||
|
||||
testFn('NFTablesManager status functionality', async () => {
|
||||
const nftablesManager = new NFTablesManager({ routes: [] });
|
||||
const { SmartProxy } = await import('../ts/proxies/smart-proxy/smart-proxy.js');
|
||||
const proxy = new SmartProxy({ routes: [] });
|
||||
const nftablesManager = new NFTablesManager(proxy);
|
||||
|
||||
// Create test routes
|
||||
const testRoutes = [
|
||||
|
||||
@@ -31,7 +31,6 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
||||
acceptProxyProtocol: true,
|
||||
sendProxyProtocol: false,
|
||||
enableDetailedLogging: true,
|
||||
connectionCleanupInterval: 5000, // More frequent cleanup for testing
|
||||
inactivityTimeout: 10000 // Shorter timeout for testing
|
||||
});
|
||||
await innerProxy.start();
|
||||
@@ -61,7 +60,6 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
||||
},
|
||||
sendProxyProtocol: true,
|
||||
enableDetailedLogging: true,
|
||||
connectionCleanupInterval: 5000, // More frequent cleanup for testing
|
||||
inactivityTimeout: 10000 // Shorter timeout for testing
|
||||
});
|
||||
await outerProxy.start();
|
||||
|
||||
@@ -24,7 +24,6 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
|
||||
// Create SmartProxy2 (downstream)
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8591],
|
||||
enableDetailedLogging: true,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
@@ -42,7 +41,6 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
|
||||
// Create SmartProxy1 (upstream)
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8590],
|
||||
enableDetailedLogging: true,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
@@ -91,7 +89,7 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
dataReceived = true;
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
client.on('error', (err: NodeJS.ErrnoException) => {
|
||||
console.log(`Client error: ${err.code}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
||||
|
||||
// Create SmartProxy2 (downstream proxy)
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8581],
|
||||
enableDetailedLogging: false,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
@@ -29,7 +28,6 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
||||
|
||||
// Create SmartProxy1 (upstream proxy)
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8580],
|
||||
enableDetailedLogging: false,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
@@ -71,7 +69,7 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', (err) => {
|
||||
client.on('error', (err: NodeJS.ErrnoException) => {
|
||||
console.log(`Client received error: ${err.code}`);
|
||||
resolve();
|
||||
});
|
||||
@@ -261,7 +259,6 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
||||
|
||||
// Create SmartProxy2 with HTTP handling
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8583],
|
||||
useHttpProxy: [8583], // Enable HTTP proxy handling
|
||||
httpProxyPort: 8584,
|
||||
enableDetailedLogging: false,
|
||||
@@ -280,7 +277,6 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
||||
|
||||
// Create SmartProxy1 with HTTP handling
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8582],
|
||||
useHttpProxy: [8582], // Enable HTTP proxy handling
|
||||
httpProxyPort: 8585,
|
||||
enableDetailedLogging: false,
|
||||
|
||||
@@ -10,7 +10,6 @@ tap.test('should handle rapid connection retries without leaking connections', a
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8550],
|
||||
enableDetailedLogging: false,
|
||||
maxConnectionLifetime: 10000,
|
||||
socketTimeout: 5000,
|
||||
@@ -128,7 +127,6 @@ tap.test('should handle routing failures without leaking connections', async ()
|
||||
|
||||
// Create a SmartProxy instance with no routes
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8551],
|
||||
enableDetailedLogging: false,
|
||||
maxConnectionLifetime: 10000,
|
||||
socketTimeout: 5000,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
isValidPort,
|
||||
hasRequiredPropertiesForAction,
|
||||
assertValidRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validators.js';
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
createHttpRoute,
|
||||
@@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
})
|
||||
],
|
||||
defaults: {
|
||||
targets: [{
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}],
|
||||
},
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '192.168.0.*'],
|
||||
maxConnections: 100
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
validateRouteAction,
|
||||
hasRequiredPropertiesForAction,
|
||||
assertValidRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validators.js';
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
// Route utilities
|
||||
|
||||
@@ -7,7 +7,6 @@ tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
|
||||
console.log('\n=== Test 1: Grace periods for encrypted connections ===');
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8443],
|
||||
keepAliveTreatment: 'extended',
|
||||
keepAliveInactivityMultiplier: 10,
|
||||
inactivityTimeout: 60000, // 1 minute for testing
|
||||
@@ -100,7 +99,6 @@ tap.test('long-lived connection survival test', async (tools) => {
|
||||
|
||||
// Create proxy with immortal keep-alive
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8444],
|
||||
keepAliveTreatment: 'immortal', // Never timeout
|
||||
routes: [
|
||||
{
|
||||
@@ -150,7 +148,7 @@ tap.test('long-lived connection survival test', async (tools) => {
|
||||
clearInterval(pingInterval);
|
||||
client.destroy();
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => echoServer.close(resolve));
|
||||
await new Promise<void>((resolve) => echoServer.close(() => resolve()));
|
||||
|
||||
console.log('✅ Long-lived connection survived past 30-second timeout!');
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
||||
|
||||
// Create InnerProxy with faster inactivity check for testing
|
||||
const innerProxy = new SmartProxy({
|
||||
ports: [8591],
|
||||
enableDetailedLogging: true,
|
||||
inactivityTimeout: 5000, // 5 seconds for faster testing
|
||||
inactivityCheckInterval: 1000, // Check every second
|
||||
@@ -62,7 +61,6 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
||||
|
||||
// Create OuterProxy with faster inactivity check
|
||||
const outerProxy = new SmartProxy({
|
||||
ports: [8590],
|
||||
enableDetailedLogging: true,
|
||||
inactivityTimeout: 5000, // 5 seconds for faster testing
|
||||
inactivityCheckInterval: 1000, // Check every second
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '21.1.5',
|
||||
version: '22.0.0',
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -21,13 +21,47 @@ export class IpUtils {
|
||||
const normalizedIPVariants = this.normalizeIP(ip);
|
||||
if (normalizedIPVariants.length === 0) return false;
|
||||
|
||||
// Normalize the pattern IPs for consistent comparison
|
||||
const expandedPatterns = patterns.flatMap(pattern => this.normalizeIP(pattern));
|
||||
// Check each pattern
|
||||
for (const pattern of patterns) {
|
||||
// Handle CIDR notation
|
||||
if (pattern.includes('/')) {
|
||||
if (this.matchCIDR(ip, pattern)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for any match between normalized IP variants and patterns
|
||||
return normalizedIPVariants.some((ipVariant) =>
|
||||
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
||||
);
|
||||
// Handle range notation
|
||||
if (pattern.includes('-') && !pattern.includes('*')) {
|
||||
if (this.matchIPRange(ip, pattern)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand shorthand patterns for glob matching
|
||||
let expandedPattern = pattern;
|
||||
if (pattern.includes('*') && !pattern.includes(':')) {
|
||||
const parts = pattern.split('.');
|
||||
while (parts.length < 4) {
|
||||
parts.push('*');
|
||||
}
|
||||
expandedPattern = parts.join('.');
|
||||
}
|
||||
|
||||
// Normalize and check with minimatch
|
||||
const normalizedPatterns = this.normalizeIP(expandedPattern);
|
||||
|
||||
for (const ipVariant of normalizedIPVariants) {
|
||||
for (const normalizedPattern of normalizedPatterns) {
|
||||
if (plugins.minimatch(ipVariant, normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +158,100 @@ export class IpUtils {
|
||||
return !this.isPrivateIP(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a CIDR notation
|
||||
*
|
||||
* @param ip The IP address to check
|
||||
* @param cidr The CIDR notation (e.g., "192.168.1.0/24")
|
||||
* @returns true if IP is within the CIDR range
|
||||
*/
|
||||
private static matchCIDR(ip: string, cidr: string): boolean {
|
||||
if (!cidr.includes('/')) return false;
|
||||
|
||||
const [networkAddr, prefixStr] = cidr.split('/');
|
||||
const prefix = parseInt(prefixStr, 10);
|
||||
|
||||
// Handle IPv4-mapped IPv6 in the IP being checked
|
||||
let checkIP = ip;
|
||||
if (checkIP.startsWith('::ffff:')) {
|
||||
checkIP = checkIP.slice(7);
|
||||
}
|
||||
|
||||
// Handle IPv6 CIDR
|
||||
if (networkAddr.includes(':')) {
|
||||
// TODO: Implement IPv6 CIDR matching
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 CIDR matching
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(networkAddr)) return false;
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false;
|
||||
|
||||
const ipParts = checkIP.split('.').map(Number);
|
||||
const netParts = networkAddr.split('.').map(Number);
|
||||
|
||||
// Validate IP parts
|
||||
for (const part of [...ipParts, ...netParts]) {
|
||||
if (part < 0 || part > 255) return false;
|
||||
}
|
||||
|
||||
// Convert to 32-bit integers
|
||||
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
const netNum = (netParts[0] << 24) | (netParts[1] << 16) | (netParts[2] << 8) | netParts[3];
|
||||
|
||||
// Create mask
|
||||
const mask = (-1 << (32 - prefix)) >>> 0;
|
||||
|
||||
// Check if IP is in network range
|
||||
return (ipNum & mask) === (netNum & mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a range notation
|
||||
*
|
||||
* @param ip The IP address to check
|
||||
* @param range The range notation (e.g., "192.168.1.1-192.168.1.100")
|
||||
* @returns true if IP is within the range
|
||||
*/
|
||||
private static matchIPRange(ip: string, range: string): boolean {
|
||||
if (!range.includes('-')) return false;
|
||||
|
||||
const [startIP, endIP] = range.split('-').map(s => s.trim());
|
||||
|
||||
// Handle IPv4-mapped IPv6 in the IP being checked
|
||||
let checkIP = ip;
|
||||
if (checkIP.startsWith('::ffff:')) {
|
||||
checkIP = checkIP.slice(7);
|
||||
}
|
||||
|
||||
// Only handle IPv4 for now
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(startIP)) return false;
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(endIP)) return false;
|
||||
|
||||
const ipParts = checkIP.split('.').map(Number);
|
||||
const startParts = startIP.split('.').map(Number);
|
||||
const endParts = endIP.split('.').map(Number);
|
||||
|
||||
// Validate parts
|
||||
for (const part of [...ipParts, ...startParts, ...endParts]) {
|
||||
if (part < 0 || part > 255) return false;
|
||||
}
|
||||
|
||||
// Convert to 32-bit integers for comparison
|
||||
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
const startNum = (startParts[0] << 24) | (startParts[1] << 16) | (startParts[2] << 8) | startParts[3];
|
||||
const endNum = (endParts[0] << 24) | (endParts[1] << 16) | (endParts[2] << 8) | endParts[3];
|
||||
|
||||
// Convert to unsigned for proper comparison
|
||||
const ipUnsigned = ipNum >>> 0;
|
||||
const startUnsigned = startNum >>> 0;
|
||||
const endUnsigned = endNum >>> 0;
|
||||
|
||||
return ipUnsigned >= startUnsigned && ipUnsigned <= endUnsigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a subnet CIDR to an IP range for filtering
|
||||
*
|
||||
|
||||
@@ -304,7 +304,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
// For SmartProxy connections, wait for CLIENT_IP header
|
||||
if (isFromSmartProxy) {
|
||||
const MAX_PREFACE = 256; // bytes - prevent DoS
|
||||
const HEADER_TIMEOUT_MS = 500; // timeout for header parsing
|
||||
const HEADER_TIMEOUT_MS = 2000; // timeout for header parsing (increased for slow networks)
|
||||
let headerTimer: NodeJS.Timeout | undefined;
|
||||
let buffered = Buffer.alloc(0);
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Register cleanup handlers if deleteOnExit is true
|
||||
if (this.settings.deleteOnExit) {
|
||||
const cleanup = () => {
|
||||
// Synchronous cleanup for 'exit' event (only sync code runs here)
|
||||
const syncCleanup = () => {
|
||||
try {
|
||||
this.stopSync();
|
||||
} catch (err) {
|
||||
@@ -84,14 +85,21 @@ export class NfTablesProxy {
|
||||
}
|
||||
};
|
||||
|
||||
process.on('exit', cleanup);
|
||||
// Async cleanup for signal handlers (preferred, non-blocking)
|
||||
const asyncCleanup = async () => {
|
||||
try {
|
||||
await this.stop();
|
||||
} catch (err) {
|
||||
this.log('error', 'Error cleaning nftables rules on signal:', { error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
process.on('exit', syncCleanup);
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit();
|
||||
asyncCleanup().finally(() => process.exit());
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit();
|
||||
asyncCleanup().finally(() => process.exit());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -219,37 +227,17 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute system command synchronously with multiple attempts
|
||||
* @deprecated This method blocks the event loop and should be avoided. Use executeWithRetry instead.
|
||||
* WARNING: This method contains a busy wait loop that will block the entire Node.js event loop!
|
||||
* Execute system command synchronously (single attempt, no retry)
|
||||
* Used only for exit handlers where the process is terminating anyway.
|
||||
* For normal operations, use the async executeWithRetry method.
|
||||
*/
|
||||
private executeWithRetrySync(command: string, maxRetries = 3, retryDelayMs = 1000): string {
|
||||
// Log deprecation warning
|
||||
console.warn('[DEPRECATION WARNING] executeWithRetrySync blocks the event loop and should not be used. Consider using the async executeWithRetry method instead.');
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return execSync(command).toString();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
this.log('warn', `Command failed (attempt ${i+1}/${maxRetries}): ${command}`, { error: err.message });
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < maxRetries - 1) {
|
||||
// CRITICAL: This busy wait loop blocks the entire event loop!
|
||||
// This is a temporary fallback for sync contexts only.
|
||||
// TODO: Remove this method entirely and make all callers async
|
||||
const waitUntil = Date.now() + retryDelayMs;
|
||||
while (Date.now() < waitUntil) {
|
||||
// Busy wait - blocks event loop
|
||||
}
|
||||
}
|
||||
}
|
||||
private executeSync(command: string): string {
|
||||
try {
|
||||
return execSync(command, { timeout: 5000 }).toString();
|
||||
} catch (err) {
|
||||
this.log('warn', `Sync command failed: ${command}`, { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1649,7 +1637,8 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of stop, for use in exit handlers
|
||||
* Synchronous version of stop, for use in exit handlers only.
|
||||
* Uses single-attempt commands without retry (process is exiting anyway).
|
||||
*/
|
||||
public stopSync(): void {
|
||||
try {
|
||||
@@ -1671,12 +1660,8 @@ export class NfTablesProxy {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
this.executeWithRetrySync(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset (single attempt, no retry - process is exiting)
|
||||
this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
@@ -1687,7 +1672,11 @@ export class NfTablesProxy {
|
||||
});
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
try {
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
} catch {
|
||||
// Ignore - process is exiting
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IP sets if we created any
|
||||
@@ -1696,12 +1685,10 @@ export class NfTablesProxy {
|
||||
const [family, setName] = key.split(':');
|
||||
|
||||
try {
|
||||
this.executeWithRetrySync(
|
||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
this.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
|
||||
);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Non-critical error, continue
|
||||
}
|
||||
}
|
||||
@@ -1760,7 +1747,7 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of cleanupEmptyTables
|
||||
* Synchronous version of cleanupEmptyTables (for exit handlers only)
|
||||
*/
|
||||
private cleanupEmptyTablesSync(): void {
|
||||
// Check if tables are empty, and if so, delete them
|
||||
@@ -1772,10 +1759,8 @@ export class NfTablesProxy {
|
||||
|
||||
try {
|
||||
// Check if table exists
|
||||
const tableExistsOutput = this.executeWithRetrySync(
|
||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
const tableExistsOutput = this.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`
|
||||
);
|
||||
|
||||
const tableExists = tableExistsOutput.includes(`table ${family} ${this.tableName}`);
|
||||
@@ -1785,20 +1770,16 @@ export class NfTablesProxy {
|
||||
}
|
||||
|
||||
// Check if the table has any rules
|
||||
const stdout = this.executeWithRetrySync(
|
||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
const stdout = this.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
|
||||
);
|
||||
|
||||
const hasRules = stdout.includes('rule');
|
||||
|
||||
if (!hasRules) {
|
||||
// Table is empty, delete it
|
||||
this.executeWithRetrySync(
|
||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
this.executeSync(
|
||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
|
||||
);
|
||||
|
||||
this.log('info', `Deleted empty table ${family} ${this.tableName}`);
|
||||
|
||||
@@ -389,12 +389,13 @@ export class SmartCertManager {
|
||||
let cert: string = certConfig.cert;
|
||||
|
||||
// Load from files if paths are provided
|
||||
const smartFileFactory = plugins.smartfile.SmartFileFactory.nodeFs();
|
||||
if (certConfig.keyFile) {
|
||||
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.keyFile);
|
||||
const keyFile = await smartFileFactory.fromFilePath(certConfig.keyFile);
|
||||
key = keyFile.contents.toString();
|
||||
}
|
||||
if (certConfig.certFile) {
|
||||
const certFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.certFile);
|
||||
const certFile = await smartFileFactory.fromFilePath(certConfig.certFile);
|
||||
cert = certFile.contents.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -110,16 +110,45 @@ export class HttpProxyBridge {
|
||||
throw new Error('HttpProxy not initialized');
|
||||
}
|
||||
|
||||
// Check if client socket is already destroyed before proceeding
|
||||
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
|
||||
if (underlyingSocket.destroyed) {
|
||||
console.log(`[${connectionId}] Client socket already destroyed, skipping HttpProxy forwarding`);
|
||||
cleanupCallback('client_disconnected_before_proxy');
|
||||
return;
|
||||
}
|
||||
|
||||
const proxySocket = new plugins.net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxySocket.connect(httpProxyPort, 'localhost', () => {
|
||||
console.log(`[${connectionId}] Connected to HttpProxy for termination`);
|
||||
resolve();
|
||||
});
|
||||
// Handle client disconnect during proxy connection setup
|
||||
const clientDisconnectHandler = () => {
|
||||
console.log(`[${connectionId}] Client disconnected during HttpProxy connection setup`);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback('client_disconnected_during_setup');
|
||||
};
|
||||
underlyingSocket.once('close', clientDisconnectHandler);
|
||||
|
||||
proxySocket.on('error', reject);
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxySocket.connect(httpProxyPort, 'localhost', () => {
|
||||
console.log(`[${connectionId}] Connected to HttpProxy for termination`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
proxySocket.on('error', reject);
|
||||
});
|
||||
} finally {
|
||||
// Remove the disconnect handler after connection attempt
|
||||
underlyingSocket.removeListener('close', clientDisconnectHandler);
|
||||
}
|
||||
|
||||
// Double-check client socket is still connected after async operation
|
||||
if (underlyingSocket.destroyed) {
|
||||
console.log(`[${connectionId}] Client disconnected while connecting to HttpProxy`);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback('client_disconnected_after_proxy_connect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send client IP information header first (custom protocol)
|
||||
// Format: "CLIENT_IP:<ip>\r\n"
|
||||
@@ -136,10 +165,7 @@ export class HttpProxyBridge {
|
||||
proxySocket.write(initialChunk);
|
||||
}
|
||||
|
||||
// Use centralized bidirectional forwarding
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
|
||||
|
||||
// Use centralized bidirectional forwarding (underlyingSocket already extracted above)
|
||||
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
|
||||
onClientData: (chunk) => {
|
||||
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
|
||||
|
||||
@@ -89,7 +89,6 @@ export interface ISmartProxyOptions {
|
||||
enableDetailedLogging?: boolean; // Enable detailed connection logging
|
||||
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
|
||||
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
|
||||
allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true)
|
||||
|
||||
// Rate limiting and security
|
||||
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
||||
|
||||
@@ -192,6 +192,20 @@ export class RouteConnectionHandler {
|
||||
route.action.tls.mode === 'passthrough');
|
||||
});
|
||||
|
||||
// Auto-calculate session ticket handling based on route configuration
|
||||
// If any route on this port terminates TLS, allow session tickets (HttpProxy handles resumption)
|
||||
// Otherwise, block session tickets (need SNI for passthrough routing)
|
||||
const hasTlsTermination = allRoutes.some(route => {
|
||||
const matchesPort = this.smartProxy.routeManager.getRoutesForPort(localPort).includes(route);
|
||||
|
||||
return matchesPort &&
|
||||
route.action.type === 'forward' &&
|
||||
route.action.tls &&
|
||||
(route.action.tls.mode === 'terminate' ||
|
||||
route.action.tls.mode === 'terminate-and-reencrypt');
|
||||
});
|
||||
const allowSessionTicket = hasTlsTermination;
|
||||
|
||||
// If no routes require TLS handling and it's not port 443, route immediately
|
||||
if (!needsTlsHandling && localPort !== 443) {
|
||||
// Extract underlying socket for socket-utils functions
|
||||
@@ -345,7 +359,7 @@ export class RouteConnectionHandler {
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
// Check if we should reject connections without SNI
|
||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||
if (!serverName && allowSessionTicket === false) {
|
||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${record.id}; sending TLS alert`, {
|
||||
connectionId: record.id,
|
||||
component: 'route-handler'
|
||||
|
||||
@@ -127,8 +127,20 @@ export class SecurityManager {
|
||||
const normalizedIPVariants = normalizeIP(ip);
|
||||
if (normalizedIPVariants.length === 0) return false;
|
||||
|
||||
// Normalize the pattern IPs for consistent comparison
|
||||
const expandedPatterns = patterns.flatMap(normalizeIP);
|
||||
// Expand shorthand patterns and normalize IPs for consistent comparison
|
||||
const expandShorthand = (pattern: string): string => {
|
||||
// Expand shorthand IP patterns like '192.168.*' to '192.168.*.*'
|
||||
if (pattern.includes('*') && !pattern.includes(':')) {
|
||||
const parts = pattern.split('.');
|
||||
while (parts.length < 4) {
|
||||
parts.push('*');
|
||||
}
|
||||
return parts.join('.');
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
|
||||
const expandedPatterns = patterns.map(expandShorthand).flatMap(normalizeIP);
|
||||
|
||||
// Check for any match between normalized IP variants and patterns
|
||||
return normalizedIPVariants.some((ipVariant) =>
|
||||
|
||||
@@ -137,8 +137,6 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
|
||||
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
|
||||
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
|
||||
allowSessionTicket:
|
||||
settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
|
||||
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
|
||||
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
|
||||
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
|
||||
|
||||
@@ -50,43 +50,7 @@ export class TlsManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session resumption attempts
|
||||
*/
|
||||
public handleSessionResumption(
|
||||
chunk: Buffer,
|
||||
connectionId: string,
|
||||
hasSNI: boolean
|
||||
): { shouldBlock: boolean; reason?: string } {
|
||||
// Skip if session tickets are allowed
|
||||
if (this.smartProxy.settings.allowSessionTicket !== false) {
|
||||
return { shouldBlock: false };
|
||||
}
|
||||
|
||||
// Check for session resumption attempt
|
||||
const resumptionInfo = SniHandler.hasSessionResumption(
|
||||
chunk,
|
||||
this.smartProxy.settings.enableTlsDebugLogging || false
|
||||
);
|
||||
|
||||
// If this is a resumption attempt without SNI, block it
|
||||
if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) {
|
||||
if (this.smartProxy.settings.enableTlsDebugLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` +
|
||||
`Terminating connection to force new TLS handshake.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
shouldBlock: true,
|
||||
reason: 'session_ticket_blocked'
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldBlock: false };
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Check for SNI mismatch during renegotiation
|
||||
*/
|
||||
public checkRenegotiationSNI(
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// Export route helpers for creating route configurations
|
||||
export * from './route-helpers.js';
|
||||
|
||||
// Export route validators for validating route configurations
|
||||
export * from './route-validators.js';
|
||||
// Export route validator (class-based and functional API)
|
||||
export * from './route-validator.js';
|
||||
|
||||
// Export route utilities for route operations
|
||||
export * from './route-utils.js';
|
||||
@@ -21,5 +21,3 @@ export {
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from './route-helpers.js';
|
||||
|
||||
// Migration utilities have been removed as they are no longer needed
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch } from '../models/route-types.js';
|
||||
import { validateRouteConfig } from './route-validators.js';
|
||||
import { validateRouteConfig } from './route-validator.js';
|
||||
|
||||
/**
|
||||
* Merge two route configurations
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from '../../../core/utils/logger.js';
|
||||
import type { IRouteConfig } from '../models/route-types.js';
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Validates route configurations for correctness and safety
|
||||
@@ -335,10 +335,22 @@ export class RouteValidator {
|
||||
private static isValidDomain(domain: string): boolean {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
if (domain === '*') return true;
|
||||
if (domain === 'localhost') return true;
|
||||
|
||||
// Basic domain pattern validation
|
||||
const domainPattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
return domainPattern.test(domain) || domain === 'localhost';
|
||||
// Allow both *.domain and *domain patterns
|
||||
// Also allow regular domains and subdomains
|
||||
const domainPatterns = [
|
||||
// Standard domain with optional wildcard subdomain (*.example.com)
|
||||
/^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
||||
// Wildcard prefix without dot (*example.com)
|
||||
/^\*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/,
|
||||
// IP address
|
||||
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
// IPv6 address
|
||||
/^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/
|
||||
];
|
||||
|
||||
return domainPatterns.some(pattern => pattern.test(domain));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +405,8 @@ export class RouteValidator {
|
||||
// Check for wildcards in IPv4
|
||||
if (ip.includes('*') && !ip.includes(':')) {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
// Allow 1-4 parts for wildcard patterns (e.g., '10.*', '192.168.*', '192.168.1.*')
|
||||
if (parts.length < 1 || parts.length > 4) return false;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
|
||||
@@ -451,3 +464,273 @@ export class RouteValidator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Functional API (for backwards compatibility with route-validators.ts)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validates a port range or port number
|
||||
* @param port Port number, port range, or port function
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function isValidPort(port: any): boolean {
|
||||
if (typeof port === 'number') {
|
||||
return port > 0 && port < 65536;
|
||||
} else if (Array.isArray(port)) {
|
||||
return port.every(p =>
|
||||
(typeof p === 'number' && p > 0 && p < 65536) ||
|
||||
(typeof p === 'object' && 'from' in p && 'to' in p &&
|
||||
p.from > 0 && p.from < 65536 && p.to > 0 && p.to < 65536)
|
||||
);
|
||||
} else if (typeof port === 'function') {
|
||||
return true;
|
||||
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
||||
return port.from > 0 && port.from < 65536 && port.to > 0 && port.to < 65536;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a domain string - supports wildcards, localhost, and IP addresses
|
||||
* @param domain Domain string to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
if (domain === '*') return true;
|
||||
if (domain === 'localhost') return true;
|
||||
|
||||
const domainPatterns = [
|
||||
// Standard domain with optional wildcard subdomain (*.example.com)
|
||||
/^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
||||
// Wildcard prefix without dot (*example.com)
|
||||
/^\*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/,
|
||||
// IP address
|
||||
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
// IPv6 address
|
||||
/^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/
|
||||
];
|
||||
|
||||
return domainPatterns.some(pattern => pattern.test(domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a route match configuration
|
||||
* @param match Route match configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteMatch(match: IRouteMatch): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (match.ports !== undefined) {
|
||||
if (!isValidPort(match.ports)) {
|
||||
errors.push('Invalid port number or port range in match.ports');
|
||||
}
|
||||
}
|
||||
|
||||
if (match.domains !== undefined) {
|
||||
if (typeof match.domains === 'string') {
|
||||
if (!isValidDomain(match.domains)) {
|
||||
errors.push(`Invalid domain format: ${match.domains}`);
|
||||
}
|
||||
} else if (Array.isArray(match.domains)) {
|
||||
for (const domain of match.domains) {
|
||||
if (!isValidDomain(domain)) {
|
||||
errors.push(`Invalid domain format: ${domain}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push('Domains must be a string or an array of strings');
|
||||
}
|
||||
}
|
||||
|
||||
if (match.path !== undefined) {
|
||||
if (typeof match.path !== 'string' || !match.path.startsWith('/')) {
|
||||
errors.push('Path must be a string starting with /');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a route action configuration
|
||||
* @param action Route action configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteAction(action: IRouteAction): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!action.type) {
|
||||
errors.push('Action type is required');
|
||||
} else if (!['forward', 'socket-handler'].includes(action.type)) {
|
||||
errors.push(`Invalid action type: ${action.type}`);
|
||||
}
|
||||
|
||||
if (action.type === 'forward') {
|
||||
if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
|
||||
errors.push('Targets array is required for forward action');
|
||||
} else {
|
||||
action.targets.forEach((target, index) => {
|
||||
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`);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (action.tls) {
|
||||
if (!['passthrough', 'terminate', 'terminate-and-reencrypt'].includes(action.tls.mode)) {
|
||||
errors.push(`Invalid TLS mode: ${action.tls.mode}`);
|
||||
}
|
||||
|
||||
if (['terminate', 'terminate-and-reencrypt'].includes(action.tls.mode)) {
|
||||
if (action.tls.certificate !== 'auto' &&
|
||||
(!action.tls.certificate || !action.tls.certificate.key || !action.tls.certificate.cert)) {
|
||||
errors.push('Certificate must be "auto" or an object with key and cert properties');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'socket-handler') {
|
||||
if (!action.socketHandler) {
|
||||
errors.push('Socket handler function is required for socket-handler action');
|
||||
} else if (typeof action.socketHandler !== 'function') {
|
||||
errors.push('Socket handler must be a function');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a complete route configuration
|
||||
* @param route Route configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteConfig(route: IRouteConfig): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!route.match) {
|
||||
errors.push('Route match configuration is required');
|
||||
}
|
||||
|
||||
if (!route.action) {
|
||||
errors.push('Route action configuration is required');
|
||||
}
|
||||
|
||||
if (route.match) {
|
||||
const matchValidation = validateRouteMatch(route.match);
|
||||
if (!matchValidation.valid) {
|
||||
errors.push(...matchValidation.errors.map(err => `Match: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (route.action) {
|
||||
const actionValidation = validateRouteAction(route.action);
|
||||
if (!actionValidation.valid) {
|
||||
errors.push(...actionValidation.errors.map(err => `Action: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of route configurations
|
||||
* @param routes Array of route configurations to validate
|
||||
* @returns { valid: boolean, errors: { index: number, errors: string[] }[] } Validation result
|
||||
*/
|
||||
export function validateRoutes(routes: IRouteConfig[]): {
|
||||
valid: boolean;
|
||||
errors: { index: number; errors: string[] }[]
|
||||
} {
|
||||
const results: { index: number; errors: string[] }[] = [];
|
||||
|
||||
routes.forEach((route, index) => {
|
||||
const validation = validateRouteConfig(route);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
index,
|
||||
errors: validation.errors
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: results.length === 0,
|
||||
errors: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route configuration has the required properties for a specific action type
|
||||
* @param route Route configuration to check
|
||||
* @param actionType Expected action type
|
||||
* @returns True if the route has the necessary properties, false otherwise
|
||||
*/
|
||||
export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType: string): boolean {
|
||||
if (!route.action || route.action.type !== actionType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (actionType) {
|
||||
case 'forward':
|
||||
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:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if the route config is invalid, returns the config if valid
|
||||
* Useful for immediate validation when creating routes
|
||||
* @param route Route configuration to validate
|
||||
* @returns The validated route configuration
|
||||
* @throws Error if the route configuration is invalid
|
||||
*/
|
||||
export function assertValidRoute(route: IRouteConfig): IRouteConfig {
|
||||
const validation = validateRouteConfig(route);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid route configuration: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Route Validators
|
||||
*
|
||||
* This file provides utility functions for validating route configurations.
|
||||
* These validators help ensure that route configurations are valid and correctly structured.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Validates a port range or port number
|
||||
* @param port Port number, port range, or port function
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function isValidPort(port: any): boolean {
|
||||
if (typeof port === 'number') {
|
||||
return port > 0 && port < 65536; // Valid port range is 1-65535
|
||||
} else if (Array.isArray(port)) {
|
||||
return port.every(p =>
|
||||
(typeof p === 'number' && p > 0 && p < 65536) ||
|
||||
(typeof p === 'object' && 'from' in p && 'to' in p &&
|
||||
p.from > 0 && p.from < 65536 && p.to > 0 && p.to < 65536)
|
||||
);
|
||||
} else if (typeof port === 'function') {
|
||||
// For function-based ports, we can't validate the result at config time
|
||||
// so we just check that it's a function
|
||||
return true;
|
||||
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
||||
return port.from > 0 && port.from < 65536 && port.to > 0 && port.to < 65536;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a domain string
|
||||
* @param domain Domain string to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
// Basic domain validation regex - allows wildcards (*.example.com)
|
||||
const domainRegex = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a route match configuration
|
||||
* @param match Route match configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteMatch(match: IRouteMatch): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Validate ports
|
||||
if (match.ports !== undefined) {
|
||||
if (!isValidPort(match.ports)) {
|
||||
errors.push('Invalid port number or port range in match.ports');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domains
|
||||
if (match.domains !== undefined) {
|
||||
if (typeof match.domains === 'string') {
|
||||
if (!isValidDomain(match.domains)) {
|
||||
errors.push(`Invalid domain format: ${match.domains}`);
|
||||
}
|
||||
} else if (Array.isArray(match.domains)) {
|
||||
for (const domain of match.domains) {
|
||||
if (!isValidDomain(domain)) {
|
||||
errors.push(`Invalid domain format: ${domain}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push('Domains must be a string or an array of strings');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate path
|
||||
if (match.path !== undefined) {
|
||||
if (typeof match.path !== 'string' || !match.path.startsWith('/')) {
|
||||
errors.push('Path must be a string starting with /');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a route action configuration
|
||||
* @param action Route action configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteAction(action: IRouteAction): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Validate action type
|
||||
if (!action.type) {
|
||||
errors.push('Action type is required');
|
||||
} else if (!['forward', 'socket-handler'].includes(action.type)) {
|
||||
errors.push(`Invalid action type: ${action.type}`);
|
||||
}
|
||||
|
||||
// Validate targets for 'forward' action
|
||||
if (action.type === 'forward') {
|
||||
if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
|
||||
errors.push('Targets array is required for forward action');
|
||||
} else {
|
||||
// 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 (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
|
||||
if (action.tls) {
|
||||
if (!['passthrough', 'terminate', 'terminate-and-reencrypt'].includes(action.tls.mode)) {
|
||||
errors.push(`Invalid TLS mode: ${action.tls.mode}`);
|
||||
}
|
||||
|
||||
// For termination modes, validate certificate
|
||||
if (['terminate', 'terminate-and-reencrypt'].includes(action.tls.mode)) {
|
||||
if (action.tls.certificate !== 'auto' &&
|
||||
(!action.tls.certificate || !action.tls.certificate.key || !action.tls.certificate.cert)) {
|
||||
errors.push('Certificate must be "auto" or an object with key and cert properties');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate socket handler for 'socket-handler' action
|
||||
if (action.type === 'socket-handler') {
|
||||
if (!action.socketHandler) {
|
||||
errors.push('Socket handler function is required for socket-handler action');
|
||||
} else if (typeof action.socketHandler !== 'function') {
|
||||
errors.push('Socket handler must be a function');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a complete route configuration
|
||||
* @param route Route configuration to validate
|
||||
* @returns { valid: boolean, errors: string[] } Validation result
|
||||
*/
|
||||
export function validateRouteConfig(route: IRouteConfig): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for required properties
|
||||
if (!route.match) {
|
||||
errors.push('Route match configuration is required');
|
||||
}
|
||||
|
||||
if (!route.action) {
|
||||
errors.push('Route action configuration is required');
|
||||
}
|
||||
|
||||
// Validate match configuration
|
||||
if (route.match) {
|
||||
const matchValidation = validateRouteMatch(route.match);
|
||||
if (!matchValidation.valid) {
|
||||
errors.push(...matchValidation.errors.map(err => `Match: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate action configuration
|
||||
if (route.action) {
|
||||
const actionValidation = validateRouteAction(route.action);
|
||||
if (!actionValidation.valid) {
|
||||
errors.push(...actionValidation.errors.map(err => `Action: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the route has a unique identifier
|
||||
if (!route.id && !route.name) {
|
||||
errors.push('Route should have either an id or a name for identification');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of route configurations
|
||||
* @param routes Array of route configurations to validate
|
||||
* @returns { valid: boolean, errors: { index: number, errors: string[] }[] } Validation result
|
||||
*/
|
||||
export function validateRoutes(routes: IRouteConfig[]): {
|
||||
valid: boolean;
|
||||
errors: { index: number; errors: string[] }[]
|
||||
} {
|
||||
const results: { index: number; errors: string[] }[] = [];
|
||||
|
||||
routes.forEach((route, index) => {
|
||||
const validation = validateRouteConfig(route);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
index,
|
||||
errors: validation.errors
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: results.length === 0,
|
||||
errors: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route configuration has the required properties for a specific action type
|
||||
* @param route Route configuration to check
|
||||
* @param actionType Expected action type
|
||||
* @returns True if the route has the necessary properties, false otherwise
|
||||
*/
|
||||
export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType: string): boolean {
|
||||
if (!route.action || route.action.type !== actionType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (actionType) {
|
||||
case 'forward':
|
||||
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:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if the route config is invalid, returns the config if valid
|
||||
* Useful for immediate validation when creating routes
|
||||
* @param route Route configuration to validate
|
||||
* @returns The validated route configuration
|
||||
* @throws Error if the route configuration is invalid
|
||||
*/
|
||||
export function assertValidRoute(route: IRouteConfig): IRouteConfig {
|
||||
const validation = validateRouteConfig(route);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid route configuration: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
Reference in New Issue
Block a user