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.
736 lines
24 KiB
TypeScript
736 lines
24 KiB
TypeScript
import { logger } from '../../../core/utils/logger.js';
|
|
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../models/route-types.js';
|
|
|
|
/**
|
|
* Validates route configurations for correctness and safety
|
|
*/
|
|
export class RouteValidator {
|
|
private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
|
|
private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
|
|
private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss'];
|
|
private static readonly MAX_PORTS = 100;
|
|
private static readonly MAX_DOMAINS = 1000;
|
|
private static readonly MAX_HEADER_SIZE = 8192;
|
|
|
|
/**
|
|
* Validate a single route configuration
|
|
*/
|
|
public static validateRoute(route: IRouteConfig): { valid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
|
|
// Validate route has a name
|
|
if (!route.name || typeof route.name !== 'string') {
|
|
errors.push('Route must have a valid name');
|
|
}
|
|
|
|
// Validate match criteria
|
|
if (!route.match) {
|
|
errors.push('Route must have match criteria');
|
|
} else {
|
|
// Validate ports
|
|
if (route.match.ports) {
|
|
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
|
|
|
if (ports.length > this.MAX_PORTS) {
|
|
errors.push(`Too many ports specified (max ${this.MAX_PORTS})`);
|
|
}
|
|
|
|
for (const port of ports) {
|
|
if (typeof port === 'number') {
|
|
if (!this.isValidPort(port)) {
|
|
errors.push(`Invalid port: ${port}. Must be between 1 and 65535`);
|
|
}
|
|
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
|
if (!this.isValidPort(port.from)) {
|
|
errors.push(`Invalid port range start: ${port.from}. Must be between 1 and 65535`);
|
|
}
|
|
if (!this.isValidPort(port.to)) {
|
|
errors.push(`Invalid port range end: ${port.to}. Must be between 1 and 65535`);
|
|
}
|
|
if (port.from > port.to) {
|
|
errors.push(`Invalid port range: ${port.from}-${port.to} (start > end)`);
|
|
}
|
|
} else {
|
|
errors.push(`Invalid port configuration: ${JSON.stringify(port)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate domains
|
|
if (route.match.domains) {
|
|
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
|
|
|
if (domains.length > this.MAX_DOMAINS) {
|
|
errors.push(`Too many domains specified (max ${this.MAX_DOMAINS})`);
|
|
}
|
|
|
|
for (const domain of domains) {
|
|
if (!this.isValidDomain(domain)) {
|
|
errors.push(`Invalid domain pattern: ${domain}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate paths
|
|
if (route.match.path) {
|
|
const paths = Array.isArray(route.match.path) ? route.match.path : [route.match.path];
|
|
|
|
for (const path of paths) {
|
|
if (!this.isValidPath(path)) {
|
|
errors.push(`Invalid path pattern: ${path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate client IPs
|
|
if (route.match.clientIp) {
|
|
const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp];
|
|
|
|
for (const ip of ips) {
|
|
if (!this.isValidIPPattern(ip)) {
|
|
errors.push(`Invalid IP pattern: ${ip}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate headers
|
|
if (route.match.headers) {
|
|
for (const [key, value] of Object.entries(route.match.headers)) {
|
|
if (key.length > 256) {
|
|
errors.push(`Header name too long: ${key}`);
|
|
}
|
|
|
|
const headerValue = String(value);
|
|
if (headerValue.length > this.MAX_HEADER_SIZE) {
|
|
errors.push(`Header value too long for ${key} (max ${this.MAX_HEADER_SIZE} bytes)`);
|
|
}
|
|
|
|
if (!/^[\x20-\x7E]+$/.test(key)) {
|
|
errors.push(`Invalid header name: ${key} (must be printable ASCII)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Protocol validation removed - not part of IRouteMatch interface
|
|
}
|
|
|
|
// Validate action
|
|
if (!route.action) {
|
|
errors.push('Route must have an action');
|
|
} else {
|
|
// Validate action type
|
|
if (!route.action.type || !this.VALID_ACTION_TYPES.includes(route.action.type)) {
|
|
errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`);
|
|
}
|
|
|
|
// Validate socket-handler
|
|
if (route.action.type === 'socket-handler') {
|
|
if (typeof route.action.socketHandler !== 'function') {
|
|
errors.push('socket-handler action requires a socketHandler function');
|
|
}
|
|
}
|
|
|
|
// Validate forward target
|
|
if (route.action.type === 'forward') {
|
|
if (!route.action.targets || route.action.targets.length === 0) {
|
|
errors.push('Forward action must have at least one target');
|
|
} else {
|
|
for (const target of route.action.targets) {
|
|
if (!target.host) {
|
|
errors.push('Target must have a host');
|
|
} else if (typeof target.host !== 'string' && !Array.isArray(target.host) && typeof target.host !== 'function') {
|
|
errors.push('Target host must be a string, array of strings, or function');
|
|
}
|
|
|
|
if (target.port) {
|
|
if (typeof target.port === 'number' && !this.isValidPort(target.port)) {
|
|
errors.push(`Invalid target port: ${target.port}`);
|
|
} else if (target.port !== 'preserve' && typeof target.port !== 'function' && typeof target.port !== 'number') {
|
|
errors.push(`Invalid target port configuration: ${target.port}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate TLS settings
|
|
if (route.action.tls) {
|
|
if (route.action.tls.mode && !this.VALID_TLS_MODES.includes(route.action.tls.mode)) {
|
|
errors.push(`Invalid TLS mode: ${route.action.tls.mode}. Must be one of: ${this.VALID_TLS_MODES.join(', ')}`);
|
|
}
|
|
|
|
if (route.action.tls.certificate) {
|
|
if (route.action.tls.certificate !== 'auto' && typeof route.action.tls.certificate !== 'object') {
|
|
errors.push('TLS certificate must be "auto" or a certificate configuration object');
|
|
}
|
|
}
|
|
|
|
if (route.action.tls.versions) {
|
|
for (const version of route.action.tls.versions) {
|
|
if (!['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'].includes(version)) {
|
|
errors.push(`Invalid TLS version: ${version}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate security settings
|
|
if (route.security) {
|
|
// Validate IP allow/block lists
|
|
if (route.security.ipAllowList) {
|
|
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
|
|
|
for (const ip of allowList) {
|
|
if (!this.isValidIPPattern(ip)) {
|
|
errors.push(`Invalid IP pattern in allow list: ${ip}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (route.security.ipBlockList) {
|
|
const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList];
|
|
|
|
for (const ip of blockList) {
|
|
if (!this.isValidIPPattern(ip)) {
|
|
errors.push(`Invalid IP pattern in block list: ${ip}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate rate limits
|
|
if (route.security.rateLimit) {
|
|
if (route.security.rateLimit.maxRequests && route.security.rateLimit.maxRequests < 0) {
|
|
errors.push('Rate limit maxRequests must be positive');
|
|
}
|
|
|
|
if (route.security.rateLimit.window && route.security.rateLimit.window < 0) {
|
|
errors.push('Rate limit window must be positive');
|
|
}
|
|
}
|
|
|
|
// Validate connection limits
|
|
if (route.security.maxConnections && route.security.maxConnections < 0) {
|
|
errors.push('Max connections must be positive');
|
|
}
|
|
}
|
|
|
|
// Validate priority
|
|
if (route.priority !== undefined && (route.priority < 0 || route.priority > 10000)) {
|
|
errors.push('Priority must be between 0 and 10000');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate multiple route configurations
|
|
*/
|
|
public static validateRoutes(routes: IRouteConfig[]): { valid: boolean; errors: Map<string, string[]> } {
|
|
const errorMap = new Map<string, string[]>();
|
|
let valid = true;
|
|
|
|
// Check for duplicate route names
|
|
const routeNames = new Set<string>();
|
|
for (const route of routes) {
|
|
if (route.name && routeNames.has(route.name)) {
|
|
const existingErrors = errorMap.get(route.name) || [];
|
|
existingErrors.push('Duplicate route name');
|
|
errorMap.set(route.name, existingErrors);
|
|
valid = false;
|
|
}
|
|
routeNames.add(route.name);
|
|
}
|
|
|
|
// Validate each route
|
|
for (const route of routes) {
|
|
const result = this.validateRoute(route);
|
|
if (!result.valid) {
|
|
errorMap.set(route.name || 'unnamed', result.errors);
|
|
valid = false;
|
|
}
|
|
}
|
|
|
|
// Check for conflicting routes
|
|
const conflicts = this.findRouteConflicts(routes);
|
|
if (conflicts.length > 0) {
|
|
for (const conflict of conflicts) {
|
|
const existingErrors = errorMap.get(conflict.route) || [];
|
|
existingErrors.push(conflict.message);
|
|
errorMap.set(conflict.route, existingErrors);
|
|
}
|
|
valid = false;
|
|
}
|
|
|
|
return { valid, errors: errorMap };
|
|
}
|
|
|
|
/**
|
|
* Find potential conflicts between routes
|
|
*/
|
|
private static findRouteConflicts(routes: IRouteConfig[]): Array<{ route: string; message: string }> {
|
|
const conflicts: Array<{ route: string; message: string }> = [];
|
|
|
|
// Group routes by port
|
|
const portMap = new Map<number, IRouteConfig[]>();
|
|
|
|
for (const route of routes) {
|
|
if (route.match?.ports) {
|
|
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
|
|
|
// Expand port ranges to individual ports
|
|
const expandedPorts: number[] = [];
|
|
for (const port of ports) {
|
|
if (typeof port === 'number') {
|
|
expandedPorts.push(port);
|
|
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
|
for (let p = port.from; p <= port.to; p++) {
|
|
expandedPorts.push(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const port of expandedPorts) {
|
|
const routesOnPort = portMap.get(port) || [];
|
|
routesOnPort.push(route);
|
|
portMap.set(port, routesOnPort);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for conflicting catch-all routes on the same port
|
|
for (const [port, routesOnPort] of portMap) {
|
|
const catchAllRoutes = routesOnPort.filter(r =>
|
|
!r.match.domains ||
|
|
(Array.isArray(r.match.domains) && r.match.domains.includes('*')) ||
|
|
r.match.domains === '*'
|
|
);
|
|
|
|
if (catchAllRoutes.length > 1) {
|
|
for (const route of catchAllRoutes) {
|
|
conflicts.push({
|
|
route: route.name,
|
|
message: `Multiple catch-all routes on port ${port}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* Validate port number
|
|
*/
|
|
private static isValidPort(port: number): boolean {
|
|
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
}
|
|
|
|
/**
|
|
* Validate domain pattern
|
|
*/
|
|
private static isValidDomain(domain: string): boolean {
|
|
if (!domain || typeof domain !== 'string') return false;
|
|
if (domain === '*') return true;
|
|
if (domain === 'localhost') return true;
|
|
|
|
// 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));
|
|
}
|
|
|
|
/**
|
|
* Validate path pattern
|
|
*/
|
|
private static isValidPath(path: string): boolean {
|
|
if (!path || typeof path !== 'string') return false;
|
|
if (!path.startsWith('/')) return false;
|
|
|
|
// Check for invalid characters
|
|
if (!/^[a-zA-Z0-9/_*:{}.-]+$/.test(path)) return false;
|
|
|
|
// Validate parameter syntax
|
|
const paramPattern = /\{[a-zA-Z_][a-zA-Z0-9_]*\}/g;
|
|
const params = path.match(paramPattern) || [];
|
|
|
|
for (const param of params) {
|
|
if (param.length > 32) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Validate IP pattern
|
|
*/
|
|
private static isValidIPPattern(ip: string): boolean {
|
|
if (!ip || typeof ip !== 'string') return false;
|
|
if (ip === '*') return true;
|
|
|
|
// Check for CIDR notation
|
|
if (ip.includes('/')) {
|
|
const [addr, prefix] = ip.split('/');
|
|
const prefixNum = parseInt(prefix, 10);
|
|
|
|
if (addr.includes(':')) {
|
|
// IPv6 CIDR
|
|
return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128;
|
|
} else {
|
|
// IPv4 CIDR
|
|
return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32;
|
|
}
|
|
}
|
|
|
|
// Check for range
|
|
if (ip.includes('-')) {
|
|
const [start, end] = ip.split('-');
|
|
return (this.isValidIPv4(start) && this.isValidIPv4(end)) ||
|
|
(this.isValidIPv6(start) && this.isValidIPv6(end));
|
|
}
|
|
|
|
// Check for wildcards in IPv4
|
|
if (ip.includes('*') && !ip.includes(':')) {
|
|
const parts = ip.split('.');
|
|
// 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;
|
|
if (part !== '*' && parseInt(part, 10) > 255) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Regular IP address
|
|
return this.isValidIPv4(ip) || this.isValidIPv6(ip);
|
|
}
|
|
|
|
/**
|
|
* Validate IPv4 address
|
|
*/
|
|
private static isValidIPv4(ip: string): boolean {
|
|
const parts = ip.split('.');
|
|
if (parts.length !== 4) return false;
|
|
|
|
for (const part of parts) {
|
|
const num = parseInt(part, 10);
|
|
if (isNaN(num) || num < 0 || num > 255) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Validate IPv6 address
|
|
*/
|
|
private static isValidIPv6(ip: string): boolean {
|
|
// Simple IPv6 validation
|
|
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::)$/;
|
|
return ipv6Pattern.test(ip);
|
|
}
|
|
|
|
/**
|
|
* Log validation errors
|
|
*/
|
|
public static logValidationErrors(errors: Map<string, string[]>): void {
|
|
for (const [routeName, routeErrors] of errors) {
|
|
logger.log('error', `Route validation failed for ${routeName}:`, {
|
|
route: routeName,
|
|
errors: routeErrors,
|
|
component: 'route-validator'
|
|
});
|
|
|
|
for (const error of routeErrors) {
|
|
logger.log('error', ` - ${error}`, {
|
|
route: routeName,
|
|
component: 'route-validator'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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;
|
|
} |