This commit is contained in:
2025-05-10 13:59:34 +00:00
parent b17af3b81d
commit ffc8b22533
28 changed files with 2827 additions and 2366 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import type { TForwardingType } from '../../../forwarding/config/forwarding-type
/**
* Supported action types for route configurations
*/
export type TRouteActionType = 'forward' | 'redirect' | 'block';
export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static';
/**
* TLS handling modes for route configurations
@ -31,6 +31,7 @@ export interface IRouteMatch {
path?: string; // Match specific paths
clientIp?: string[]; // Match specific client IPs
tlsVersion?: string[]; // Match specific TLS versions
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
}
/**
@ -94,7 +95,10 @@ export interface IRouteSecurity {
* Static file server configuration
*/
export interface IRouteStaticFiles {
directory: string;
root: string;
index?: string[];
headers?: Record<string, string>;
directory?: string;
indexFiles?: string[];
cacheControl?: string;
expires?: number;
@ -123,6 +127,30 @@ export interface IRouteAdvanced {
// Additional advanced options would go here
}
/**
* WebSocket configuration
*/
export interface IRouteWebSocket {
enabled: boolean;
pingInterval?: number;
pingTimeout?: number;
maxPayloadSize?: number;
}
/**
* Load balancing configuration
*/
export interface IRouteLoadBalancing {
algorithm: 'round-robin' | 'least-connections' | 'ip-hash';
healthCheck?: {
path: string;
interval: number;
timeout: number;
unhealthyThreshold: number;
healthyThreshold: number;
};
}
/**
* Action configuration for route handling
*/
@ -139,6 +167,15 @@ export interface IRouteAction {
// For redirects
redirect?: IRouteRedirect;
// For static files
static?: IRouteStaticFiles;
// WebSocket support
websocket?: IRouteWebSocket;
// Load balancing options
loadBalancing?: IRouteLoadBalancing;
// Security options
security?: IRouteSecurity;
@ -146,21 +183,75 @@ export interface IRouteAction {
advanced?: IRouteAdvanced;
}
/**
* Rate limiting configuration
*/
export interface IRouteRateLimit {
enabled: boolean;
maxRequests: number;
window: number; // Time window in seconds
keyBy?: 'ip' | 'path' | 'header';
headerName?: string;
errorMessage?: string;
}
/**
* Security features for routes
*/
export interface IRouteSecurity {
rateLimit?: IRouteRateLimit;
basicAuth?: {
enabled: boolean;
users: Array<{ username: string; password: string }>;
realm?: string;
excludePaths?: string[];
};
jwtAuth?: {
enabled: boolean;
secret: string;
algorithm?: string;
issuer?: string;
audience?: string;
expiresIn?: number;
excludePaths?: string[];
};
ipAllowList?: string[];
ipBlockList?: string[];
}
/**
* Headers configuration
*/
export interface IRouteHeaders {
request?: Record<string, string>;
response?: Record<string, string>;
}
/**
* The core unified configuration interface
*/
export interface IRouteConfig {
// Unique identifier
id?: string;
// What to match
match: IRouteMatch;
// What to do with matched traffic
action: IRouteAction;
// Custom headers
headers?: IRouteHeaders;
// Security features
security?: IRouteSecurity;
// Optional metadata
name?: string; // Human-readable name for this route
description?: string; // Description of the route's purpose
priority?: number; // Controls matching order (higher = matched first)
tags?: string[]; // Arbitrary tags for categorization
enabled?: boolean; // Whether the route is active (default: true)
}
/**

View File

@ -11,7 +11,7 @@ import type { IRouteConfig } from './models/route-types.js';
* Manages NetworkProxy integration for TLS termination
*
* NetworkProxyBridge connects SmartProxy with NetworkProxy to handle TLS termination.
* It converts route configurations to NetworkProxy configuration format and manages
* It directly maps route configurations to NetworkProxy configuration format and manages
* certificate provisioning through Port80Handler when ACME is enabled.
*
* It is used by SmartProxy for routes that have:
@ -156,35 +156,90 @@ export class NetworkProxyBridge {
}
/**
* Register domains with Port80Handler
* Register domains from routes with Port80Handler for certificate management
*
* Extracts domains from routes that require TLS termination and registers them
* with the Port80Handler for certificate issuance and renewal.
*
* @param routes The route configurations to extract domains from
*/
public registerDomainsWithPort80Handler(domains: string[]): void {
public registerDomainsWithPort80Handler(routes: IRouteConfig[]): void {
if (!this.port80Handler) {
console.log('Cannot register domains - Port80Handler not initialized');
return;
}
for (const domain of domains) {
// Skip wildcards
if (domain.includes('*')) {
console.log(`Skipping wildcard domain for ACME: ${domain}`);
continue;
// Extract domains from routes that require TLS termination
const domainsToRegister = new Set<string>();
for (const route of routes) {
// Skip routes without domains or TLS configuration
if (!route.match.domains || !route.action.tls) continue;
// Only register domains for routes that terminate TLS
if (route.action.tls.mode !== 'terminate' && route.action.tls.mode !== 'terminate-and-reencrypt') continue;
// Extract domains from route
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Add each domain to the set (avoiding duplicates)
for (const domain of domains) {
// Skip wildcards
if (domain.includes('*')) {
console.log(`Skipping wildcard domain for ACME: ${domain}`);
continue;
}
domainsToRegister.add(domain);
}
// Register the domain
}
// Register each unique domain with Port80Handler
for (const domain of domainsToRegister) {
try {
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
acmeMaintenance: true,
// Include route reference if we can find it
routeReference: this.findRouteReferenceForDomain(domain, routes)
});
console.log(`Registered domain with Port80Handler: ${domain}`);
} catch (err) {
console.log(`Error registering domain ${domain} with Port80Handler: ${err}`);
}
}
}
/**
* Finds the route reference for a given domain
*
* @param domain The domain to find a route reference for
* @param routes The routes to search
* @returns The route reference if found, undefined otherwise
*/
private findRouteReferenceForDomain(domain: string, routes: IRouteConfig[]): { routeId?: string; routeName?: string } | undefined {
// Find the first route that matches this domain
for (const route of routes) {
if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.includes(domain)) {
return {
routeId: undefined, // No explicit IDs in our current routes
routeName: route.name
};
}
}
return undefined;
}
/**
* Forwards a TLS connection to a NetworkProxy for handling
@ -260,8 +315,8 @@ export class NetworkProxyBridge {
/**
* Synchronizes routes to NetworkProxy
*
* This method converts route configurations to NetworkProxy format and updates
* the NetworkProxy with the converted configurations. It handles:
* This method directly maps route configurations to NetworkProxy format and updates
* the NetworkProxy with these configurations. It handles:
*
* - Extracting domain, target, and certificate information from routes
* - Converting TLS mode settings to NetworkProxy configuration
@ -281,9 +336,9 @@ export class NetworkProxyBridge {
// Import fs directly since it's not in plugins
const fs = await import('fs');
let certPair;
let defaultCertPair;
try {
certPair = {
defaultCertPair = {
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
};
@ -295,35 +350,40 @@ export class NetworkProxyBridge {
// Use empty placeholders - NetworkProxy will use its internal defaults
// or ACME will generate proper ones if enabled
certPair = {
defaultCertPair = {
key: '',
cert: '',
};
}
// Convert routes to NetworkProxy configs
const proxyConfigs = this.convertRoutesToNetworkProxyConfigs(routes, certPair);
// Map routes directly to NetworkProxy configs
const proxyConfigs = this.mapRoutesToNetworkProxyConfigs(routes, defaultCertPair);
// Update the proxy configs
await this.networkProxy.updateProxyConfigs(proxyConfigs);
console.log(`Synced ${proxyConfigs.length} configurations to NetworkProxy`);
// Register domains with Port80Handler for certificate issuance
if (this.port80Handler) {
this.registerDomainsWithPort80Handler(routes);
}
} catch (err) {
console.log(`Error syncing routes to NetworkProxy: ${err}`);
}
}
/**
* Convert routes to NetworkProxy configuration format
* Map routes directly to NetworkProxy configuration format
*
* This method transforms route-based configuration to NetworkProxy's configuration format.
* It processes each route and creates appropriate NetworkProxy configs for domains
* that require TLS termination.
* This method directly maps route configurations to NetworkProxy's format
* without any intermediate domain-based representation. It processes each route
* and creates appropriate NetworkProxy configs for domains that require TLS termination.
*
* @param routes Array of route configurations to convert
* @param routes Array of route configurations to map
* @param defaultCertPair Default certificate to use if no custom certificate is specified
* @returns Array of NetworkProxy configurations
*/
public convertRoutesToNetworkProxyConfigs(
public mapRoutesToNetworkProxyConfigs(
routes: IRouteConfig[],
defaultCertPair: { key: string; cert: string }
): plugins.tsclass.network.IReverseProxyConfig[] {
@ -339,6 +399,9 @@ export class NetworkProxyBridge {
// Skip routes without TLS configuration
if (!route.action.tls || !route.action.target) continue;
// Skip routes that don't require TLS termination
if (route.action.tls.mode !== 'terminate' && route.action.tls.mode !== 'terminate-and-reencrypt') continue;
// Get domains from route
const domains = Array.isArray(route.match.domains)
? route.match.domains
@ -346,13 +409,6 @@ export class NetworkProxyBridge {
// Create a config for each domain
for (const domain of domains) {
// Determine if this route requires TLS termination
const needsTermination = route.action.tls.mode === 'terminate' ||
route.action.tls.mode === 'terminate-and-reencrypt';
// Skip passthrough domains for NetworkProxy
if (route.action.tls.mode === 'passthrough') continue;
// Get certificate
let certKey = defaultCertPair.key;
let certCert = defaultCertPair.cert;
@ -370,14 +426,14 @@ export class NetworkProxyBridge {
const targetPort = route.action.target.port;
// Create NetworkProxy config
// Create the NetworkProxy config
const config: plugins.tsclass.network.IReverseProxyConfig = {
hostName: domain,
privateKey: certKey,
publicKey: certCert,
destinationIps: targetHosts,
destinationPorts: [targetPort],
// Headers handling happens in the request handler level
destinationPorts: [targetPort]
// Note: We can't include additional metadata as it's not supported in the interface
};
configs.push(config);
@ -387,6 +443,17 @@ export class NetworkProxyBridge {
return configs;
}
/**
* @deprecated This method is kept for backward compatibility.
* Use mapRoutesToNetworkProxyConfigs() instead.
*/
public convertRoutesToNetworkProxyConfigs(
routes: IRouteConfig[],
defaultCertPair: { key: string; cert: string }
): plugins.tsclass.network.IReverseProxyConfig[] {
return this.mapRoutesToNetworkProxyConfigs(routes, defaultCertPair);
}
/**
* @deprecated This method is deprecated and will be removed in a future version.
* Use syncRoutesToNetworkProxy() instead.
@ -395,14 +462,18 @@ export class NetworkProxyBridge {
* simply forwards to syncRoutesToNetworkProxy().
*/
public async syncDomainConfigsToNetworkProxy(): Promise<void> {
console.log('Method syncDomainConfigsToNetworkProxy is deprecated. Use syncRoutesToNetworkProxy instead.');
console.log('DEPRECATED: Method syncDomainConfigsToNetworkProxy will be removed in a future version.');
console.log('Please use syncRoutesToNetworkProxy() instead for direct route-based configuration.');
await this.syncRoutesToNetworkProxy(this.settings.routes || []);
}
/**
* Request a certificate for a specific domain
*
* @param domain The domain to request a certificate for
* @param routeName Optional route name to associate with this certificate
*/
public async requestCertificate(domain: string): Promise<boolean> {
public async requestCertificate(domain: string, routeName?: string): Promise<boolean> {
// Delegate to Port80Handler if available
if (this.port80Handler) {
try {
@ -412,14 +483,30 @@ export class NetworkProxyBridge {
console.log(`Certificate already exists for ${domain}`);
return true;
}
// Register the domain for certificate issuance
this.port80Handler.addDomain({
// Build the domain options
const domainOptions: any = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
acmeMaintenance: true,
};
// Add route reference if available
if (routeName) {
domainOptions.routeReference = {
routeName
};
} else {
// Try to find a route reference from the current routes
const routeReference = this.findRouteReferenceForDomain(domain, this.settings.routes || []);
if (routeReference) {
domainOptions.routeReference = routeReference;
}
}
// Register the domain for certificate issuance
this.port80Handler.addDomain(domainOptions);
console.log(`Domain ${domain} registered for certificate issuance`);
return true;
} catch (err) {
@ -427,7 +514,7 @@ export class NetworkProxyBridge {
return false;
}
}
// Fall back to NetworkProxy if Port80Handler is not available
if (!this.networkProxy) {
console.log('Cannot request certificate - NetworkProxy not initialized');

View File

@ -1,211 +0,0 @@
import type { ISmartProxyOptions } from './models/interfaces.js';
/**
* Manages port ranges and port-based configuration
*/
export class PortRangeManager {
constructor(private settings: ISmartProxyOptions) {}
/**
* Get all ports that should be listened on
*/
public getListeningPorts(): Set<number> {
const listeningPorts = new Set<number>();
// Always include the main fromPort
listeningPorts.add(this.settings.fromPort);
// Add ports from global port ranges if defined
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
}
return listeningPorts;
}
/**
* Check if a port should use NetworkProxy for forwarding
*/
public shouldUseNetworkProxy(port: number): boolean {
return !!this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(port);
}
/**
* Check if port should use global forwarding
*/
public shouldUseGlobalForwarding(port: number): boolean {
return (
!!this.settings.forwardAllGlobalRanges &&
this.isPortInGlobalRanges(port)
);
}
/**
* Check if a port is in global ranges
*/
public isPortInGlobalRanges(port: number): boolean {
return (
this.settings.globalPortRanges &&
this.isPortInRanges(port, this.settings.globalPortRanges)
);
}
/**
* Check if a port falls within the specified ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get forwarding port for a specific listening port
* This determines what port to connect to on the target
*/
public getForwardingPort(listeningPort: number): number {
// If using global forwarding, forward to the original port
if (this.settings.forwardAllGlobalRanges && this.isPortInGlobalRanges(listeningPort)) {
return listeningPort;
}
// Otherwise use the configured toPort
return this.settings.toPort;
}
/**
* Find domain-specific port ranges that include a given port
*/
public findDomainPortRange(port: number): {
domainIndex: number,
range: { from: number, to: number }
} | undefined {
for (let i = 0; i < this.settings.domainConfigs.length; i++) {
const domain = this.settings.domainConfigs[i];
// Get port ranges from forwarding.advanced if available
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
if (port >= range.from && port <= range.to) {
return { domainIndex: i, range };
}
}
}
}
return undefined;
}
/**
* Get a list of all configured ports
* This includes the fromPort, NetworkProxy ports, and ports from all ranges
*/
public getAllConfiguredPorts(): number[] {
const ports = new Set<number>();
// Add main listening port
ports.add(this.settings.fromPort);
// Add NetworkProxy port if configured
if (this.settings.networkProxyPort) {
ports.add(this.settings.networkProxyPort);
}
// Add NetworkProxy ports
if (this.settings.useNetworkProxy) {
for (const port of this.settings.useNetworkProxy) {
ports.add(port);
}
}
// Add global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
// Get port ranges from forwarding.advanced
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific NetworkProxy port if configured in forwarding.advanced
const networkProxyPort = domain.forwarding?.advanced?.networkProxyPort;
if (networkProxyPort) {
ports.add(networkProxyPort);
}
}
return Array.from(ports);
}
/**
* Validate port configuration
* Returns array of warning messages
*/
public validateConfiguration(): string[] {
const warnings: string[] = [];
// Check for overlapping port ranges
const portMappings = new Map<number, string[]>();
// Track global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push('Global Port Range');
}
}
}
// Track domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
// Get port ranges from forwarding.advanced
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push(`Domain: ${domain.domains.join(', ')}`);
}
}
}
}
// Check for ports with multiple mappings
for (const [port, mappings] of portMappings.entries()) {
if (mappings.length > 1) {
warnings.push(`Port ${port} has multiple mappings: ${mappings.join(', ')}`);
}
}
// Check if main ports are used elsewhere
if (portMappings.has(this.settings.fromPort) && portMappings.get(this.settings.fromPort)!.length > 0) {
warnings.push(`Main listening port ${this.settings.fromPort} is also used in port ranges`);
}
if (this.settings.networkProxyPort && portMappings.has(this.settings.networkProxyPort)) {
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
}
return warnings;
}
}

View File

@ -436,8 +436,9 @@ export function createStaticFileRoute(
advanced: {
...(options.headers ? { headers: options.headers } : {}),
staticFiles: {
directory: options.targetDirectory,
indexFiles: ['index.html', 'index.htm']
root: options.targetDirectory,
index: ['index.html', 'index.htm'],
directory: options.targetDirectory // For backward compatibility
}
},
...(options.security ? { security: options.security } : {})

View File

@ -135,7 +135,7 @@ export class SmartProxy extends plugins.EventEmitter {
skipConfiguredCerts: false,
httpsRedirectPort: 443,
renewCheckIntervalHours: 24,
domainForwards: []
routeForwards: []
};
}
@ -220,49 +220,8 @@ export class SmartProxy extends plugins.EventEmitter {
if (this.port80Handler) {
const acme = this.settings.acme!;
// Setup domain forwards
const domainForwards = acme.domainForwards?.map(f => {
// Check if a matching route exists
const matchingRoute = this.settings.routes.find(
route => Array.isArray(route.match.domains)
? route.match.domains.some(d => d === f.domain)
: route.match.domains === f.domain
);
if (matchingRoute) {
return {
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || false
};
} else {
// In route mode, look for matching route
const route = this.routeManager.findMatchingRoute({
port: 443,
domain: f.domain,
clientIp: '127.0.0.1' // Dummy IP for finding routes
})?.route;
if (route && route.action.type === 'forward' && route.action.tls) {
// If we found a matching route with TLS settings
return {
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || false
};
}
}
// Otherwise use the existing configuration
return {
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || false
};
}) || [];
// Setup route forwards
const routeForwards = acme.routeForwards?.map(f => f) || [];
// Create CertProvisioner with appropriate parameters
// No longer need to support multiple configuration types
@ -275,7 +234,7 @@ export class SmartProxy extends plugins.EventEmitter {
acme.renewThresholdDays!,
acme.renewCheckIntervalHours!,
acme.autoRenew!,
domainForwards
routeForwards
);
// Register certificate event handler
@ -527,65 +486,53 @@ export class SmartProxy extends plugins.EventEmitter {
// If Port80Handler is running, provision certificates based on routes
if (this.port80Handler && this.settings.acme?.enabled) {
for (const route of newRoutes) {
// Skip routes without domains
if (!route.match.domains) continue;
// Register all eligible domains from routes
this.port80Handler.addDomainsFromRoutes(newRoutes);
// Skip non-forward routes
if (route.action.type !== 'forward') continue;
// Handle static certificates from certProvisionFunction if available
if (this.settings.certProvisionFunction) {
for (const route of newRoutes) {
// Skip routes without domains
if (!route.match.domains) continue;
// Skip routes without TLS termination
if (!route.action.tls ||
route.action.tls.mode === 'passthrough' ||
!route.action.target) continue;
// Skip non-forward routes
if (route.action.type !== 'forward') continue;
// Skip certificate provisioning if certificate is not auto
if (route.action.tls.certificate !== 'auto') continue;
// Skip routes without TLS termination
if (!route.action.tls ||
route.action.tls.mode === 'passthrough' ||
!route.action.target) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Skip certificate provisioning if certificate is not auto
if (route.action.tls.certificate !== 'auto') continue;
for (const domain of domains) {
const isWildcard = domain.includes('*');
let provision: string | plugins.tsclass.network.ICert = 'http01';
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (this.settings.certProvisionFunction) {
for (const domain of domains) {
try {
provision = await this.settings.certProvisionFunction(domain);
const provision = await this.settings.certProvisionFunction(domain);
// Skip http01 as those are handled by Port80Handler
if (provision !== 'http01') {
// Handle static certificate (e.g., DNS-01 provisioned)
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil),
routeReference: {
routeName: route.name
}
};
this.networkProxyBridge.applyExternalCertificate(certData);
console.log(`Applied static certificate for ${domain} from certProvider`);
}
} catch (err) {
console.log(`certProvider error for ${domain}: ${err}`);
}
} else if (isWildcard) {
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
continue;
}
if (provision === 'http01') {
if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
continue;
}
// Register domain with Port80Handler
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`);
} else {
// Handle static certificate (e.g., DNS-01 provisioned)
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
console.log(`Applied static certificate for ${domain} from certProvider`);
}
}
}
@ -596,14 +543,17 @@ export class SmartProxy extends plugins.EventEmitter {
/**
* Request a certificate for a specific domain
*
* @param domain The domain to request a certificate for
* @param routeName Optional route name to associate with the certificate
*/
public async requestCertificate(domain: string): Promise<boolean> {
public async requestCertificate(domain: string, routeName?: string): Promise<boolean> {
// Validate domain format
if (!this.isValidDomain(domain)) {
console.log(`Invalid domain format: ${domain}`);
return false;
}
// Use Port80Handler if available
if (this.port80Handler) {
try {
@ -613,15 +563,16 @@ export class SmartProxy extends plugins.EventEmitter {
console.log(`Certificate already exists for ${domain}, valid until ${cert.expiryDate.toISOString()}`);
return true;
}
// Register domain for certificate issuance
this.port80Handler.addDomain({
domainName: domain,
domain,
sslRedirect: true,
acmeMaintenance: true
acmeMaintenance: true,
routeReference: routeName ? { routeName } : undefined
});
console.log(`Domain ${domain} registered for certificate issuance`);
console.log(`Domain ${domain} registered for certificate issuance` + (routeName ? ` for route '${routeName}'` : ''));
return true;
} catch (err) {
console.log(`Error registering domain with Port80Handler: ${err}`);

View File

@ -0,0 +1,40 @@
/**
* SmartProxy Route Utilities
*
* This file exports all route-related utilities for the SmartProxy module,
* including helpers, validators, utilities, and patterns for working with routes.
*/
// Export route helpers for creating routes
export * from './route-helpers.js';
// Export route validators for validating route configurations
export * from './route-validators.js';
// Export route utilities for route operations
export * from './route-utils.js';
// Export route patterns with renamed exports to avoid conflicts
import {
createWebSocketRoute as createWebSocketPatternRoute,
createLoadBalancerRoute as createLoadBalancerPatternRoute,
createApiGatewayRoute,
createStaticFileServerRoute,
addRateLimiting,
addBasicAuth,
addJwtAuth
} from './route-patterns.js';
export {
createWebSocketPatternRoute,
createLoadBalancerPatternRoute,
createApiGatewayRoute,
createStaticFileServerRoute,
addRateLimiting,
addBasicAuth,
addJwtAuth
};
// Export migration utilities for transitioning from domain-based to route-based configs
// Note: These will be removed in a future version once migration is complete
export * from './route-migration-utils.js';

View File

@ -0,0 +1,455 @@
/**
* Route Helper Functions
*
* This file provides utility functions for creating route configurations for common scenarios.
* These functions aim to simplify the creation of route configurations for typical use cases.
*
* This module includes helper functions for creating:
* - HTTP routes (createHttpRoute)
* - HTTPS routes with TLS termination (createHttpsTerminateRoute)
* - HTTP to HTTPS redirects (createHttpToHttpsRedirect)
* - HTTPS passthrough routes (createHttpsPassthroughRoute)
* - Complete HTTPS servers with redirects (createCompleteHttpsServer)
* - Load balancer routes (createLoadBalancerRoute)
* - Static file server routes (createStaticFileRoute)
* - API routes (createApiRoute)
* - WebSocket routes (createWebSocketRoute)
*/
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange } from '../models/route-types.js';
/**
* Create an HTTP-only route configuration
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 80,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target
};
// Create the route config
return {
match,
action,
name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an HTTPS route with TLS termination (including HTTP redirect to HTTPS)
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpsTerminateRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: {
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
reencrypt?: boolean;
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.httpsPort || 443,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target,
tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto'
}
};
// Create the route config
return {
match,
action,
name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an HTTP to HTTPS redirect route
* @param domains Domain(s) to match
* @param httpsPort HTTPS port to redirect to (default: 443)
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpToHttpsRedirect(
domains: string | string[],
httpsPort: number = 443,
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 80,
domains
};
// Create route action
const action: IRouteAction = {
type: 'redirect',
redirect: {
to: `https://{domain}:${httpsPort}{path}`,
status: 301
}
};
// Create the route config
return {
match,
action,
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an HTTPS passthrough route (SNI-based forwarding without TLS termination)
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpsPassthroughRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 443,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target,
tls: {
mode: 'passthrough'
}
};
// Create the route config
return {
match,
action,
name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create a complete HTTPS server with HTTP to HTTPS redirects
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional configuration options
* @returns Array of two route configurations (HTTPS and HTTP redirect)
*/
export function createCompleteHttpsServer(
domains: string | string[],
target: { host: string | string[]; port: number },
options: {
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
reencrypt?: boolean;
name?: string;
[key: string]: any;
} = {}
): IRouteConfig[] {
// Create the HTTPS route
const httpsRoute = createHttpsTerminateRoute(domains, target, options);
// Create the HTTP redirect route
const httpRedirectRoute = createHttpToHttpsRedirect(
domains,
// Extract the HTTPS port from the HTTPS route - ensure it's a number
typeof options.httpsPort === 'number' ? options.httpsPort :
Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443,
{
// Set the HTTP port
match: {
ports: options.httpPort || 80,
domains
},
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`
}
);
return [httpsRoute, httpRedirectRoute];
}
/**
* Create a load balancer route (round-robin between multiple backend hosts)
* @param domains Domain(s) to match
* @param hosts Array of backend hosts to load balance between
* @param port Backend port
* @param options Additional route options
* @returns Route configuration object
*/
export function createLoadBalancerRoute(
domains: string | string[],
hosts: string[],
port: number,
options: {
tls?: {
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
};
[key: string]: any;
} = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || (options.tls ? 443 : 80),
domains
};
// Create route target
const target: IRouteTarget = {
host: hosts,
port
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target
};
// Add TLS configuration if provided
if (options.tls) {
action.tls = {
mode: options.tls.mode,
certificate: options.tls.certificate || 'auto'
};
}
// Create the route config
return {
match,
action,
name: options.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create a static file server route
* @param domains Domain(s) to match
* @param rootDir Root directory path for static files
* @param options Additional route options
* @returns Route configuration object
*/
export function createStaticFileRoute(
domains: string | string[],
rootDir: string,
options: {
indexFiles?: string[];
serveOnHttps?: boolean;
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.serveOnHttps
? (options.httpsPort || 443)
: (options.httpPort || 80),
domains
};
// Create route action
const action: IRouteAction = {
type: 'static',
static: {
root: rootDir,
index: options.indexFiles || ['index.html', 'index.htm']
}
};
// Add TLS configuration if serving on HTTPS
if (options.serveOnHttps) {
action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
}
// Create the route config
return {
match,
action,
name: options.name || `Static Files for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an API route configuration
* @param domains Domain(s) to match
* @param apiPath API base path (e.g., "/api")
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createApiRoute(
domains: string | string[],
apiPath: string,
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
addCorsHeaders?: boolean;
httpPort?: number | number[];
httpsPort?: number | number[];
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Normalize API path
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
const pathWithWildcard = normalizedPath.endsWith('/')
? `${normalizedPath}*`
: `${normalizedPath}/*`;
// Create route match
const match: IRouteMatch = {
ports: options.useTls
? (options.httpsPort || 443)
: (options.httpPort || 80),
domains,
path: pathWithWildcard
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target
};
// Add TLS configuration if using HTTPS
if (options.useTls) {
action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
}
// Add CORS headers if requested
const headers: Record<string, Record<string, string>> = {};
if (options.addCorsHeaders) {
headers.response = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
};
}
// Create the route config
return {
match,
action,
headers: Object.keys(headers).length > 0 ? headers : undefined,
name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: options.priority || 100, // Higher priority for specific path matches
...options
};
}
/**
* Create a WebSocket route configuration
* @param domains Domain(s) to match
* @param wsPath WebSocket path (e.g., "/ws")
* @param target Target WebSocket server host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createWebSocketRoute(
domains: string | string[],
wsPath: string,
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
pingInterval?: number;
pingTimeout?: number;
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Normalize WebSocket path
const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
// Create route match
const match: IRouteMatch = {
ports: options.useTls
? (options.httpsPort || 443)
: (options.httpPort || 80),
domains,
path: normalizedPath
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target,
websocket: {
enabled: true,
pingInterval: options.pingInterval || 30000, // 30 seconds
pingTimeout: options.pingTimeout || 5000 // 5 seconds
}
};
// Add TLS configuration if using HTTPS
if (options.useTls) {
action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
}
// Create the route config
return {
match,
action,
name: options.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: options.priority || 100, // Higher priority for WebSocket routes
...options
};
}

View File

@ -0,0 +1,165 @@
/**
* Route Migration Utilities
*
* This file provides utility functions for migrating from legacy domain-based
* configuration to the new route-based configuration system. These functions
* are temporary and will be removed after the migration is complete.
*/
import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js';
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget } from '../models/route-types.js';
/**
* Legacy domain config interface (for migration only)
* @deprecated This interface will be removed in a future version
*/
export interface ILegacyDomainConfig {
domains: string[];
forwarding: {
type: TForwardingType;
target: {
host: string | string[];
port: number;
};
[key: string]: any;
};
}
/**
* Convert a legacy domain config to a route-based config
* @param domainConfig Legacy domain configuration
* @param additionalOptions Additional options to add to the route
* @returns Route configuration
* @deprecated This function will be removed in a future version
*/
export function domainConfigToRouteConfig(
domainConfig: ILegacyDomainConfig,
additionalOptions: Partial<IRouteConfig> = {}
): IRouteConfig {
// Default port based on forwarding type
let defaultPort = 80;
let tlsMode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt' | undefined;
switch (domainConfig.forwarding.type) {
case 'http-only':
defaultPort = 80;
break;
case 'https-passthrough':
defaultPort = 443;
tlsMode = 'passthrough';
break;
case 'https-terminate-to-http':
defaultPort = 443;
tlsMode = 'terminate';
break;
case 'https-terminate-to-https':
defaultPort = 443;
tlsMode = 'terminate-and-reencrypt';
break;
}
// Create route match criteria
const match: IRouteMatch = {
ports: additionalOptions.match?.ports || defaultPort,
domains: domainConfig.domains
};
// Create route target
const target: IRouteTarget = {
host: domainConfig.forwarding.target.host,
port: domainConfig.forwarding.target.port
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target
};
// Add TLS configuration if needed
if (tlsMode) {
action.tls = {
mode: tlsMode,
certificate: 'auto'
};
// If the legacy config has custom certificates, use them
if (domainConfig.forwarding.https?.customCert) {
action.tls.certificate = {
key: domainConfig.forwarding.https.customCert.key,
cert: domainConfig.forwarding.https.customCert.cert
};
}
}
// Add security options if present
if (domainConfig.forwarding.security) {
action.security = domainConfig.forwarding.security;
}
// Create the route config
const routeConfig: IRouteConfig = {
match,
action,
// Include a name based on domains if not provided
name: additionalOptions.name || `Legacy route for ${domainConfig.domains.join(', ')}`,
// Include a note that this was converted from a legacy config
description: additionalOptions.description || 'Converted from legacy domain configuration'
};
// Add optional properties if provided
if (additionalOptions.priority !== undefined) {
routeConfig.priority = additionalOptions.priority;
}
if (additionalOptions.tags) {
routeConfig.tags = additionalOptions.tags;
}
return routeConfig;
}
/**
* Convert an array of legacy domain configs to route configurations
* @param domainConfigs Array of legacy domain configurations
* @returns Array of route configurations
* @deprecated This function will be removed in a future version
*/
export function domainConfigsToRouteConfigs(
domainConfigs: ILegacyDomainConfig[]
): IRouteConfig[] {
return domainConfigs.map(config => domainConfigToRouteConfig(config));
}
/**
* Extract domains from a route configuration
* @param route Route configuration
* @returns Array of domains
*/
export function extractDomainsFromRoute(route: IRouteConfig): string[] {
if (!route.match.domains) {
return [];
}
return Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
}
/**
* Extract domains from an array of route configurations
* @param routes Array of route configurations
* @returns Array of unique domains
*/
export function extractDomainsFromRoutes(routes: IRouteConfig[]): string[] {
const domains = new Set<string>();
for (const route of routes) {
const routeDomains = extractDomainsFromRoute(route);
for (const domain of routeDomains) {
domains.add(domain);
}
}
return Array.from(domains);
}

View File

@ -0,0 +1,309 @@
/**
* Route Patterns
*
* This file provides pre-defined route patterns for common use cases.
* These patterns can be used as templates for creating route configurations.
*/
import type { IRouteConfig } from '../models/route-types.js';
import { createHttpRoute, createHttpsTerminateRoute, createHttpsPassthroughRoute, createCompleteHttpsServer } from './route-helpers.js';
import { mergeRouteConfigs } from './route-utils.js';
/**
* Create an API Gateway route pattern
* @param domains Domain(s) to match
* @param apiBasePath Base path for API endpoints (e.g., '/api')
* @param target Target host and port
* @param options Additional route options
* @returns API route configuration
*/
export function createApiGatewayRoute(
domains: string | string[],
apiBasePath: string,
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
addCorsHeaders?: boolean;
[key: string]: any;
} = {}
): IRouteConfig {
// Normalize apiBasePath to ensure it starts with / and doesn't end with /
const normalizedPath = apiBasePath.startsWith('/')
? apiBasePath
: `/${apiBasePath}`;
// Add wildcard to path to match all API endpoints
const apiPath = normalizedPath.endsWith('/')
? `${normalizedPath}*`
: `${normalizedPath}/*`;
// Create base route
const baseRoute = options.useTls
? createHttpsTerminateRoute(domains, target, {
certificate: options.certificate || 'auto'
})
: createHttpRoute(domains, target);
// Add API-specific configurations
const apiRoute: Partial<IRouteConfig> = {
match: {
...baseRoute.match,
path: apiPath
},
name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
priority: options.priority || 100 // Higher priority for specific path matching
};
// Add CORS headers if requested
if (options.addCorsHeaders) {
apiRoute.headers = {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
};
}
return mergeRouteConfigs(baseRoute, apiRoute);
}
/**
* Create a static file server route pattern
* @param domains Domain(s) to match
* @param rootDirectory Root directory for static files
* @param options Additional route options
* @returns Static file server route configuration
*/
export function createStaticFileServerRoute(
domains: string | string[],
rootDirectory: string,
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
indexFiles?: string[];
cacheControl?: string;
path?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Create base route with static action
const baseRoute: IRouteConfig = {
match: {
domains,
ports: options.useTls ? 443 : 80,
path: options.path || '/'
},
action: {
type: 'static',
static: {
root: rootDirectory,
index: options.indexFiles || ['index.html', 'index.htm'],
headers: {
'Cache-Control': options.cacheControl || 'public, max-age=3600'
}
}
},
name: options.name || `Static Server: ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: options.priority || 50
};
// Add TLS configuration if requested
if (options.useTls) {
baseRoute.action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
}
return baseRoute;
}
/**
* Create a WebSocket route pattern
* @param domains Domain(s) to match
* @param target WebSocket server host and port
* @param options Additional route options
* @returns WebSocket route configuration
*/
export function createWebSocketRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
path?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Create base route
const baseRoute = options.useTls
? createHttpsTerminateRoute(domains, target, {
certificate: options.certificate || 'auto'
})
: createHttpRoute(domains, target);
// Add WebSocket-specific configurations
const wsRoute: Partial<IRouteConfig> = {
match: {
...baseRoute.match,
path: options.path || '/ws',
headers: {
'Upgrade': 'websocket'
}
},
action: {
...baseRoute.action,
websocket: {
enabled: true,
pingInterval: options.pingInterval || 30000, // 30 seconds
pingTimeout: options.pingTimeout || 5000 // 5 seconds
}
},
name: options.name || `WebSocket: ${Array.isArray(domains) ? domains.join(', ') : domains} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
priority: options.priority || 100 // Higher priority for WebSocket routes
};
return mergeRouteConfigs(baseRoute, wsRoute);
}
/**
* Create a load balancer route pattern
* @param domains Domain(s) to match
* @param backends Array of backend servers
* @param options Additional route options
* @returns Load balancer route configuration
*/
export function createLoadBalancerRoute(
domains: string | string[],
backends: Array<{ host: string; port: number }>,
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
algorithm?: 'round-robin' | 'least-connections' | 'ip-hash';
healthCheck?: {
path: string;
interval: number;
timeout: number;
unhealthyThreshold: number;
healthyThreshold: number;
};
[key: string]: any;
} = {}
): IRouteConfig {
// Extract hosts and ensure all backends use the same port
const port = backends[0].port;
const hosts = backends.map(backend => backend.host);
// Create route with multiple hosts for load balancing
const baseRoute = options.useTls
? createHttpsTerminateRoute(domains, { host: hosts, port }, {
certificate: options.certificate || 'auto'
})
: createHttpRoute(domains, { host: hosts, port });
// Add load balancing specific configurations
const lbRoute: Partial<IRouteConfig> = {
action: {
...baseRoute.action,
loadBalancing: {
algorithm: options.algorithm || 'round-robin',
healthCheck: options.healthCheck
}
},
name: options.name || `Load Balancer: ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: options.priority || 50
};
return mergeRouteConfigs(baseRoute, lbRoute);
}
/**
* Create a rate limiting route pattern
* @param baseRoute Base route to add rate limiting to
* @param rateLimit Rate limiting configuration
* @returns Route with rate limiting
*/
export function addRateLimiting(
baseRoute: IRouteConfig,
rateLimit: {
maxRequests: number;
window: number; // Time window in seconds
keyBy?: 'ip' | 'path' | 'header';
headerName?: string; // Required if keyBy is 'header'
errorMessage?: string;
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
rateLimit: {
enabled: true,
maxRequests: rateLimit.maxRequests,
window: rateLimit.window,
keyBy: rateLimit.keyBy || 'ip',
headerName: rateLimit.headerName,
errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.'
}
}
});
}
/**
* Create a basic authentication route pattern
* @param baseRoute Base route to add authentication to
* @param auth Authentication configuration
* @returns Route with basic authentication
*/
export function addBasicAuth(
baseRoute: IRouteConfig,
auth: {
users: Array<{ username: string; password: string }>;
realm?: string;
excludePaths?: string[];
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
basicAuth: {
enabled: true,
users: auth.users,
realm: auth.realm || 'Restricted Area',
excludePaths: auth.excludePaths || []
}
}
});
}
/**
* Create a JWT authentication route pattern
* @param baseRoute Base route to add JWT authentication to
* @param jwt JWT authentication configuration
* @returns Route with JWT authentication
*/
export function addJwtAuth(
baseRoute: IRouteConfig,
jwt: {
secret: string;
algorithm?: string;
issuer?: string;
audience?: string;
expiresIn?: number; // Time in seconds
excludePaths?: string[];
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
jwtAuth: {
enabled: true,
secret: jwt.secret,
algorithm: jwt.algorithm || 'HS256',
issuer: jwt.issuer,
audience: jwt.audience,
expiresIn: jwt.expiresIn,
excludePaths: jwt.excludePaths || []
}
}
});
}

View File

@ -0,0 +1,330 @@
/**
* Route Utilities
*
* This file provides utility functions for working with route configurations,
* including merging, finding, and managing route collections.
*/
import type { IRouteConfig, IRouteMatch } from '../models/route-types.js';
import { validateRouteConfig } from './route-validators.js';
/**
* Merge two route configurations
* The second route's properties will override the first route's properties where they exist
* @param baseRoute The base route configuration
* @param overrideRoute The route configuration with overriding properties
* @returns A new merged route configuration
*/
export function mergeRouteConfigs(
baseRoute: IRouteConfig,
overrideRoute: Partial<IRouteConfig>
): IRouteConfig {
// Create deep copies to avoid modifying original objects
const mergedRoute: IRouteConfig = JSON.parse(JSON.stringify(baseRoute));
// Apply overrides at the top level
if (overrideRoute.id) mergedRoute.id = overrideRoute.id;
if (overrideRoute.name) mergedRoute.name = overrideRoute.name;
if (overrideRoute.enabled !== undefined) mergedRoute.enabled = overrideRoute.enabled;
if (overrideRoute.priority !== undefined) mergedRoute.priority = overrideRoute.priority;
// Merge match configuration
if (overrideRoute.match) {
mergedRoute.match = { ...mergedRoute.match };
if (overrideRoute.match.ports !== undefined) {
mergedRoute.match.ports = overrideRoute.match.ports;
}
if (overrideRoute.match.domains !== undefined) {
mergedRoute.match.domains = overrideRoute.match.domains;
}
if (overrideRoute.match.path !== undefined) {
mergedRoute.match.path = overrideRoute.match.path;
}
if (overrideRoute.match.headers !== undefined) {
mergedRoute.match.headers = overrideRoute.match.headers;
}
}
// Merge action configuration
if (overrideRoute.action) {
// If action types are different, replace the entire action
if (overrideRoute.action.type && overrideRoute.action.type !== mergedRoute.action.type) {
mergedRoute.action = JSON.parse(JSON.stringify(overrideRoute.action));
} else {
// Otherwise merge the action properties
mergedRoute.action = { ...mergedRoute.action };
// Merge target
if (overrideRoute.action.target) {
mergedRoute.action.target = {
...mergedRoute.action.target,
...overrideRoute.action.target
};
}
// Merge TLS options
if (overrideRoute.action.tls) {
mergedRoute.action.tls = {
...mergedRoute.action.tls,
...overrideRoute.action.tls
};
}
// Merge redirect options
if (overrideRoute.action.redirect) {
mergedRoute.action.redirect = {
...mergedRoute.action.redirect,
...overrideRoute.action.redirect
};
}
// Merge static options
if (overrideRoute.action.static) {
mergedRoute.action.static = {
...mergedRoute.action.static,
...overrideRoute.action.static
};
}
}
}
return mergedRoute;
}
/**
* Check if a route matches a domain
* @param route The route to check
* @param domain The domain to match against
* @returns True if the route matches the domain, false otherwise
*/
export function routeMatchesDomain(route: IRouteConfig, domain: string): boolean {
if (!route.match?.domains) {
return false;
}
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
return domains.some(d => {
// Handle wildcard domains
if (d.startsWith('*.')) {
const suffix = d.substring(2);
return domain.endsWith(suffix) && domain.split('.').length > suffix.split('.').length;
}
return d.toLowerCase() === domain.toLowerCase();
});
}
/**
* Check if a route matches a port
* @param route The route to check
* @param port The port to match against
* @returns True if the route matches the port, false otherwise
*/
export function routeMatchesPort(route: IRouteConfig, port: number): boolean {
if (!route.match?.ports) {
return false;
}
if (typeof route.match.ports === 'number') {
return route.match.ports === port;
}
if (Array.isArray(route.match.ports)) {
// Simple case - array of numbers
if (typeof route.match.ports[0] === 'number') {
return (route.match.ports as number[]).includes(port);
}
// Complex case - array of port ranges
if (typeof route.match.ports[0] === 'object') {
return (route.match.ports as Array<{ from: number; to: number }>).some(
range => port >= range.from && port <= range.to
);
}
}
return false;
}
/**
* Check if a route matches a path
* @param route The route to check
* @param path The path to match against
* @returns True if the route matches the path, false otherwise
*/
export function routeMatchesPath(route: IRouteConfig, path: string): boolean {
if (!route.match?.path) {
return true; // No path specified means it matches any path
}
// Handle exact path
if (route.match.path === path) {
return true;
}
// Handle path prefix with trailing slash (e.g., /api/)
if (route.match.path.endsWith('/') && path.startsWith(route.match.path)) {
return true;
}
// Handle exact path match without trailing slash
if (!route.match.path.endsWith('/') && path === route.match.path) {
return true;
}
// Handle wildcard paths (e.g., /api/*)
if (route.match.path.endsWith('*')) {
const prefix = route.match.path.slice(0, -1);
return path.startsWith(prefix);
}
return false;
}
/**
* Check if a route matches headers
* @param route The route to check
* @param headers The headers to match against
* @returns True if the route matches the headers, false otherwise
*/
export function routeMatchesHeaders(
route: IRouteConfig,
headers: Record<string, string>
): boolean {
if (!route.match?.headers || Object.keys(route.match.headers).length === 0) {
return true; // No headers specified means it matches any headers
}
// Check each header in the route's match criteria
return Object.entries(route.match.headers).every(([key, value]) => {
// If the header isn't present in the request, it doesn't match
if (!headers[key]) {
return false;
}
// Handle exact match
if (typeof value === 'string') {
return headers[key] === value;
}
// Handle regex match
if (value instanceof RegExp) {
return value.test(headers[key]);
}
return false;
});
}
/**
* Find all routes that match the given criteria
* @param routes Array of routes to search
* @param criteria Matching criteria
* @returns Array of matching routes sorted by priority
*/
export function findMatchingRoutes(
routes: IRouteConfig[],
criteria: {
domain?: string;
port?: number;
path?: string;
headers?: Record<string, string>;
}
): IRouteConfig[] {
// Filter routes that are enabled and match all provided criteria
const matchingRoutes = routes.filter(route => {
// Skip disabled routes
if (route.enabled === false) {
return false;
}
// Check domain match if specified
if (criteria.domain && !routeMatchesDomain(route, criteria.domain)) {
return false;
}
// Check port match if specified
if (criteria.port !== undefined && !routeMatchesPort(route, criteria.port)) {
return false;
}
// Check path match if specified
if (criteria.path && !routeMatchesPath(route, criteria.path)) {
return false;
}
// Check headers match if specified
if (criteria.headers && !routeMatchesHeaders(route, criteria.headers)) {
return false;
}
return true;
});
// Sort matching routes by priority (higher priority first)
return matchingRoutes.sort((a, b) => {
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
return priorityB - priorityA; // Higher priority first
});
}
/**
* Find the best matching route for the given criteria
* @param routes Array of routes to search
* @param criteria Matching criteria
* @returns The best matching route or undefined if no match
*/
export function findBestMatchingRoute(
routes: IRouteConfig[],
criteria: {
domain?: string;
port?: number;
path?: string;
headers?: Record<string, string>;
}
): IRouteConfig | undefined {
const matchingRoutes = findMatchingRoutes(routes, criteria);
return matchingRoutes.length > 0 ? matchingRoutes[0] : undefined;
}
/**
* Create a route ID based on route properties
* @param route Route configuration
* @returns Generated route ID
*/
export function generateRouteId(route: IRouteConfig): string {
// Create a deterministic ID based on route properties
const domains = Array.isArray(route.match?.domains)
? route.match.domains.join('-')
: route.match?.domains || 'any';
let portsStr = 'any';
if (route.match?.ports) {
if (Array.isArray(route.match.ports)) {
portsStr = route.match.ports.join('-');
} else if (typeof route.match.ports === 'number') {
portsStr = route.match.ports.toString();
}
}
const path = route.match?.path || 'any';
const action = route.action?.type || 'unknown';
return `route-${domains}-${portsStr}-${path}-${action}`.replace(/[^a-zA-Z0-9-]/g, '-');
}
/**
* Clone a route configuration
* @param route Route to clone
* @returns Deep copy of the route
*/
export function cloneRoute(route: IRouteConfig): IRouteConfig {
return JSON.parse(JSON.stringify(route));
}

View File

@ -0,0 +1,269 @@
/**
* 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 or port range
* @returns True if valid, false otherwise
*/
export function isValidPort(port: TPortRange): 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);
}
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', 'redirect', 'static', 'block'].includes(action.type)) {
errors.push(`Invalid action type: ${action.type}`);
}
// Validate target for 'forward' action
if (action.type === 'forward') {
if (!action.target) {
errors.push('Target is required for forward action');
} else {
// Validate target host
if (!action.target.host) {
errors.push('Target host is required');
}
// Validate target port
if (!action.target.port || !isValidPort(action.target.port)) {
errors.push('Valid target port is required');
}
}
// 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 redirect for 'redirect' action
if (action.type === 'redirect') {
if (!action.redirect) {
errors.push('Redirect configuration is required for redirect action');
} else {
if (!action.redirect.to) {
errors.push('Redirect target (to) is required');
}
if (action.redirect.status &&
![301, 302, 303, 307, 308].includes(action.redirect.status)) {
errors.push('Invalid redirect status code');
}
}
}
// Validate static file config for 'static' action
if (action.type === 'static') {
if (!action.static) {
errors.push('Static file configuration is required for static action');
} else {
if (!action.static.root) {
errors.push('Static file root directory is required');
}
}
}
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.target && !!route.action.target.host && !!route.action.target.port;
case 'redirect':
return !!route.action.redirect && !!route.action.redirect.to;
case 'static':
return !!route.action.static && !!route.action.static.root;
case 'block':
return true; // Block action doesn't require additional properties
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;
}