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.
This commit is contained in:
11
changelog.md
11
changelog.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
Relax domain validation to accept 'localhost', prefix wildcards (e.g. *example.com) and IP literals; add comprehensive domain validation tests
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^3.1.2",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tstest": "^3.1.3",
|
"@git.zone/tstest": "^3.1.3",
|
||||||
|
"@push.rocks/smartserve": "^1.4.0",
|
||||||
"@types/node": "^24.10.2",
|
"@types/node": "^24.10.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"why-is-node-running": "^3.2.2"
|
"why-is-node-running": "^3.2.2"
|
||||||
|
|||||||
2777
pnpm-lock.yaml
generated
2777
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ import {
|
|||||||
isValidPort,
|
isValidPort,
|
||||||
hasRequiredPropertiesForAction,
|
hasRequiredPropertiesForAction,
|
||||||
assertValidRoute
|
assertValidRoute
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-validators.js';
|
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createHttpRoute,
|
createHttpRoute,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
validateRouteAction,
|
validateRouteAction,
|
||||||
hasRequiredPropertiesForAction,
|
hasRequiredPropertiesForAction,
|
||||||
assertValidRoute
|
assertValidRoute
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-validators.js';
|
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
// Route utilities
|
// Route utilities
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '21.1.7',
|
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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export class HttpProxy implements IMetricsTracker {
|
|||||||
// For SmartProxy connections, wait for CLIENT_IP header
|
// For SmartProxy connections, wait for CLIENT_IP header
|
||||||
if (isFromSmartProxy) {
|
if (isFromSmartProxy) {
|
||||||
const MAX_PREFACE = 256; // bytes - prevent DoS
|
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 headerTimer: NodeJS.Timeout | undefined;
|
||||||
let buffered = Buffer.alloc(0);
|
let buffered = Buffer.alloc(0);
|
||||||
|
|
||||||
|
|||||||
@@ -76,22 +76,30 @@ export class NfTablesProxy {
|
|||||||
|
|
||||||
// Register cleanup handlers if deleteOnExit is true
|
// Register cleanup handlers if deleteOnExit is true
|
||||||
if (this.settings.deleteOnExit) {
|
if (this.settings.deleteOnExit) {
|
||||||
const cleanup = () => {
|
// Synchronous cleanup for 'exit' event (only sync code runs here)
|
||||||
|
const syncCleanup = () => {
|
||||||
try {
|
try {
|
||||||
this.stopSync();
|
this.stopSync();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log('error', 'Error cleaning nftables rules on exit:', { error: err.message });
|
this.log('error', 'Error cleaning nftables rules on exit:', { error: err.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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', () => {
|
process.on('SIGINT', () => {
|
||||||
cleanup();
|
asyncCleanup().finally(() => process.exit());
|
||||||
process.exit();
|
|
||||||
});
|
});
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
cleanup();
|
asyncCleanup().finally(() => process.exit());
|
||||||
process.exit();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,37 +227,17 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute system command synchronously with multiple attempts
|
* Execute system command synchronously (single attempt, no retry)
|
||||||
* @deprecated This method blocks the event loop and should be avoided. Use executeWithRetry instead.
|
* Used only for exit handlers where the process is terminating anyway.
|
||||||
* WARNING: This method contains a busy wait loop that will block the entire Node.js event loop!
|
* For normal operations, use the async executeWithRetry method.
|
||||||
*/
|
*/
|
||||||
private executeWithRetrySync(command: string, maxRetries = 3, retryDelayMs = 1000): string {
|
private executeSync(command: string): string {
|
||||||
// Log deprecation warning
|
try {
|
||||||
console.warn('[DEPRECATION WARNING] executeWithRetrySync blocks the event loop and should not be used. Consider using the async executeWithRetry method instead.');
|
return execSync(command, { timeout: 5000 }).toString();
|
||||||
|
} catch (err) {
|
||||||
let lastError: Error | undefined;
|
this.log('warn', `Sync command failed: ${command}`, { error: err.message });
|
||||||
|
throw err;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1649,67 +1637,66 @@ 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 {
|
public stopSync(): void {
|
||||||
try {
|
try {
|
||||||
let rulesetContent = '';
|
let rulesetContent = '';
|
||||||
|
|
||||||
// Process rules in reverse order (LIFO)
|
// Process rules in reverse order (LIFO)
|
||||||
for (let i = this.rules.length - 1; i >= 0; i--) {
|
for (let i = this.rules.length - 1; i >= 0; i--) {
|
||||||
const rule = this.rules[i];
|
const rule = this.rules[i];
|
||||||
|
|
||||||
if (rule.added) {
|
if (rule.added) {
|
||||||
// Create delete rules by replacing 'add' with 'delete'
|
// Create delete rules by replacing 'add' with 'delete'
|
||||||
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
|
const deleteRule = rule.ruleContents.replace('add rule', 'delete rule');
|
||||||
rulesetContent += `${deleteRule}\n`;
|
rulesetContent += `${deleteRule}\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the ruleset if we have any rules to delete
|
// Apply the ruleset if we have any rules to delete
|
||||||
if (rulesetContent) {
|
if (rulesetContent) {
|
||||||
// Write to temporary file
|
// Write to temporary file
|
||||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||||
|
|
||||||
// Apply the ruleset
|
// Apply the ruleset (single attempt, no retry - process is exiting)
|
||||||
this.executeWithRetrySync(
|
this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`);
|
||||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
|
||||||
|
|
||||||
this.log('info', 'Removed all added rules');
|
this.log('info', 'Removed all added rules');
|
||||||
|
|
||||||
// Mark all rules as removed
|
// Mark all rules as removed
|
||||||
this.rules.forEach(rule => {
|
this.rules.forEach(rule => {
|
||||||
rule.added = false;
|
rule.added = false;
|
||||||
rule.verified = false;
|
rule.verified = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove temporary file
|
// 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
|
// Clean up IP sets if we created any
|
||||||
if (this.settings.useIPSets && this.ipSets.size > 0) {
|
if (this.settings.useIPSets && this.ipSets.size > 0) {
|
||||||
for (const [key, _] of this.ipSets) {
|
for (const [key, _] of this.ipSets) {
|
||||||
const [family, setName] = key.split(':');
|
const [family, setName] = key.split(':');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.executeWithRetrySync(
|
this.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`,
|
`${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Non-critical error, continue
|
// Non-critical error, continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally clean up tables if they're empty (sync version)
|
// Optionally clean up tables if they're empty (sync version)
|
||||||
this.cleanupEmptyTablesSync();
|
this.cleanupEmptyTablesSync();
|
||||||
|
|
||||||
this.log('info', 'NfTablesProxy stopped successfully');
|
this.log('info', 'NfTablesProxy stopped successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log('error', `Error stopping NfTablesProxy: ${err.message}`);
|
this.log('error', `Error stopping NfTablesProxy: ${err.message}`);
|
||||||
@@ -1760,7 +1747,7 @@ export class NfTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous version of cleanupEmptyTables
|
* Synchronous version of cleanupEmptyTables (for exit handlers only)
|
||||||
*/
|
*/
|
||||||
private cleanupEmptyTablesSync(): void {
|
private cleanupEmptyTablesSync(): void {
|
||||||
// Check if tables are empty, and if so, delete them
|
// Check if tables are empty, and if so, delete them
|
||||||
@@ -1769,38 +1756,32 @@ export class NfTablesProxy {
|
|||||||
if (family === 'ip6' && !this.settings.ipv6Support) {
|
if (family === 'ip6' && !this.settings.ipv6Support) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if table exists
|
// Check if table exists
|
||||||
const tableExistsOutput = this.executeWithRetrySync(
|
const tableExistsOutput = this.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} list tables ${family}`,
|
`${NfTablesProxy.NFT_CMD} list tables ${family}`
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableExists = tableExistsOutput.includes(`table ${family} ${this.tableName}`);
|
const tableExists = tableExistsOutput.includes(`table ${family} ${this.tableName}`);
|
||||||
|
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the table has any rules
|
// Check if the table has any rules
|
||||||
const stdout = this.executeWithRetrySync(
|
const stdout = this.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasRules = stdout.includes('rule');
|
const hasRules = stdout.includes('rule');
|
||||||
|
|
||||||
if (!hasRules) {
|
if (!hasRules) {
|
||||||
// Table is empty, delete it
|
// Table is empty, delete it
|
||||||
this.executeWithRetrySync(
|
this.executeSync(
|
||||||
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`,
|
`${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`
|
||||||
this.settings.maxRetries,
|
|
||||||
this.settings.retryDelayMs
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.log('info', `Deleted empty table ${family} ${this.tableName}`);
|
this.log('info', `Deleted empty table ${family} ${this.tableName}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -389,12 +389,13 @@ export class SmartCertManager {
|
|||||||
let cert: string = certConfig.cert;
|
let cert: string = certConfig.cert;
|
||||||
|
|
||||||
// Load from files if paths are provided
|
// Load from files if paths are provided
|
||||||
|
const smartFileFactory = plugins.smartfile.SmartFileFactory.nodeFs();
|
||||||
if (certConfig.keyFile) {
|
if (certConfig.keyFile) {
|
||||||
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.keyFile);
|
const keyFile = await smartFileFactory.fromFilePath(certConfig.keyFile);
|
||||||
key = keyFile.contents.toString();
|
key = keyFile.contents.toString();
|
||||||
}
|
}
|
||||||
if (certConfig.certFile) {
|
if (certConfig.certFile) {
|
||||||
const certFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.certFile);
|
const certFile = await smartFileFactory.fromFilePath(certConfig.certFile);
|
||||||
cert = certFile.contents.toString();
|
cert = certFile.contents.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,17 +109,46 @@ export class HttpProxyBridge {
|
|||||||
if (!this.httpProxy) {
|
if (!this.httpProxy) {
|
||||||
throw new Error('HttpProxy not initialized');
|
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();
|
const proxySocket = new plugins.net.Socket();
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
// Handle client disconnect during proxy connection setup
|
||||||
proxySocket.connect(httpProxyPort, 'localhost', () => {
|
const clientDisconnectHandler = () => {
|
||||||
console.log(`[${connectionId}] Connected to HttpProxy for termination`);
|
console.log(`[${connectionId}] Client disconnected during HttpProxy connection setup`);
|
||||||
resolve();
|
proxySocket.destroy();
|
||||||
|
cleanupCallback('client_disconnected_during_setup');
|
||||||
|
};
|
||||||
|
underlyingSocket.once('close', clientDisconnectHandler);
|
||||||
|
|
||||||
|
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 {
|
||||||
proxySocket.on('error', reject);
|
// 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)
|
// Send client IP information header first (custom protocol)
|
||||||
// Format: "CLIENT_IP:<ip>\r\n"
|
// Format: "CLIENT_IP:<ip>\r\n"
|
||||||
@@ -136,10 +165,7 @@ export class HttpProxyBridge {
|
|||||||
proxySocket.write(initialChunk);
|
proxySocket.write(initialChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use centralized bidirectional forwarding
|
// Use centralized bidirectional forwarding (underlyingSocket already extracted above)
|
||||||
// Extract underlying socket if it's a WrappedSocket
|
|
||||||
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
|
|
||||||
|
|
||||||
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
|
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
|
||||||
onClientData: (chunk) => {
|
onClientData: (chunk) => {
|
||||||
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
|
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
// Export route helpers for creating route configurations
|
// Export route helpers for creating route configurations
|
||||||
export * from './route-helpers.js';
|
export * from './route-helpers.js';
|
||||||
|
|
||||||
// Export route validators for validating route configurations
|
// Export route validator (class-based and functional API)
|
||||||
export * from './route-validators.js';
|
export * from './route-validator.js';
|
||||||
|
|
||||||
// Export route utilities for route operations
|
// Export route utilities for route operations
|
||||||
export * from './route-utils.js';
|
export * from './route-utils.js';
|
||||||
@@ -20,6 +20,4 @@ export {
|
|||||||
addRateLimiting,
|
addRateLimiting,
|
||||||
addBasicAuth,
|
addBasicAuth,
|
||||||
addJwtAuth
|
addJwtAuth
|
||||||
} from './route-helpers.js';
|
} 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 type { IRouteConfig, IRouteMatch } from '../models/route-types.js';
|
||||||
import { validateRouteConfig } from './route-validators.js';
|
import { validateRouteConfig } from './route-validator.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge two route configurations
|
* Merge two route configurations
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { logger } from '../../../core/utils/logger.js';
|
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
|
* Validates route configurations for correctness and safety
|
||||||
@@ -454,7 +454,7 @@ export class RouteValidator {
|
|||||||
errors: routeErrors,
|
errors: routeErrors,
|
||||||
component: 'route-validator'
|
component: 'route-validator'
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const error of routeErrors) {
|
for (const error of routeErrors) {
|
||||||
logger.log('error', ` - ${error}`, {
|
logger.log('error', ` - ${error}`, {
|
||||||
route: routeName,
|
route: routeName,
|
||||||
@@ -463,4 +463,274 @@ 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