Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
e212dacbf3 | |||
eea8942670 | |||
0574331b91 | |||
06e6c2eb52 |
16
changelog.md
16
changelog.md
@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.27.0 - feat(AcmeCertManager)
|
||||||
|
Introduce AcmeCertManager for enhanced ACME certificate management
|
||||||
|
|
||||||
|
- Refactored the existing Port80Handler to AcmeCertManager.
|
||||||
|
- Added event-driven certificate management with CertManagerEvents.
|
||||||
|
- Introduced options for configuration such as renew thresholds and production mode.
|
||||||
|
- Implemented certificate renewal checks and logging improvements.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.26.0 - feat(readme)
|
||||||
|
Updated README with enhanced TLS handling, connection management, and troubleshooting sections.
|
||||||
|
|
||||||
|
- Added details on enhanced TLS handling and browser compatibility improvements.
|
||||||
|
- Included advanced connection management features like random timeout prevention.
|
||||||
|
- Provided comprehensive troubleshooting tips for browser certificate errors and connection stability.
|
||||||
|
- Clarified default configuration options and optimization settings for PortProxy.
|
||||||
|
|
||||||
## 2025-03-05 - 3.25.4 - fix(portproxy)
|
## 2025-03-05 - 3.25.4 - fix(portproxy)
|
||||||
Improve connection timeouts and detailed logging for PortProxy
|
Improve connection timeouts and detailed logging for PortProxy
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.25.4",
|
"version": "3.27.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
126
readme.md
126
readme.md
@ -193,12 +193,14 @@ sequenceDiagram
|
|||||||
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
||||||
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
||||||
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
|
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
|
||||||
|
- **Enhanced TLS Handling** - Robust TLS handshake processing with improved certificate error handling
|
||||||
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
|
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
|
||||||
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
||||||
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
||||||
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
||||||
- **Basic Authentication** - Support for basic auth on proxied routes
|
- **Basic Authentication** - Support for basic auth on proxied routes
|
||||||
- **Connection Management** - Intelligent connection tracking and cleanup
|
- **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
|
||||||
|
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -275,18 +277,38 @@ const portProxy = new PortProxy({
|
|||||||
toPort: 8443,
|
toPort: 8443,
|
||||||
targetIP: 'localhost', // Default target host
|
targetIP: 'localhost', // Default target host
|
||||||
sniEnabled: true, // Enable SNI inspection
|
sniEnabled: true, // Enable SNI inspection
|
||||||
|
|
||||||
|
// Enhanced reliability settings
|
||||||
|
initialDataTimeout: 60000, // 60 seconds for initial TLS handshake
|
||||||
|
socketTimeout: 3600000, // 1 hour socket timeout
|
||||||
|
maxConnectionLifetime: 3600000, // 1 hour connection lifetime
|
||||||
|
inactivityTimeout: 3600000, // 1 hour inactivity timeout
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // 10MB buffer for large TLS handshakes
|
||||||
|
|
||||||
|
// Browser compatibility enhancement
|
||||||
|
enableTlsDebugLogging: false, // Enable for troubleshooting TLS issues
|
||||||
|
|
||||||
|
// Port and IP configuration
|
||||||
globalPortRanges: [{ from: 443, to: 443 }],
|
globalPortRanges: [{ from: 443, to: 443 }],
|
||||||
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
||||||
|
|
||||||
|
// Socket optimizations for better connection stability
|
||||||
|
noDelay: true, // Disable Nagle's algorithm
|
||||||
|
keepAlive: true, // Enable TCP keepalive
|
||||||
|
enableKeepAliveProbes: true, // Enhanced keepalive for stability
|
||||||
|
|
||||||
|
// Domain-specific routing configuration
|
||||||
domainConfigs: [
|
domainConfigs: [
|
||||||
{
|
{
|
||||||
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
||||||
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
||||||
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
||||||
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
|
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
|
||||||
portRanges: [{ from: 443, to: 443 }]
|
portRanges: [{ from: 443, to: 443 }],
|
||||||
|
connectionTimeout: 7200000 // Domain-specific timeout (2 hours)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
|
|
||||||
preserveSourceIP: true
|
preserveSourceIP: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -333,19 +355,31 @@ acmeHandler.addDomain('api.example.com');
|
|||||||
|
|
||||||
### PortProxy Settings
|
### PortProxy Settings
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------------------------|--------------------------------------------------------|-------------|
|
|---------------------------|--------------------------------------------------------|-------------|
|
||||||
| `fromPort` | Port to listen on | - |
|
| `fromPort` | Port to listen on | - |
|
||||||
| `toPort` | Destination port to forward to | - |
|
| `toPort` | Destination port to forward to | - |
|
||||||
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
||||||
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
||||||
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
||||||
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
||||||
| `preserveSourceIP` | Preserve the original client IP | false |
|
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||||
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 600000 |
|
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 3600000 |
|
||||||
| `globalPortRanges` | Array of port ranges to listen on | - |
|
| `initialDataTimeout` | Timeout for initial data/handshake in ms | 60000 |
|
||||||
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
|
| `socketTimeout` | Socket inactivity timeout in ms | 3600000 |
|
||||||
| `gracefulShutdownTimeout`| Time in ms to wait during shutdown | 30000 |
|
| `inactivityTimeout` | Connection inactivity check timeout in ms | 3600000 |
|
||||||
|
| `inactivityCheckInterval` | How often to check for inactive connections in ms | 60000 |
|
||||||
|
| `maxPendingDataSize` | Maximum bytes to buffer during connection setup | 10485760 |
|
||||||
|
| `globalPortRanges` | Array of port ranges to listen on | - |
|
||||||
|
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
|
||||||
|
| `gracefulShutdownTimeout` | Time in ms to wait during shutdown | 30000 |
|
||||||
|
| `noDelay` | Disable Nagle's algorithm | true |
|
||||||
|
| `keepAlive` | Enable TCP keepalive | true |
|
||||||
|
| `keepAliveInitialDelay` | Initial delay before sending keepalive probes in ms | 30000 |
|
||||||
|
| `enableKeepAliveProbes` | Enable enhanced TCP keep-alive probes | false |
|
||||||
|
| `enableTlsDebugLogging` | Enable detailed TLS handshake debugging | false |
|
||||||
|
| `enableDetailedLogging` | Enable detailed connection logging | false |
|
||||||
|
| `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true |
|
||||||
|
|
||||||
### IPTablesProxy Settings
|
### IPTablesProxy Settings
|
||||||
|
|
||||||
@ -359,14 +393,37 @@ acmeHandler.addDomain('api.example.com');
|
|||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
|
### TLS Handshake Optimization
|
||||||
|
|
||||||
|
The enhanced `PortProxy` implementation includes significant improvements for TLS handshake handling:
|
||||||
|
|
||||||
|
- Robust SNI extraction with improved error handling
|
||||||
|
- Increased buffer size for complex TLS handshakes (10MB)
|
||||||
|
- Longer initial handshake timeout (60 seconds)
|
||||||
|
- Detection and tracking of TLS connection states
|
||||||
|
- Optional detailed TLS debug logging for troubleshooting
|
||||||
|
- Browser compatibility fixes for Chrome certificate errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example configuration to solve Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000, // Give browser more time for handshake
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // Larger buffer for complex handshakes
|
||||||
|
enableTlsDebugLogging: true, // Enable when troubleshooting
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### Connection Management and Monitoring
|
### Connection Management and Monitoring
|
||||||
|
|
||||||
The `PortProxy` class includes built-in connection tracking and monitoring:
|
The `PortProxy` class includes built-in connection tracking and monitoring:
|
||||||
|
|
||||||
- Automatic cleanup of idle connections
|
- Automatic cleanup of idle connections with configurable timeouts
|
||||||
- Timeouts for connections that exceed maximum lifetime
|
- Timeouts for connections that exceed maximum lifetime
|
||||||
- Detailed logging of connection states
|
- Detailed logging of connection states
|
||||||
- Termination statistics
|
- Termination statistics
|
||||||
|
- Randomized timeouts to prevent "thundering herd" problems
|
||||||
|
- Per-domain timeout configuration
|
||||||
|
|
||||||
### WebSocket Support
|
### WebSocket Support
|
||||||
|
|
||||||
@ -385,6 +442,39 @@ The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS
|
|||||||
- Domain-specific allowed IP ranges
|
- Domain-specific allowed IP ranges
|
||||||
- Protection against SNI renegotiation attacks
|
- Protection against SNI renegotiation attacks
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Browser Certificate Errors
|
||||||
|
|
||||||
|
If you experience certificate errors in browsers, especially in Chrome, try these solutions:
|
||||||
|
|
||||||
|
1. **Increase Initial Data Timeout**: Set `initialDataTimeout` to 60 seconds or higher
|
||||||
|
2. **Increase Buffer Size**: Set `maxPendingDataSize` to 10MB or higher
|
||||||
|
3. **Enable TLS Debug Logging**: Set `enableTlsDebugLogging: true` to troubleshoot handshake issues
|
||||||
|
4. **Enable Keep-Alive Probes**: Set `enableKeepAliveProbes: true` for better connection stability
|
||||||
|
5. **Check Certificate Chain**: Ensure your certificate chain is complete and in the correct order
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Configuration to fix Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000,
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024,
|
||||||
|
enableTlsDebugLogging: true,
|
||||||
|
enableKeepAliveProbes: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Stability
|
||||||
|
|
||||||
|
For improved connection stability in high-traffic environments:
|
||||||
|
|
||||||
|
1. **Set Appropriate Timeouts**: Use longer timeouts for long-lived connections
|
||||||
|
2. **Use Domain-Specific Timeouts**: Configure per-domain timeouts for different types of services
|
||||||
|
3. **Enable TCP Keep-Alive**: Ensure `keepAlive` is set to `true`
|
||||||
|
4. **Monitor Connection Statistics**: Enable detailed logging to track termination reasons
|
||||||
|
5. **Fine-tune Inactivity Checks**: Adjust `inactivityCheckInterval` based on your traffic patterns
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
@ -402,4 +492,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
|
|||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.25.4',
|
version: '3.27.0',
|
||||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import * as http from 'http';
|
import * as plugins from './plugins.js';
|
||||||
import * as acme from 'acme-client';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a domain certificate with various status information
|
||||||
|
*/
|
||||||
interface IDomainCertificate {
|
interface IDomainCertificate {
|
||||||
certObtained: boolean;
|
certObtained: boolean;
|
||||||
obtainingInProgress: boolean;
|
obtainingInProgress: boolean;
|
||||||
@ -8,27 +10,147 @@ interface IDomainCertificate {
|
|||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
challengeToken?: string;
|
challengeToken?: string;
|
||||||
challengeKeyAuthorization?: string;
|
challengeKeyAuthorization?: string;
|
||||||
|
expiryDate?: Date;
|
||||||
|
lastRenewalAttempt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Port80Handler {
|
/**
|
||||||
|
* Configuration options for the ACME Certificate Manager
|
||||||
|
*/
|
||||||
|
interface IAcmeCertManagerOptions {
|
||||||
|
port?: number;
|
||||||
|
contactEmail?: string;
|
||||||
|
useProduction?: boolean;
|
||||||
|
renewThresholdDays?: number;
|
||||||
|
httpsRedirectPort?: number;
|
||||||
|
renewCheckIntervalHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate data that can be emitted via events or set from outside
|
||||||
|
*/
|
||||||
|
interface ICertificateData {
|
||||||
|
domain: string;
|
||||||
|
certificate: string;
|
||||||
|
privateKey: string;
|
||||||
|
expiryDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the ACME Certificate Manager
|
||||||
|
*/
|
||||||
|
export enum CertManagerEvents {
|
||||||
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||||
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||||
|
CERTIFICATE_FAILED = 'certificate-failed',
|
||||||
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||||
|
MANAGER_STARTED = 'manager-started',
|
||||||
|
MANAGER_STOPPED = 'manager-stopped',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Improved ACME Certificate Manager with event emission and external certificate management
|
||||||
|
*/
|
||||||
|
export class AcmeCertManager extends plugins.EventEmitter {
|
||||||
private domainCertificates: Map<string, IDomainCertificate>;
|
private domainCertificates: Map<string, IDomainCertificate>;
|
||||||
private server: http.Server;
|
private server: plugins.http.Server | null = null;
|
||||||
private acmeClient: acme.Client | null = null;
|
private acmeClient: plugins.acme.Client | null = null;
|
||||||
private accountKey: string | null = null;
|
private accountKey: string | null = null;
|
||||||
|
private renewalTimer: NodeJS.Timeout | null = null;
|
||||||
|
private isShuttingDown: boolean = false;
|
||||||
|
private options: Required<IAcmeCertManagerOptions>;
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
|
* Creates a new ACME Certificate Manager
|
||||||
|
* @param options Configuration options
|
||||||
|
*/
|
||||||
|
constructor(options: IAcmeCertManagerOptions = {}) {
|
||||||
|
super();
|
||||||
this.domainCertificates = new Map<string, IDomainCertificate>();
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
port: options.port ?? 80,
|
||||||
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
||||||
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
||||||
|
renewThresholdDays: options.renewThresholdDays ?? 30,
|
||||||
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||||
|
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Create and start an HTTP server on port 80.
|
/**
|
||||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
* Starts the HTTP server for ACME challenges
|
||||||
this.server.listen(80, () => {
|
*/
|
||||||
console.log('Port80Handler is listening on port 80');
|
public async start(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
throw new Error('Server is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
throw new Error('Server is shutting down');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
||||||
|
|
||||||
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'EACCES') {
|
||||||
|
reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`));
|
||||||
|
} else if (error.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`Port ${this.options.port} is already in use.`));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(this.options.port, () => {
|
||||||
|
console.log(`AcmeCertManager is listening on port ${this.options.port}`);
|
||||||
|
this.startRenewalTimer();
|
||||||
|
this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a domain to be managed.
|
* Stops the HTTP server and renewal timer
|
||||||
* @param domain The domain to add.
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop the renewal timer
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
this.renewalTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close(() => {
|
||||||
|
this.server = null;
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
this.emit(CertManagerEvents.MANAGER_STOPPED);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a domain to be managed for certificates
|
||||||
|
* @param domain The domain to add
|
||||||
*/
|
*/
|
||||||
public addDomain(domain: string): void {
|
public addDomain(domain: string): void {
|
||||||
if (!this.domainCertificates.has(domain)) {
|
if (!this.domainCertificates.has(domain)) {
|
||||||
@ -38,55 +160,126 @@ export class Port80Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a domain from management.
|
* Removes a domain from management
|
||||||
* @param domain The domain to remove.
|
* @param domain The domain to remove
|
||||||
*/
|
*/
|
||||||
public removeDomain(domain: string): void {
|
public removeDomain(domain: string): void {
|
||||||
if (this.domainCertificates.delete(domain)) {
|
if (this.domainCertificates.delete(domain)) {
|
||||||
console.log(`Domain removed: ${domain}`);
|
console.log(`Domain removed: ${domain}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a certificate for a domain directly (for externally obtained certificates)
|
||||||
|
* @param domain The domain for the certificate
|
||||||
|
* @param certificate The certificate (PEM format)
|
||||||
|
* @param privateKey The private key (PEM format)
|
||||||
|
* @param expiryDate Optional expiry date
|
||||||
|
*/
|
||||||
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
||||||
|
let domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo) {
|
||||||
|
domainInfo = { certObtained: false, obtainingInProgress: false };
|
||||||
|
this.domainCertificates.set(domain, domainInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.certificate = certificate;
|
||||||
|
domainInfo.privateKey = privateKey;
|
||||||
|
domainInfo.certObtained = true;
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
|
||||||
|
if (expiryDate) {
|
||||||
|
domainInfo.expiryDate = expiryDate;
|
||||||
|
} else {
|
||||||
|
// Try to extract expiry date from certificate
|
||||||
|
try {
|
||||||
|
// This is a simplistic approach - in a real implementation, use a proper
|
||||||
|
// certificate parsing library like node-forge or x509
|
||||||
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
domainInfo.expiryDate = new Date(matches[1]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate set for ${domain}`);
|
||||||
|
|
||||||
|
// Emit certificate event
|
||||||
|
this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the certificate for a domain if it exists
|
||||||
|
* @param domain The domain to get the certificate for
|
||||||
|
*/
|
||||||
|
public getCertificate(domain: string): ICertificateData | null {
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
certificate: domainInfo.certificate,
|
||||||
|
privateKey: domainInfo.privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy initialization of the ACME client.
|
* Lazy initialization of the ACME client
|
||||||
* Uses Let’s Encrypt’s production directory (for testing you might switch to staging).
|
* @returns An ACME client instance
|
||||||
*/
|
*/
|
||||||
private async getAcmeClient(): Promise<acme.Client> {
|
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
||||||
if (this.acmeClient) {
|
if (this.acmeClient) {
|
||||||
return this.acmeClient;
|
return this.acmeClient;
|
||||||
}
|
}
|
||||||
// Generate a new account key and convert Buffer to string.
|
|
||||||
this.accountKey = (await acme.forge.createPrivateKey()).toString();
|
// Generate a new account key
|
||||||
this.acmeClient = new acme.Client({
|
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
||||||
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
|
|
||||||
// For testing, you could use:
|
this.acmeClient = new plugins.acme.Client({
|
||||||
// directoryUrl: acme.directory.letsencrypt.staging,
|
directoryUrl: this.options.useProduction
|
||||||
|
? plugins.acme.directory.letsencrypt.production
|
||||||
|
: plugins.acme.directory.letsencrypt.staging,
|
||||||
accountKey: this.accountKey,
|
accountKey: this.accountKey,
|
||||||
});
|
});
|
||||||
// Create a new account. Make sure to update the contact email.
|
|
||||||
|
// Create a new account
|
||||||
await this.acmeClient.createAccount({
|
await this.acmeClient.createAccount({
|
||||||
termsOfServiceAgreed: true,
|
termsOfServiceAgreed: true,
|
||||||
contact: ['mailto:admin@example.com'],
|
contact: [`mailto:${this.options.contactEmail}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.acmeClient;
|
return this.acmeClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles incoming HTTP requests on port 80.
|
* Handles incoming HTTP requests
|
||||||
* If the request is for an ACME challenge, it responds with the key authorization.
|
* @param req The HTTP request
|
||||||
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
|
* @param res The HTTP response
|
||||||
*/
|
*/
|
||||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||||
const hostHeader = req.headers.host;
|
const hostHeader = req.headers.host;
|
||||||
if (!hostHeader) {
|
if (!hostHeader) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Bad Request: Host header is missing');
|
res.end('Bad Request: Host header is missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (ignoring any port in the Host header)
|
// Extract domain (ignoring any port in the Host header)
|
||||||
const domain = hostHeader.split(':')[0];
|
const domain = hostHeader.split(':')[0];
|
||||||
|
|
||||||
// If the request is for an ACME HTTP-01 challenge, handle it.
|
// If the request is for an ACME HTTP-01 challenge, handle it
|
||||||
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
||||||
this.handleAcmeChallenge(req, res, domain);
|
this.handleAcmeChallenge(req, res, domain);
|
||||||
return;
|
return;
|
||||||
@ -100,38 +293,47 @@ export class Port80Handler {
|
|||||||
|
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
|
|
||||||
// If certificate exists, redirect to HTTPS on port 443.
|
// If certificate exists, redirect to HTTPS
|
||||||
if (domainInfo.certObtained) {
|
if (domainInfo.certObtained) {
|
||||||
const redirectUrl = `https://${domain}:443${req.url}`;
|
const httpsPort = this.options.httpsRedirectPort;
|
||||||
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
||||||
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
||||||
|
|
||||||
res.statusCode = 301;
|
res.statusCode = 301;
|
||||||
res.setHeader('Location', redirectUrl);
|
res.setHeader('Location', redirectUrl);
|
||||||
res.end(`Redirecting to ${redirectUrl}`);
|
res.end(`Redirecting to ${redirectUrl}`);
|
||||||
} else {
|
} else {
|
||||||
// Trigger certificate issuance if not already running.
|
// Trigger certificate issuance if not already running
|
||||||
if (!domainInfo.obtainingInProgress) {
|
if (!domainInfo.obtainingInProgress) {
|
||||||
domainInfo.obtainingInProgress = true;
|
|
||||||
this.obtainCertificate(domain).catch(err => {
|
this.obtainCertificate(domain).catch(err => {
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message });
|
||||||
console.error(`Error obtaining certificate for ${domain}:`, err);
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.statusCode = 503;
|
res.statusCode = 503;
|
||||||
res.end('Certificate issuance in progress, please try again later.');
|
res.end('Certificate issuance in progress, please try again later.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves the ACME HTTP-01 challenge response.
|
* Serves the ACME HTTP-01 challenge response
|
||||||
|
* @param req The HTTP request
|
||||||
|
* @param res The HTTP response
|
||||||
|
* @param domain The domain for the challenge
|
||||||
*/
|
*/
|
||||||
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
|
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
if (!domainInfo) {
|
if (!domainInfo) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('Domain not configured');
|
res.end('Domain not configured');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The token is the last part of the URL.
|
|
||||||
|
// The token is the last part of the URL
|
||||||
const urlParts = req.url?.split('/');
|
const urlParts = req.url?.split('/');
|
||||||
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
||||||
|
|
||||||
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
@ -144,71 +346,214 @@ export class Port80Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
|
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||||
* On success, it stores the certificate and key in memory and clears challenge data.
|
* @param domain The domain to obtain a certificate for
|
||||||
|
* @param isRenewal Whether this is a renewal attempt
|
||||||
*/
|
*/
|
||||||
private async obtainCertificate(domain: string): Promise<void> {
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||||
|
// Get the domain info
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
throw new Error(`Domain not found: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent certificate issuance
|
||||||
|
if (domainInfo.obtainingInProgress) {
|
||||||
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.obtainingInProgress = true;
|
||||||
|
domainInfo.lastRenewalAttempt = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await this.getAcmeClient();
|
const client = await this.getAcmeClient();
|
||||||
|
|
||||||
// Create a new order for the domain.
|
// Create a new order for the domain
|
||||||
const order = await client.createOrder({
|
const order = await client.createOrder({
|
||||||
identifiers: [{ type: 'dns', value: domain }],
|
identifiers: [{ type: 'dns', value: domain }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the authorizations for the order.
|
// Get the authorizations for the order
|
||||||
const authorizations = await client.getAuthorizations(order);
|
const authorizations = await client.getAuthorizations(order);
|
||||||
|
|
||||||
for (const authz of authorizations) {
|
for (const authz of authorizations) {
|
||||||
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
throw new Error('HTTP-01 challenge not found');
|
throw new Error('HTTP-01 challenge not found');
|
||||||
}
|
}
|
||||||
// Get the key authorization for the challenge.
|
|
||||||
|
// Get the key authorization for the challenge
|
||||||
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
|
||||||
|
// Store the challenge data
|
||||||
domainInfo.challengeToken = challenge.token;
|
domainInfo.challengeToken = challenge.token;
|
||||||
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
||||||
|
|
||||||
// Notify the ACME server that the challenge is ready.
|
// ACME client type definition workaround - use compatible approach
|
||||||
// The acme-client examples show that verifyChallenge takes three arguments:
|
// First check if challenge verification is needed
|
||||||
// (authorization, challenge, keyAuthorization). However, the official TypeScript
|
const authzUrl = authz.url;
|
||||||
// types appear to be out-of-sync. As a workaround, we cast client to 'any'.
|
|
||||||
await (client as any).verifyChallenge(authz, challenge, keyAuthorization);
|
try {
|
||||||
|
// Check if authzUrl exists and perform verification
|
||||||
await client.completeChallenge(challenge);
|
if (authzUrl) {
|
||||||
// Wait until the challenge is validated.
|
await client.verifyChallenge(authz, challenge);
|
||||||
await client.waitForValidStatus(challenge);
|
}
|
||||||
console.log(`HTTP-01 challenge completed for ${domain}`);
|
|
||||||
|
// Complete the challenge
|
||||||
|
await client.completeChallenge(challenge);
|
||||||
|
|
||||||
|
// Wait for validation
|
||||||
|
await client.waitForValidStatus(challenge);
|
||||||
|
console.log(`HTTP-01 challenge completed for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Challenge error for ${domain}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a CSR and a new private key for the domain.
|
// Generate a CSR and private key
|
||||||
// Convert the resulting Buffers to strings.
|
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
||||||
const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({
|
|
||||||
commonName: domain,
|
commonName: domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const csr = csrBuffer.toString();
|
const csr = csrBuffer.toString();
|
||||||
const privateKey = privateKeyBuffer.toString();
|
const privateKey = privateKeyBuffer.toString();
|
||||||
|
|
||||||
// Finalize the order and obtain the certificate.
|
// Finalize the order with our CSR
|
||||||
await client.finalizeOrder(order, csr);
|
await client.finalizeOrder(order, csr);
|
||||||
|
|
||||||
|
// Get the certificate with the full chain
|
||||||
const certificate = await client.getCertificate(order);
|
const certificate = await client.getCertificate(order);
|
||||||
|
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
// Store the certificate and key
|
||||||
domainInfo.certificate = certificate;
|
domainInfo.certificate = certificate;
|
||||||
domainInfo.privateKey = privateKey;
|
domainInfo.privateKey = privateKey;
|
||||||
domainInfo.certObtained = true;
|
domainInfo.certObtained = true;
|
||||||
domainInfo.obtainingInProgress = false;
|
|
||||||
|
// Clear challenge data
|
||||||
delete domainInfo.challengeToken;
|
delete domainInfo.challengeToken;
|
||||||
delete domainInfo.challengeKeyAuthorization;
|
delete domainInfo.challengeKeyAuthorization;
|
||||||
|
|
||||||
|
// Extract expiry date from certificate
|
||||||
|
try {
|
||||||
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
domainInfo.expiryDate = new Date(matches[1]);
|
||||||
|
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Certificate obtained for ${domain}`);
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||||
// In a production system, persist the certificate and key and reload your TLS server.
|
|
||||||
} catch (error) {
|
// Emit the appropriate event
|
||||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
const eventType = isRenewal
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
? CertManagerEvents.CERTIFICATE_RENEWED
|
||||||
if (domainInfo) {
|
: CertManagerEvents.CERTIFICATE_ISSUED;
|
||||||
domainInfo.obtainingInProgress = false;
|
|
||||||
|
this.emitCertificateEvent(eventType, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check for rate limit errors
|
||||||
|
if (error.message && (
|
||||||
|
error.message.includes('rateLimited') ||
|
||||||
|
error.message.includes('too many certificates') ||
|
||||||
|
error.message.includes('rate limit')
|
||||||
|
)) {
|
||||||
|
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit failure event
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, {
|
||||||
|
domain,
|
||||||
|
error: error.message || 'Unknown error',
|
||||||
|
isRenewal
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Reset flag whether successful or not
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the certificate renewal timer
|
||||||
|
*/
|
||||||
|
private startRenewalTimer(): void {
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hours to milliseconds
|
||||||
|
const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval);
|
||||||
|
|
||||||
|
// Prevent the timer from keeping the process alive
|
||||||
|
if (this.renewalTimer.unref) {
|
||||||
|
this.renewalTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for certificates that need renewal
|
||||||
|
*/
|
||||||
|
private checkForRenewals(): void {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Checking for certificates that need renewal...');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||||
|
// Skip domains without certificates or already in renewal
|
||||||
|
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains without expiry dates
|
||||||
|
if (!domainInfo.expiryDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime();
|
||||||
|
|
||||||
|
// Check if certificate is near expiry
|
||||||
|
if (timeUntilExpiry <= renewThresholdMs) {
|
||||||
|
console.log(`Certificate for ${domain} expires soon, renewing...`);
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, {
|
||||||
|
domain,
|
||||||
|
expiryDate: domainInfo.expiryDate,
|
||||||
|
daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start renewal process
|
||||||
|
this.obtainCertificate(domain, true).catch(err => {
|
||||||
|
console.error(`Error renewing certificate for ${domain}:`, err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Emits a certificate event with the certificate data
|
||||||
|
* @param eventType The event type to emit
|
||||||
|
* @param data The certificate data
|
||||||
|
*/
|
||||||
|
private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void {
|
||||||
|
this.emit(eventType, data);
|
||||||
|
}
|
||||||
|
}
|
@ -1,33 +1,359 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
export class ProxyRouter {
|
/**
|
||||||
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
* Optional path pattern configuration that can be added to proxy configs
|
||||||
|
*/
|
||||||
|
export interface IPathPatternConfig {
|
||||||
|
pathPattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sets a new set of reverse configs to be routed to
|
* Interface for router result with additional metadata
|
||||||
* @param reverseCandidatesArg
|
*/
|
||||||
*/
|
export interface IRouterResult {
|
||||||
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
config: plugins.tsclass.network.IReverseProxyConfig;
|
||||||
this.reverseProxyConfigs = reverseCandidatesArg;
|
pathMatch?: string;
|
||||||
|
pathParams?: Record<string, string>;
|
||||||
|
pathRemainder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxyRouter {
|
||||||
|
// Using a Map for O(1) hostname lookups instead of array search
|
||||||
|
private hostMap: Map<string, plugins.tsclass.network.IReverseProxyConfig[]> = new Map();
|
||||||
|
// Store original configs for reference
|
||||||
|
private reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
||||||
|
// Default config to use when no match is found (optional)
|
||||||
|
private defaultConfig?: plugins.tsclass.network.IReverseProxyConfig;
|
||||||
|
// Store path patterns separately since they're not in the original interface
|
||||||
|
private pathPatterns: Map<plugins.tsclass.network.IReverseProxyConfig, string> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
configs?: plugins.tsclass.network.IReverseProxyConfig[],
|
||||||
|
private readonly logger: {
|
||||||
|
error: (message: string, data?: any) => void;
|
||||||
|
warn: (message: string, data?: any) => void;
|
||||||
|
info: (message: string, data?: any) => void;
|
||||||
|
debug: (message: string, data?: any) => void;
|
||||||
|
} = console
|
||||||
|
) {
|
||||||
|
if (configs) {
|
||||||
|
this.setNewProxyConfigs(configs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* routes a request
|
* Sets a new set of reverse configs to be routed to
|
||||||
|
* @param reverseCandidatesArg Array of reverse proxy configurations
|
||||||
|
*/
|
||||||
|
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]): void {
|
||||||
|
this.reverseProxyConfigs = [...reverseCandidatesArg];
|
||||||
|
|
||||||
|
// Reset the host map and path patterns
|
||||||
|
this.hostMap.clear();
|
||||||
|
this.pathPatterns.clear();
|
||||||
|
|
||||||
|
// Find default config if any (config with "*" as hostname)
|
||||||
|
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
|
||||||
|
|
||||||
|
// Group configs by hostname for faster lookups
|
||||||
|
for (const config of this.reverseProxyConfigs) {
|
||||||
|
// Skip the default config as it's stored separately
|
||||||
|
if (config.hostName === '*') continue;
|
||||||
|
|
||||||
|
const hostname = config.hostName.toLowerCase(); // Case-insensitive hostname lookup
|
||||||
|
|
||||||
|
if (!this.hostMap.has(hostname)) {
|
||||||
|
this.hostMap.set(hostname, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path pattern in extended properties
|
||||||
|
// (using any to access custom properties not in the interface)
|
||||||
|
const extendedConfig = config as any;
|
||||||
|
if (extendedConfig.pathPattern) {
|
||||||
|
this.pathPatterns.set(config, extendedConfig.pathPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to the list of configs for this hostname
|
||||||
|
this.hostMap.get(hostname).push(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort configs for each hostname by specificity
|
||||||
|
// More specific path patterns should be checked first
|
||||||
|
for (const [hostname, configs] of this.hostMap.entries()) {
|
||||||
|
if (configs.length > 1) {
|
||||||
|
// Sort by pathPattern - most specific first
|
||||||
|
// (null comes last, exact paths before patterns with wildcards)
|
||||||
|
configs.sort((a, b) => {
|
||||||
|
const aPattern = this.pathPatterns.get(a);
|
||||||
|
const bPattern = this.pathPatterns.get(b);
|
||||||
|
|
||||||
|
// If one has a path and the other doesn't, the one with a path comes first
|
||||||
|
if (!aPattern && bPattern) return 1;
|
||||||
|
if (aPattern && !bPattern) return -1;
|
||||||
|
if (!aPattern && !bPattern) return 0;
|
||||||
|
|
||||||
|
// Both have path patterns - more specific (longer) first
|
||||||
|
// This is a simple heuristic; we could use a more sophisticated approach
|
||||||
|
return bPattern.length - aPattern.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.hostMap.size} unique hosts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request based on hostname and path
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns The matching proxy config or undefined if no match found
|
||||||
*/
|
*/
|
||||||
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
|
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
|
||||||
|
const result = this.routeReqWithDetails(req);
|
||||||
|
return result ? result.config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request with detailed matching information
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns Detailed routing result including matched config and path information
|
||||||
|
*/
|
||||||
|
public routeReqWithDetails(req: plugins.http.IncomingMessage): IRouterResult | undefined {
|
||||||
|
// Extract and validate host header
|
||||||
const originalHost = req.headers.host;
|
const originalHost = req.headers.host;
|
||||||
if (!originalHost) {
|
if (!originalHost) {
|
||||||
console.error('No host header found in request');
|
this.logger.error('No host header found in request');
|
||||||
return undefined;
|
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
|
||||||
}
|
}
|
||||||
// Strip port from host if present
|
|
||||||
const hostWithoutPort = originalHost.split(':')[0];
|
// Parse URL for path matching
|
||||||
const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => {
|
const urlPath = new URL(
|
||||||
return reverseConfig.hostName === hostWithoutPort;
|
req.url || '/',
|
||||||
});
|
`http://${originalHost}`
|
||||||
if (!correspodingReverseProxyConfig) {
|
).pathname;
|
||||||
console.error(`No config found for host: ${hostWithoutPort}`);
|
|
||||||
|
// Extract hostname without port
|
||||||
|
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// Find configs for this hostname
|
||||||
|
const configs = this.hostMap.get(hostWithoutPort);
|
||||||
|
|
||||||
|
if (configs && configs.length > 0) {
|
||||||
|
// Check each config for path matching
|
||||||
|
for (const config of configs) {
|
||||||
|
// Get the path pattern if any
|
||||||
|
const pathPattern = this.pathPatterns.get(config);
|
||||||
|
|
||||||
|
// If no path pattern specified, this config matches all paths
|
||||||
|
if (!pathPattern) {
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path matches the pattern
|
||||||
|
const pathMatch = this.matchPath(urlPath, pathPattern);
|
||||||
|
if (pathMatch) {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
pathMatch: pathMatch.matched,
|
||||||
|
pathParams: pathMatch.params,
|
||||||
|
pathRemainder: pathMatch.remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return correspodingReverseProxyConfig;
|
|
||||||
|
// Try wildcard subdomains if no direct match found
|
||||||
|
// For example, if request is for sub.example.com, try *.example.com
|
||||||
|
const domainParts = hostWithoutPort.split('.');
|
||||||
|
if (domainParts.length > 2) {
|
||||||
|
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||||
|
const wildcardConfigs = this.hostMap.get(wildcardDomain);
|
||||||
|
|
||||||
|
if (wildcardConfigs && wildcardConfigs.length > 0) {
|
||||||
|
// Use the first matching wildcard config
|
||||||
|
// Could add path matching logic here as well
|
||||||
|
return { config: wildcardConfigs[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default config if available
|
||||||
|
if (this.defaultConfig) {
|
||||||
|
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
|
||||||
|
return { config: this.defaultConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`No config found for host: ${hostWithoutPort}`);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Sets a path pattern for an existing config
|
||||||
|
* @param config The existing configuration
|
||||||
|
* @param pathPattern The path pattern to set
|
||||||
|
* @returns Boolean indicating if the config was found and updated
|
||||||
|
*/
|
||||||
|
public setPathPattern(
|
||||||
|
config: plugins.tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern: string
|
||||||
|
): boolean {
|
||||||
|
const exists = this.reverseProxyConfigs.includes(config);
|
||||||
|
if (exists) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a URL path against a pattern
|
||||||
|
* Supports:
|
||||||
|
* - Exact matches: /users/profile
|
||||||
|
* - Wildcards: /api/* (matches any path starting with /api/)
|
||||||
|
* - Path parameters: /users/:id (captures id as a parameter)
|
||||||
|
*
|
||||||
|
* @param path The URL path to match
|
||||||
|
* @param pattern The pattern to match against
|
||||||
|
* @returns Match result with params and remainder, or null if no match
|
||||||
|
*/
|
||||||
|
private matchPath(path: string, pattern: string): {
|
||||||
|
matched: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
remainder: string;
|
||||||
|
} | null {
|
||||||
|
// Handle exact match
|
||||||
|
if (path === pattern) {
|
||||||
|
return {
|
||||||
|
matched: pattern,
|
||||||
|
params: {},
|
||||||
|
remainder: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard match
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
const prefix = pattern.slice(0, -2);
|
||||||
|
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
||||||
|
return {
|
||||||
|
matched: prefix,
|
||||||
|
params: {},
|
||||||
|
remainder: path.slice(prefix.length)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle path parameters
|
||||||
|
const patternParts = pattern.split('/');
|
||||||
|
const pathParts = path.split('/');
|
||||||
|
|
||||||
|
// Check if paths are compatible length
|
||||||
|
if (
|
||||||
|
// If pattern doesn't end with wildcard, paths must have the same number of parts
|
||||||
|
(!pattern.endsWith('/*') && patternParts.length !== pathParts.length) ||
|
||||||
|
// If pattern ends with wildcard, path must have at least as many parts as the pattern
|
||||||
|
(pattern.endsWith('/*') && pathParts.length < patternParts.length - 1)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const matchedParts: string[] = [];
|
||||||
|
|
||||||
|
// Compare path parts
|
||||||
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
|
const patternPart = patternParts[i];
|
||||||
|
|
||||||
|
// Handle wildcard at the end
|
||||||
|
if (patternPart === '*' && i === patternParts.length - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pathParts[i] doesn't exist, we've reached the end of the path
|
||||||
|
if (i >= pathParts.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathPart = pathParts[i];
|
||||||
|
|
||||||
|
// Handle parameter
|
||||||
|
if (patternPart.startsWith(':')) {
|
||||||
|
const paramName = patternPart.slice(1);
|
||||||
|
params[paramName] = pathPart;
|
||||||
|
matchedParts.push(pathPart);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exact match for this part
|
||||||
|
if (patternPart !== pathPart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedParts.push(pathPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the remainder
|
||||||
|
let remainder = '';
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
remainder = '/' + pathParts.slice(patternParts.length - 1).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: matchedParts.join('/'),
|
||||||
|
params,
|
||||||
|
remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all currently active proxy configurations
|
||||||
|
* @returns Array of all active configurations
|
||||||
|
*/
|
||||||
|
public getProxyConfigs(): plugins.tsclass.network.IReverseProxyConfig[] {
|
||||||
|
return [...this.reverseProxyConfigs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all hostnames that this router is configured to handle
|
||||||
|
* @returns Array of hostnames
|
||||||
|
*/
|
||||||
|
public getHostnames(): string[] {
|
||||||
|
return Array.from(this.hostMap.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a single new proxy configuration
|
||||||
|
* @param config The configuration to add
|
||||||
|
* @param pathPattern Optional path pattern for route matching
|
||||||
|
*/
|
||||||
|
public addProxyConfig(
|
||||||
|
config: plugins.tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern?: string
|
||||||
|
): void {
|
||||||
|
this.reverseProxyConfigs.push(config);
|
||||||
|
|
||||||
|
// Store path pattern if provided
|
||||||
|
if (pathPattern) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setNewProxyConfigs(this.reverseProxyConfigs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a proxy configuration by hostname
|
||||||
|
* @param hostname The hostname to remove
|
||||||
|
* @returns Boolean indicating whether any configs were removed
|
||||||
|
*/
|
||||||
|
public removeProxyConfig(hostname: string): boolean {
|
||||||
|
const initialCount = this.reverseProxyConfigs.length;
|
||||||
|
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName !== hostname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialCount !== this.reverseProxyConfigs.length) {
|
||||||
|
this.setNewProxyConfigs(this.reverseProxyConfigs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
// node native scope
|
// node native scope
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as tls from 'tls';
|
import * as tls from 'tls';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
|
||||||
export { http, https, net, tls, url };
|
|
||||||
|
export { EventEmitter, http, https, net, tls, url };
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
@ -22,9 +24,10 @@ import * as smartstring from '@push.rocks/smartstring';
|
|||||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
import * as acme from 'acme-client';
|
||||||
import prettyMs from 'pretty-ms';
|
import prettyMs from 'pretty-ms';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import wsDefault from 'ws';
|
import wsDefault from 'ws';
|
||||||
import { minimatch } from 'minimatch';
|
import { minimatch } from 'minimatch';
|
||||||
|
|
||||||
export { prettyMs, ws, wsDefault, minimatch };
|
export { acme, prettyMs, ws, wsDefault, minimatch };
|
||||||
|
Reference in New Issue
Block a user