Compare commits

...

26 Commits

Author SHA1 Message Date
50fab2e1c3 5.1.0
Some checks failed
Default (tags) / security (push) Successful in 29s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 22:04:37 +00:00
88a1891bcf feat(docs): docs: replace IPTablesProxy references with NfTablesProxy in README and examples, updating configuration options and diagrams for advanced nftables features 2025-03-18 22:04:37 +00:00
6b2765a429 5.0.0
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 21:55:09 +00:00
9b5b8225bc BREAKING CHANGE(nftables): Replace IPTablesProxy with NfTablesProxy and update module exports in index.ts 2025-03-18 21:55:09 +00:00
54e81b3c32 4.3.0
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 15:00:24 +00:00
b7b47cd11f feat(Port80Handler): Add glob pattern support for domain certificate management in Port80Handler. Wildcard domains are now detected and skipped in certificate issuance and retrieval, ensuring that only explicit domains receive ACME certificates and improving route matching. 2025-03-18 15:00:24 +00:00
62061517fd 4.2.6
Some checks failed
Default (tags) / security (push) Successful in 21s
Default (tags) / test (push) Failing after 58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 14:56:57 +00:00
531350a1c1 fix(Port80Handler): Restrict ACME HTTP-01 challenge handling to domains with acmeMaintenance or acmeForward enabled 2025-03-18 14:56:57 +00:00
559a52af41 4.2.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 14:53:39 +00:00
f8c86c76ae fix(networkproxy): Refactor certificate management components: rename AcmeCertManager to Port80Handler and update related event names from CertManagerEvents to Port80HandlerEvents. The changes update internal API usage in ts/classes.networkproxy.ts and ts/classes.port80handler.ts to unify and simplify ACME certificate handling and HTTP-01 challenge management. 2025-03-18 14:53:39 +00:00
cc04e8786c 4.2.4
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 12:49:52 +00:00
9cb6e397b9 fix(ts/index.ts): Fix export order in ts/index.ts by moving the port proxy export back and adding interfaces export for proper module exposure 2025-03-18 12:49:52 +00:00
11b65bf684 4.2.3
Some checks failed
Default (tags) / security (push) Successful in 21s
Default (tags) / test (push) Failing after 58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 00:32:01 +00:00
4b30e377b9 fix(connectionhandler): Remove unnecessary delay in TLS session ticket handling for connections without SNI 2025-03-18 00:32:01 +00:00
b10f35be4b 4.2.2
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 1m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-18 00:29:17 +00:00
426249e70e fix(connectionhandler): Ensure proper termination of TLS connections without SNI by explicitly ending the socket after sending the unrecognized_name alert. This prevents the connection from hanging and avoids potential duplicate handling. 2025-03-18 00:29:17 +00:00
ba0d9d0b8e 4.2.1
Some checks failed
Default (tags) / security (push) Failing after 14m48s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-17 14:28:09 +00:00
151b8f498c fix(core): No uncommitted changes detected in the project. 2025-03-17 14:28:08 +00:00
0db4b07b22 4.2.0
Some checks failed
Default (tags) / security (push) Successful in 58s
Default (tags) / test (push) Failing after 14m46s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-17 14:27:10 +00:00
b55e2da23e feat(tlsalert): add sendForceSniSequence and sendFatalAndClose helper functions to TlsAlert for improved SNI enforcement 2025-03-17 14:27:10 +00:00
3593e411cf 4.1.16
Some checks failed
Default (tags) / security (push) Successful in 29s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-17 13:37:48 +00:00
ca6f6de798 fix(tls): Improve TLS alert handling in connection handler: use the new TlsAlert class to send proper unrecognized_name alerts when a ClientHello is missing SNI and wait for a retry on the same connection before closing. Also, add alertFallbackTimeout tracking to connection records for better timeout management. 2025-03-17 13:37:48 +00:00
80d2f30804 4.1.15
Some checks failed
Default (tags) / security (push) Failing after 14m48s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-17 13:23:07 +00:00
22f46700f1 fix(connectionhandler): Delay socket termination in TLS session resumption handling to allow proper alert processing 2025-03-17 13:23:07 +00:00
1611f65455 4.1.14
Some checks failed
Default (tags) / security (push) Successful in 21s
Default (tags) / test (push) Failing after 1m9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-17 13:19:18 +00:00
c6350e271a fix(ConnectionHandler): Use the correct TLS alert data and increase the delay before socket termination when session resumption without SNI is detected. 2025-03-17 13:19:18 +00:00
12 changed files with 3031 additions and 1135 deletions

View File

@ -1,5 +1,91 @@
# Changelog # Changelog
## 2025-03-18 - 5.1.0 - feat(docs)
docs: replace IPTablesProxy references with NfTablesProxy in README and examples, updating configuration options and diagrams for advanced nftables features
- Updated README diagrams to reflect nftables integration for low-level port forwarding.
- Replaced all occurrences of 'IPTablesProxy' with 'NfTablesProxy' in documentation and code examples.
- Included additional details on QoS, advanced NAT, and IP set options in the configuration options section.
## 2025-03-18 - 5.0.0 - BREAKING CHANGE(nftables)
Replace IPTablesProxy with NfTablesProxy and update module exports in index.ts
- Removed ts/classes.iptablesproxy.ts
- Added ts/classes.nftablesproxy.ts for enhanced nftables integration
- Updated ts/index.ts to export NfTablesProxy instead of IPTablesProxy
## 2025-03-18 - 4.3.0 - feat(Port80Handler)
Add glob pattern support for domain certificate management in Port80Handler. Wildcard domains are now detected and skipped in certificate issuance and retrieval, ensuring that only explicit domains receive ACME certificates and improving route matching.
- Introduced isGlobPattern to detect wildcard domains.
- Added getDomainInfoForRequest and domainMatchesPattern methods to enable glob pattern matching for domain configurations.
- Modified setCertificate and getCertificate to prevent certificate operations for glob patterns.
- Updated request handling to skip ACME challenge processing and certificate issuance for wildcard domains.
- Updated documentation and tests to reflect the new glob pattern support.
## 2025-03-18 - 4.2.6 - fix(Port80Handler)
Restrict ACME HTTP-01 challenge handling to domains with acmeMaintenance or acmeForward enabled
- Updated challenge handler in ts/classes.port80handler.ts to include a check for (options.acmeMaintenance || options.acmeForward)
- Prevents unintended processing of ACME challenges when ACME configuration is not enabled
## 2025-03-18 - 4.2.5 - fix(networkproxy)
Refactor certificate management components: rename AcmeCertManager to Port80Handler and update related event names from CertManagerEvents to Port80HandlerEvents. The changes update internal API usage in ts/classes.networkproxy.ts and ts/classes.port80handler.ts to unify and simplify ACME certificate handling and HTTP-01 challenge management.
- Renamed AcmeCertManager to Port80Handler in ts/classes.networkproxy.ts
- Updated event names from CertManagerEvents to Port80HandlerEvents
- Modified API calls for certificate issuance and renewal in ts/classes.port80handler.ts
- Refactored domain registration and certificate extraction logic
## 2025-03-18 - 4.2.4 - fix(ts/index.ts)
Fix export order in ts/index.ts by moving the port proxy export back and adding interfaces export for proper module exposure
- Reorder exports to place './classes.pp.portproxy.js' in the correct position
- Add export for './classes.pp.interfaces.js' to expose internal interfaces
## 2025-03-18 - 4.2.3 - fix(connectionhandler)
Remove unnecessary delay in TLS session ticket handling for connections without SNI
- Eliminated the extra setTimeout waiting period before cleaning up connections flagged as session_ticket_blocked_no_sni
- Ensures immediate cleanup and improves connection responsiveness during TLS handshake failures
## 2025-03-18 - 4.2.2 - fix(connectionhandler)
Ensure proper termination of TLS connections without SNI by explicitly ending the socket after sending the unrecognized_name alert. This prevents the connection from hanging and avoids potential duplicate handling.
- Added socket.end() after uncorking the alert packet in ClientHello handling to force connection closure.
- Prevents duplicate data events and ensures the warning alert is processed by clients like Chrome.
## 2025-03-17 - 4.2.1 - fix(core)
No uncommitted changes detected in the project.
## 2025-03-17 - 4.2.0 - feat(tlsalert)
add sendForceSniSequence and sendFatalAndClose helper functions to TlsAlert for improved SNI enforcement
- Introduce sendForceSniSequence to combine multiple alerts and force clients to provide SNI
- Add sendFatalAndClose to immediately send a fatal alert and close the connection
- Enhance TLS alert handling for better browser compatibility and error management
## 2025-03-17 - 4.1.16 - fix(tls)
Improve TLS alert handling in connection handler: use the new TlsAlert class to send proper unrecognized_name alerts when a ClientHello is missing SNI and wait for a retry on the same connection before closing. Also, add alertFallbackTimeout tracking to connection records for better timeout management.
- Replaced hardcoded alert buffers in ConnectionHandler with calls to the TlsAlert class.
- Removed old warnings and implemented a mechanism to remove existing 'data' listeners and await a new ClientHello.
- Introduced alertFallbackTimeout property in connection records to track fallback timeout and ensure proper cleanup.
- Extended the delay before closing the connection after sending an alert, providing the client more time to retry.
## 2025-03-17 - 4.1.15 - fix(connectionhandler)
Delay socket termination in TLS session resumption handling to allow proper alert processing
- Removed the immediate socket.end() call in finishConnection and moved it inside the setTimeout, ensuring that clients (especially Chrome) have additional time to process the TLS alert before connection termination
- This prevents premature socket closure on ClientHello without SNI when session tickets are disallowed
## 2025-03-17 - 4.1.14 - fix(ConnectionHandler)
Use the correct TLS alert data and increase the delay before socket termination when session resumption without SNI is detected.
- Replaced certificateExpiredAlert with serverNameUnknownAlertData for sending the appropriate alert.
- Increased the cleanup delay from 1000ms to 5000ms to allow a more graceful termination.
## 2025-03-17 - 4.1.13 - fix(tls-handshake) ## 2025-03-17 - 4.1.13 - fix(tls-handshake)
Set certificate_expired TLS alert level to warning instead of fatal to allow graceful termination. Set certificate_expired TLS alert level to warning instead of fatal to allow graceful termination.

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "4.1.13", "version": "5.1.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, dynamic routing with authentication options, and automatic ACME certificate management.", "description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

104
readme.md
View File

@ -16,7 +16,7 @@ flowchart TB
HTTP80[HTTP Port 80\nSslRedirect] HTTP80[HTTP Port 80\nSslRedirect]
HTTPS443[HTTPS Port 443\nNetworkProxy] HTTPS443[HTTPS Port 443\nNetworkProxy]
PortProxy[TCP Port Proxy\nwith SNI routing] PortProxy[TCP Port Proxy\nwith SNI routing]
IPTables[IPTablesProxy] NfTables[NfTablesProxy]
Router[ProxyRouter] Router[ProxyRouter]
ACME[Port80Handler\nACME/Let's Encrypt] ACME[Port80Handler\nACME/Let's Encrypt]
Certs[(SSL Certificates)] Certs[(SSL Certificates)]
@ -40,7 +40,7 @@ flowchart TB
PortProxy -->|Direct TCP| Service2 PortProxy -->|Direct TCP| Service2
PortProxy -->|Direct TCP| Service3 PortProxy -->|Direct TCP| Service3
IPTables -.->|Low-level forwarding| PortProxy NfTables -.->|Low-level forwarding| PortProxy
HTTP80 -.->|Challenge Response| ACME HTTP80 -.->|Challenge Response| ACME
ACME -.->|Generate/Manage| Certs ACME -.->|Generate/Manage| Certs
@ -197,7 +197,7 @@ sequenceDiagram
- **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 - **NfTables Integration** - Direct manipulation of nftables for advanced 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 with configurable timeouts - **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues - **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues
@ -315,13 +315,13 @@ const portProxy = new PortProxy({
portProxy.start(); portProxy.start();
``` ```
### IPTables Port Forwarding ### NfTables Port Forwarding
```typescript ```typescript
import { IPTablesProxy } from '@push.rocks/smartproxy'; import { NfTablesProxy } from '@push.rocks/smartproxy';
// Basic usage - forward single port // Basic usage - forward single port
const basicProxy = new IPTablesProxy({ const basicProxy = new NfTablesProxy({
fromPort: 80, fromPort: 80,
toPort: 8080, toPort: 8080,
toHost: 'localhost', toHost: 'localhost',
@ -330,7 +330,7 @@ const basicProxy = new IPTablesProxy({
}); });
// Forward port ranges // Forward port ranges
const rangeProxy = new IPTablesProxy({ const rangeProxy = new NfTablesProxy({
fromPort: { from: 3000, to: 3010 }, // Forward ports 3000-3010 fromPort: { from: 3000, to: 3010 }, // Forward ports 3000-3010
toPort: { from: 8000, to: 8010 }, // To ports 8000-8010 toPort: { from: 8000, to: 8010 }, // To ports 8000-8010
protocol: 'tcp', // TCP protocol (default) protocol: 'tcp', // TCP protocol (default)
@ -339,19 +339,26 @@ const rangeProxy = new IPTablesProxy({
}); });
// Multiple port specifications with IP filtering // Multiple port specifications with IP filtering
const advancedProxy = new IPTablesProxy({ const advancedProxy = new NfTablesProxy({
fromPort: [80, 443, { from: 8000, to: 8010 }], // Multiple ports/ranges fromPort: [80, 443, { from: 8000, to: 8010 }], // Multiple ports/ranges
toPort: [8080, 8443, { from: 18000, to: 18010 }], toPort: [8080, 8443, { from: 18000, to: 18010 }],
allowedSourceIPs: ['10.0.0.0/8', '192.168.1.0/24'], // Only allow these IPs allowedSourceIPs: ['10.0.0.0/8', '192.168.1.0/24'], // Only allow these IPs
bannedSourceIPs: ['192.168.1.100'], // Explicitly block these IPs bannedSourceIPs: ['192.168.1.100'], // Explicitly block these IPs
addJumpRule: true, // Use custom chain for better management useIPSets: true, // Use IP sets for efficient IP management
checkExistingRules: true // Check for duplicate rules forceCleanSlate: false // Clean all NfTablesProxy rules before starting
}); });
// NetworkProxy integration for SSL termination // Advanced features: QoS, connection tracking, and NetworkProxy integration
const sslProxy = new IPTablesProxy({ const advancedProxy = new NfTablesProxy({
fromPort: 443, fromPort: 443,
toPort: 8443, toPort: 8443,
toHost: 'localhost',
useAdvancedNAT: true, // Use connection tracking for stateful NAT
qos: {
enabled: true,
maxRate: '10mbps', // Limit bandwidth
priority: 1 // Set traffic priority (1-10)
},
netProxyIntegration: { netProxyIntegration: {
enabled: true, enabled: true,
redirectLocalhost: true, // Redirect localhost traffic to NetworkProxy redirectLocalhost: true, // Redirect localhost traffic to NetworkProxy
@ -372,8 +379,25 @@ import { Port80Handler } from '@push.rocks/smartproxy';
const acmeHandler = new Port80Handler(); const acmeHandler = new Port80Handler();
// Add domains to manage certificates for // Add domains to manage certificates for
acmeHandler.addDomain('example.com'); acmeHandler.addDomain({
acmeHandler.addDomain('api.example.com'); domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true
});
acmeHandler.addDomain({
domainName: 'api.example.com',
sslRedirect: true,
acmeMaintenance: true
});
// Support for glob pattern domains for routing (certificates not issued for glob patterns)
acmeHandler.addDomain({
domainName: '*.example.com',
sslRedirect: true,
acmeMaintenance: false, // Can't issue certificates for wildcard domains via HTTP-01
forward: { ip: '192.168.1.10', port: 8080 } // Forward requests to this target
});
``` ```
## Configuration Options ## Configuration Options
@ -412,7 +436,7 @@ acmeHandler.addDomain('api.example.com');
| `enableDetailedLogging` | Enable detailed connection logging | false | | `enableDetailedLogging` | Enable detailed connection logging | false |
| `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true | | `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true |
### IPTablesProxy Settings ### NfTablesProxy Settings
| Option | Description | Default | | Option | Description | Default |
|-----------------------|---------------------------------------------------|-------------| |-----------------------|---------------------------------------------------|-------------|
@ -420,18 +444,32 @@ acmeHandler.addDomain('api.example.com');
| `toPort` | Destination port(s) or range(s) to forward to | - | | `toPort` | Destination port(s) or range(s) to forward to | - |
| `toHost` | Destination host to forward to | 'localhost' | | `toHost` | Destination host to forward to | 'localhost' |
| `preserveSourceIP` | Preserve the original client IP | false | | `preserveSourceIP` | Preserve the original client IP | false |
| `deleteOnExit` | Remove iptables rules when process exits | false | | `deleteOnExit` | Remove nftables rules when process exits | false |
| `protocol` | Protocol to forward ('tcp', 'udp', or 'all') | 'tcp' | | `protocol` | Protocol to forward ('tcp', 'udp', or 'all') | 'tcp' |
| `enableLogging` | Enable detailed logging | false | | `enableLogging` | Enable detailed logging | false |
| `ipv6Support` | Enable IPv6 support with ip6tables | false | | `logFormat` | Format for logs ('plain' or 'json') | 'plain' |
| `ipv6Support` | Enable IPv6 support | false |
| `allowedSourceIPs` | Array of IP addresses/CIDR allowed to connect | - | | `allowedSourceIPs` | Array of IP addresses/CIDR allowed to connect | - |
| `bannedSourceIPs` | Array of IP addresses/CIDR blocked from connecting | - | | `bannedSourceIPs` | Array of IP addresses/CIDR blocked from connecting | - |
| `forceCleanSlate` | Clear all IPTablesProxy rules before starting | false | | `useIPSets` | Use nftables sets for efficient IP management | true |
| `addJumpRule` | Add a custom chain for cleaner rule management | false | | `forceCleanSlate` | Clear all NfTablesProxy rules before starting | false |
| `checkExistingRules` | Check if rules already exist before adding | true | | `tableName` | Custom table name | 'portproxy' |
| `maxRetries` | Maximum number of retries for failed commands | 3 |
| `retryDelayMs` | Delay between retries in milliseconds | 1000 |
| `useAdvancedNAT` | Use connection tracking for stateful NAT | false |
| `qos` | Quality of Service options (object) | - |
| `netProxyIntegration` | NetworkProxy integration options (object) | - | | `netProxyIntegration` | NetworkProxy integration options (object) | - |
#### IPTablesProxy NetworkProxy Integration Options #### NfTablesProxy QoS Options
| Option | Description | Default |
|----------------------|---------------------------------------------------|---------|
| `enabled` | Enable Quality of Service features | false |
| `maxRate` | Maximum bandwidth rate (e.g. "10mbps") | - |
| `priority` | Traffic priority (1-10, 1 is highest) | - |
| `markConnections` | Mark connections for easier management | false |
#### NfTablesProxy NetworkProxy Integration Options
| Option | Description | Default | | Option | Description | Default |
|----------------------|---------------------------------------------------|---------| |----------------------|---------------------------------------------------|---------|
@ -490,18 +528,30 @@ 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
### Enhanced IPTables Management ### Enhanced NfTables Management
The improved `IPTablesProxy` class offers advanced capabilities: The `NfTablesProxy` class offers advanced capabilities compared to the previous IPTablesProxy:
- Support for multiple port ranges and individual ports - Support for multiple port ranges and individual ports
- IPv6 support with ip6tables - More efficient IP filtering using nftables sets
- Source IP filtering with allow/block lists - IPv6 support with full feature parity
- Custom chain creation for better rule organization - Quality of Service (QoS) features including bandwidth limiting and traffic prioritization
- Advanced connection tracking for stateful NAT
- Robust error handling with retry mechanisms
- Structured logging with JSON support
- NetworkProxy integration for SSL termination - NetworkProxy integration for SSL termination
- Automatic rule existence checking to prevent duplicates
- Comprehensive cleanup on shutdown - Comprehensive cleanup on shutdown
### Port80Handler with Glob Pattern Support
The `Port80Handler` class now includes support for glob pattern domain matching:
- Supports wildcard domains like `*.example.com` for HTTP request routing
- Detects glob patterns and skips certificate issuance for them
- Smart routing that first attempts exact matches, then tries pattern matching
- Supports forwarding HTTP requests to backend services
- Separate forwarding configuration for ACME challenges
## Troubleshooting ## Troubleshooting
### Browser Certificate Errors ### Browser Certificate Errors

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '4.1.13', version: '5.1.0',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
} }

View File

@ -1,901 +0,0 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Represents a port range for forwarding
*/
export interface IPortRange {
from: number;
to: number;
}
/**
* Settings for IPTablesProxy.
*/
export interface IIpTableProxySettings {
// Basic settings
fromPort: number | IPortRange | Array<number | IPortRange>; // Support single port, port range, or multiple ports/ranges
toPort: number | IPortRange | Array<number | IPortRange>;
toHost?: string; // Target host for proxying; defaults to 'localhost'
// Advanced settings
preserveSourceIP?: boolean; // If true, the original source IP is preserved
deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit
protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp'
enableLogging?: boolean; // Enable detailed logging
ipv6Support?: boolean; // Enable IPv6 support (ip6tables)
// Source filtering
allowedSourceIPs?: string[]; // If provided, only these IPs are allowed
bannedSourceIPs?: string[]; // If provided, these IPs are blocked
// Rule management
forceCleanSlate?: boolean; // Clear all IPTablesProxy rules before starting
addJumpRule?: boolean; // Add a custom chain for cleaner rule management
checkExistingRules?: boolean; // Check if rules already exist before adding
// Integration with PortProxy/NetworkProxy
netProxyIntegration?: {
enabled: boolean;
redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy
sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination
};
}
/**
* Represents a rule added to iptables
*/
interface IpTablesRule {
table: string;
chain: string;
command: string;
tag: string;
added: boolean;
}
/**
* IPTablesProxy sets up iptables NAT rules to forward TCP traffic.
* Enhanced with multi-port support, IPv6, and integration with PortProxy/NetworkProxy.
*/
export class IPTablesProxy {
public settings: IIpTableProxySettings;
private rules: IpTablesRule[] = [];
private ruleTag: string;
private customChain: string | null = null;
constructor(settings: IIpTableProxySettings) {
// Validate inputs to prevent command injection
this.validateSettings(settings);
// Set default settings
this.settings = {
...settings,
toHost: settings.toHost || 'localhost',
protocol: settings.protocol || 'tcp',
enableLogging: settings.enableLogging !== undefined ? settings.enableLogging : false,
ipv6Support: settings.ipv6Support !== undefined ? settings.ipv6Support : false,
checkExistingRules: settings.checkExistingRules !== undefined ? settings.checkExistingRules : true,
netProxyIntegration: settings.netProxyIntegration || { enabled: false }
};
// Generate a unique identifier for the rules added by this instance
this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
if (this.settings.addJumpRule) {
this.customChain = `IPTablesProxy_${Math.random().toString(36).substr(2, 5)}`;
}
// Register cleanup handlers if deleteOnExit is true
if (this.settings.deleteOnExit) {
const cleanup = () => {
try {
this.stopSync();
} catch (err) {
console.error('Error cleaning iptables rules on exit:', err);
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit();
});
process.on('SIGTERM', () => {
cleanup();
process.exit();
});
}
}
/**
* Validates settings to prevent command injection and ensure valid values
*/
private validateSettings(settings: IIpTableProxySettings): void {
// Validate port numbers
const validatePorts = (port: number | IPortRange | Array<number | IPortRange>) => {
if (Array.isArray(port)) {
port.forEach(p => validatePorts(p));
return;
}
if (typeof port === 'number') {
if (port < 1 || port > 65535) {
throw new Error(`Invalid port number: ${port}`);
}
} else if (typeof port === 'object') {
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
throw new Error(`Invalid port range: ${port.from}-${port.to}`);
}
}
};
validatePorts(settings.fromPort);
validatePorts(settings.toPort);
// Define regex patterns at the method level so they're available throughout
const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
// Validate IP addresses
const validateIPs = (ips?: string[]) => {
if (!ips) return;
for (const ip of ips) {
if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
throw new Error(`Invalid IP address format: ${ip}`);
}
}
};
validateIPs(settings.allowedSourceIPs);
validateIPs(settings.bannedSourceIPs);
// Validate toHost - only allow hostnames or IPs
if (settings.toHost) {
const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
throw new Error(`Invalid host format: ${settings.toHost}`);
}
}
}
/**
* Normalizes port specifications into an array of port ranges
*/
private normalizePortSpec(portSpec: number | IPortRange | Array<number | IPortRange>): IPortRange[] {
const result: IPortRange[] = [];
if (Array.isArray(portSpec)) {
// If it's an array, process each element
for (const spec of portSpec) {
result.push(...this.normalizePortSpec(spec));
}
} else if (typeof portSpec === 'number') {
// Single port becomes a range with the same start and end
result.push({ from: portSpec, to: portSpec });
} else {
// Already a range
result.push(portSpec);
}
return result;
}
/**
* Gets the appropriate iptables command based on settings
*/
private getIptablesCommand(isIpv6: boolean = false): string {
return isIpv6 ? 'ip6tables' : 'iptables';
}
/**
* Checks if a rule already exists in iptables
*/
private async ruleExists(table: string, command: string, isIpv6: boolean = false): Promise<boolean> {
try {
const iptablesCmd = this.getIptablesCommand(isIpv6);
const { stdout } = await execAsync(`${iptablesCmd}-save -t ${table}`);
// Convert the command to the format found in iptables-save output
// (This is a simplification - in reality, you'd need more parsing)
const rulePattern = command.replace(`${iptablesCmd} -t ${table} -A `, '-A ');
return stdout.split('\n').some(line => line.trim() === rulePattern);
} catch (err) {
this.log('error', `Failed to check if rule exists: ${err}`);
return false;
}
}
/**
* Sets up a custom chain for better rule management
*/
private async setupCustomChain(isIpv6: boolean = false): Promise<boolean> {
if (!this.customChain) return true;
const iptablesCmd = this.getIptablesCommand(isIpv6);
const table = 'nat';
try {
// Create the chain
await execAsync(`${iptablesCmd} -t ${table} -N ${this.customChain}`);
this.log('info', `Created custom chain: ${this.customChain}`);
// Add jump rule to PREROUTING chain
const jumpCommand = `${iptablesCmd} -t ${table} -A PREROUTING -j ${this.customChain} -m comment --comment "${this.ruleTag}:JUMP"`;
await execAsync(jumpCommand);
this.log('info', `Added jump rule to ${this.customChain}`);
// Store the jump rule
this.rules.push({
table,
chain: 'PREROUTING',
command: jumpCommand,
tag: `${this.ruleTag}:JUMP`,
added: true
});
return true;
} catch (err) {
this.log('error', `Failed to set up custom chain: ${err}`);
return false;
}
}
/**
* Add a source IP filter rule
*/
private async addSourceIPFilter(isIpv6: boolean = false): Promise<boolean> {
if (!this.settings.allowedSourceIPs && !this.settings.bannedSourceIPs) {
return true;
}
const iptablesCmd = this.getIptablesCommand(isIpv6);
const table = 'nat';
const chain = this.customChain || 'PREROUTING';
try {
// Add banned IPs first (explicit deny)
if (this.settings.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) {
for (const ip of this.settings.bannedSourceIPs) {
const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -j DROP -m comment --comment "${this.ruleTag}:BANNED"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${command}`);
continue;
}
await execAsync(command);
this.log('info', `Added banned IP rule: ${command}`);
this.rules.push({
table,
chain,
command,
tag: `${this.ruleTag}:BANNED`,
added: true
});
}
}
// Add allowed IPs (explicit allow)
if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) {
// First add a default deny for all
const denyAllCommand = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} -j DROP -m comment --comment "${this.ruleTag}:DENY_ALL"`;
// Add allow rules for specific IPs
for (const ip of this.settings.allowedSourceIPs) {
const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -p ${this.settings.protocol} -j ACCEPT -m comment --comment "${this.ruleTag}:ALLOWED"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${command}`);
continue;
}
await execAsync(command);
this.log('info', `Added allowed IP rule: ${command}`);
this.rules.push({
table,
chain,
command,
tag: `${this.ruleTag}:ALLOWED`,
added: true
});
}
// Now add the default deny after all allows
if (this.settings.checkExistingRules && await this.ruleExists(table, denyAllCommand, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${denyAllCommand}`);
} else {
await execAsync(denyAllCommand);
this.log('info', `Added default deny rule: ${denyAllCommand}`);
this.rules.push({
table,
chain,
command: denyAllCommand,
tag: `${this.ruleTag}:DENY_ALL`,
added: true
});
}
}
return true;
} catch (err) {
this.log('error', `Failed to add source IP filter rules: ${err}`);
return false;
}
}
/**
* Adds a port forwarding rule
*/
private async addPortForwardingRule(
fromPortRange: IPortRange,
toPortRange: IPortRange,
isIpv6: boolean = false
): Promise<boolean> {
const iptablesCmd = this.getIptablesCommand(isIpv6);
const table = 'nat';
const chain = this.customChain || 'PREROUTING';
try {
// Handle single port case
if (fromPortRange.from === fromPortRange.to && toPortRange.from === toPortRange.to) {
// Single port forward
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from} ` +
`-m comment --comment "${this.ruleTag}:DNAT"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${command}`);
} else {
await execAsync(command);
this.log('info', `Added port forwarding rule: ${command}`);
this.rules.push({
table,
chain,
command,
tag: `${this.ruleTag}:DNAT`,
added: true
});
}
} else if (fromPortRange.to - fromPortRange.from === toPortRange.to - toPortRange.from) {
// Port range forward with equal ranges
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from}:${fromPortRange.to} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from}-${toPortRange.to} ` +
`-m comment --comment "${this.ruleTag}:DNAT_RANGE"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${command}`);
} else {
await execAsync(command);
this.log('info', `Added port range forwarding rule: ${command}`);
this.rules.push({
table,
chain,
command,
tag: `${this.ruleTag}:DNAT_RANGE`,
added: true
});
}
} else {
// Unequal port ranges need individual rules
for (let i = 0; i <= fromPortRange.to - fromPortRange.from; i++) {
const fromPort = fromPortRange.from + i;
const toPort = toPortRange.from + i % (toPortRange.to - toPortRange.from + 1);
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPort} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${toPort} ` +
`-m comment --comment "${this.ruleTag}:DNAT_INDIVIDUAL"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${command}`);
continue;
}
await execAsync(command);
this.log('info', `Added individual port forwarding rule: ${command}`);
this.rules.push({
table,
chain,
command,
tag: `${this.ruleTag}:DNAT_INDIVIDUAL`,
added: true
});
}
}
// If preserveSourceIP is false, add a MASQUERADE rule
if (!this.settings.preserveSourceIP) {
// For port range
const masqCommand = `${iptablesCmd} -t nat -A POSTROUTING -p ${this.settings.protocol} -d ${this.settings.toHost} ` +
`--dport ${toPortRange.from}:${toPortRange.to} -j MASQUERADE ` +
`-m comment --comment "${this.ruleTag}:MASQ"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists('nat', masqCommand, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${masqCommand}`);
} else {
await execAsync(masqCommand);
this.log('info', `Added MASQUERADE rule: ${masqCommand}`);
this.rules.push({
table: 'nat',
chain: 'POSTROUTING',
command: masqCommand,
tag: `${this.ruleTag}:MASQ`,
added: true
});
}
}
return true;
} catch (err) {
this.log('error', `Failed to add port forwarding rule: ${err}`);
// Try to roll back any rules that were already added
await this.rollbackRules();
return false;
}
}
/**
* Special handling for NetworkProxy integration
*/
private async setupNetworkProxyIntegration(isIpv6: boolean = false): Promise<boolean> {
if (!this.settings.netProxyIntegration?.enabled) {
return true;
}
const netProxyConfig = this.settings.netProxyIntegration;
const iptablesCmd = this.getIptablesCommand(isIpv6);
const table = 'nat';
const chain = this.customChain || 'PREROUTING';
try {
// If redirectLocalhost is true, set up special rule to redirect localhost traffic to NetworkProxy
if (netProxyConfig.redirectLocalhost && netProxyConfig.sslTerminationPort) {
const redirectCommand = `${iptablesCmd} -t ${table} -A OUTPUT -p tcp -d 127.0.0.1 -j REDIRECT ` +
`--to-port ${netProxyConfig.sslTerminationPort} ` +
`-m comment --comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
// Check if rule already exists
if (this.settings.checkExistingRules && await this.ruleExists(table, redirectCommand, isIpv6)) {
this.log('info', `Rule already exists, skipping: ${redirectCommand}`);
} else {
await execAsync(redirectCommand);
this.log('info', `Added NetworkProxy redirection rule: ${redirectCommand}`);
this.rules.push({
table,
chain: 'OUTPUT',
command: redirectCommand,
tag: `${this.ruleTag}:NETPROXY_REDIRECT`,
added: true
});
}
}
return true;
} catch (err) {
this.log('error', `Failed to set up NetworkProxy integration: ${err}`);
return false;
}
}
/**
* Rolls back rules that were added in case of error
*/
private async rollbackRules(): Promise<void> {
// Process rules in reverse order (LIFO)
for (let i = this.rules.length - 1; i >= 0; i--) {
const rule = this.rules[i];
if (rule.added) {
try {
// Convert -A (add) to -D (delete)
const deleteCommand = rule.command.replace('-A', '-D');
await execAsync(deleteCommand);
this.log('info', `Rolled back rule: ${deleteCommand}`);
rule.added = false;
} catch (err) {
this.log('error', `Failed to roll back rule: ${err}`);
}
}
}
}
/**
* Sets up iptables rules for port forwarding with enhanced features
*/
public async start(): Promise<void> {
// Optionally clean the slate first
if (this.settings.forceCleanSlate) {
await IPTablesProxy.cleanSlate();
}
// First set up any custom chains
if (this.settings.addJumpRule) {
const chainSetupSuccess = await this.setupCustomChain();
if (!chainSetupSuccess) {
throw new Error('Failed to set up custom chain');
}
// For IPv6 if enabled
if (this.settings.ipv6Support) {
const chainSetupSuccessIpv6 = await this.setupCustomChain(true);
if (!chainSetupSuccessIpv6) {
this.log('warn', 'Failed to set up IPv6 custom chain, continuing with IPv4 only');
}
}
}
// Add source IP filters
await this.addSourceIPFilter();
if (this.settings.ipv6Support) {
await this.addSourceIPFilter(true);
}
// Set up NetworkProxy integration if enabled
if (this.settings.netProxyIntegration?.enabled) {
const netProxySetupSuccess = await this.setupNetworkProxyIntegration();
if (!netProxySetupSuccess) {
this.log('warn', 'Failed to set up NetworkProxy integration');
}
if (this.settings.ipv6Support) {
await this.setupNetworkProxyIntegration(true);
}
}
// Normalize port specifications
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
// Handle the case where fromPort and toPort counts don't match
if (fromPortRanges.length !== toPortRanges.length) {
if (toPortRanges.length === 1) {
// If there's only one toPort, use it for all fromPorts
for (const fromRange of fromPortRanges) {
await this.addPortForwardingRule(fromRange, toPortRanges[0]);
if (this.settings.ipv6Support) {
await this.addPortForwardingRule(fromRange, toPortRanges[0], true);
}
}
} else {
throw new Error('Mismatched port counts: fromPort and toPort arrays must have equal length or toPort must be a single value');
}
} else {
// Add port forwarding rules for each port specification
for (let i = 0; i < fromPortRanges.length; i++) {
await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i]);
if (this.settings.ipv6Support) {
await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i], true);
}
}
}
// Final check - ensure we have at least one rule added
if (this.rules.filter(r => r.added).length === 0) {
throw new Error('No rules were added');
}
}
/**
* Removes all added iptables rules
*/
public async stop(): Promise<void> {
// Process rules in reverse order (LIFO)
for (let i = this.rules.length - 1; i >= 0; i--) {
const rule = this.rules[i];
if (rule.added) {
try {
// Convert -A (add) to -D (delete)
const deleteCommand = rule.command.replace('-A', '-D');
await execAsync(deleteCommand);
this.log('info', `Removed rule: ${deleteCommand}`);
rule.added = false;
} catch (err) {
this.log('error', `Failed to remove rule: ${err}`);
}
}
}
// If we created a custom chain, we need to clean it up
if (this.customChain) {
try {
// First flush the chain
await execAsync(`iptables -t nat -F ${this.customChain}`);
this.log('info', `Flushed custom chain: ${this.customChain}`);
// Then delete it
await execAsync(`iptables -t nat -X ${this.customChain}`);
this.log('info', `Deleted custom chain: ${this.customChain}`);
// Same for IPv6 if enabled
if (this.settings.ipv6Support) {
try {
await execAsync(`ip6tables -t nat -F ${this.customChain}`);
await execAsync(`ip6tables -t nat -X ${this.customChain}`);
this.log('info', `Deleted IPv6 custom chain: ${this.customChain}`);
} catch (err) {
this.log('error', `Failed to delete IPv6 custom chain: ${err}`);
}
}
} catch (err) {
this.log('error', `Failed to delete custom chain: ${err}`);
}
}
// Clear rules array
this.rules = [];
}
/**
* Synchronous version of stop, for use in exit handlers
*/
public stopSync(): void {
// Process rules in reverse order (LIFO)
for (let i = this.rules.length - 1; i >= 0; i--) {
const rule = this.rules[i];
if (rule.added) {
try {
// Convert -A (add) to -D (delete)
const deleteCommand = rule.command.replace('-A', '-D');
execSync(deleteCommand);
this.log('info', `Removed rule: ${deleteCommand}`);
rule.added = false;
} catch (err) {
this.log('error', `Failed to remove rule: ${err}`);
}
}
}
// If we created a custom chain, we need to clean it up
if (this.customChain) {
try {
// First flush the chain
execSync(`iptables -t nat -F ${this.customChain}`);
// Then delete it
execSync(`iptables -t nat -X ${this.customChain}`);
this.log('info', `Deleted custom chain: ${this.customChain}`);
// Same for IPv6 if enabled
if (this.settings.ipv6Support) {
try {
execSync(`ip6tables -t nat -F ${this.customChain}`);
execSync(`ip6tables -t nat -X ${this.customChain}`);
} catch (err) {
// IPv6 failures are non-critical
}
}
} catch (err) {
this.log('error', `Failed to delete custom chain: ${err}`);
}
}
// Clear rules array
this.rules = [];
}
/**
* Asynchronously cleans up any iptables rules in the nat table that were added by this module.
* It looks for rules with comments containing "IPTablesProxy:".
*/
public static async cleanSlate(): Promise<void> {
await IPTablesProxy.cleanSlateInternal();
// Also clean IPv6 rules
await IPTablesProxy.cleanSlateInternal(true);
}
/**
* Internal implementation of cleanSlate with IPv6 support
*/
private static async cleanSlateInternal(isIpv6: boolean = false): Promise<void> {
const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
try {
const { stdout } = await execAsync(`${iptablesCmd}-save -t nat`);
const lines = stdout.split('\n');
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
// First, find and remove any custom chains
const customChains = new Set<string>();
const jumpRules: string[] = [];
for (const line of proxyLines) {
if (line.includes('IPTablesProxy:JUMP')) {
// Extract chain name from jump rule
const match = line.match(/\s+-j\s+(\S+)\s+/);
if (match && match[1].startsWith('IPTablesProxy_')) {
customChains.add(match[1]);
jumpRules.push(line);
}
}
}
// Remove jump rules first
for (const line of jumpRules) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
// Replace the "-A" with "-D" to form a deletion command
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
try {
await execAsync(cmd);
console.log(`Cleaned up iptables jump rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
}
}
}
// Then remove all other rules
for (const line of proxyLines) {
if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
// Replace the "-A" with "-D" to form a deletion command
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
try {
await execAsync(cmd);
console.log(`Cleaned up iptables rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables rule: ${cmd}`, err);
}
}
}
}
// Finally clean up custom chains
for (const chain of customChains) {
try {
// Flush the chain
await execAsync(`${iptablesCmd} -t nat -F ${chain}`);
console.log(`Flushed custom chain: ${chain}`);
// Delete the chain
await execAsync(`${iptablesCmd} -t nat -X ${chain}`);
console.log(`Deleted custom chain: ${chain}`);
} catch (err) {
console.error(`Failed to delete custom chain ${chain}:`, err);
}
}
} catch (err) {
console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
}
}
/**
* Synchronously cleans up any iptables rules in the nat table that were added by this module.
* It looks for rules with comments containing "IPTablesProxy:".
* This method is intended for use in process exit handlers.
*/
public static cleanSlateSync(): void {
IPTablesProxy.cleanSlateSyncInternal();
// Also clean IPv6 rules
IPTablesProxy.cleanSlateSyncInternal(true);
}
/**
* Internal implementation of cleanSlateSync with IPv6 support
*/
private static cleanSlateSyncInternal(isIpv6: boolean = false): void {
const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
try {
const stdout = execSync(`${iptablesCmd}-save -t nat`).toString();
const lines = stdout.split('\n');
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
// First, find and remove any custom chains
const customChains = new Set<string>();
const jumpRules: string[] = [];
for (const line of proxyLines) {
if (line.includes('IPTablesProxy:JUMP')) {
// Extract chain name from jump rule
const match = line.match(/\s+-j\s+(\S+)\s+/);
if (match && match[1].startsWith('IPTablesProxy_')) {
customChains.add(match[1]);
jumpRules.push(line);
}
}
}
// Remove jump rules first
for (const line of jumpRules) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
// Replace the "-A" with "-D" to form a deletion command
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
try {
execSync(cmd);
console.log(`Cleaned up iptables jump rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
}
}
}
// Then remove all other rules
for (const line of proxyLines) {
if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
try {
execSync(cmd);
console.log(`Cleaned up iptables rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables rule: ${cmd}`, err);
}
}
}
}
// Finally clean up custom chains
for (const chain of customChains) {
try {
// Flush the chain
execSync(`${iptablesCmd} -t nat -F ${chain}`);
// Delete the chain
execSync(`${iptablesCmd} -t nat -X ${chain}`);
console.log(`Deleted custom chain: ${chain}`);
} catch (err) {
console.error(`Failed to delete custom chain ${chain}:`, err);
}
}
} catch (err) {
console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
}
}
/**
* Logging utility that respects the enableLogging setting
*/
private log(level: 'info' | 'warn' | 'error', message: string): void {
if (!this.settings.enableLogging && level === 'info') {
return;
}
const timestamp = new Date().toISOString();
switch (level) {
case 'info':
console.log(`[${timestamp}] [INFO] ${message}`);
break;
case 'warn':
console.warn(`[${timestamp}] [WARN] ${message}`);
break;
case 'error':
console.error(`[${timestamp}] [ERROR] ${message}`);
break;
}
}
}

View File

@ -1,6 +1,6 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { ProxyRouter } from './classes.router.js'; import { ProxyRouter } from './classes.router.js';
import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js'; import { Port80Handler, Port80HandlerEvents, type IDomainOptions } from './classes.port80handler.js';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@ -72,8 +72,8 @@ export class NetworkProxy {
private defaultCertificates: { key: string; cert: string }; private defaultCertificates: { key: string; cert: string };
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map(); private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
// ACME certificate manager // Port80Handler for certificate management
private certManager: AcmeCertManager | null = null; private port80Handler: Port80Handler | null = null;
private certificateStoreDir: string; private certificateStoreDir: string;
// New connection pool for backend connections // New connection pool for backend connections
@ -375,16 +375,16 @@ export class NetworkProxy {
} }
/** /**
* Initializes the ACME certificate manager for automatic certificate issuance * Initializes the Port80Handler for ACME certificate management
* @private * @private
*/ */
private async initializeAcmeManager(): Promise<void> { private async initializePort80Handler(): Promise<void> {
if (!this.options.acme.enabled) { if (!this.options.acme.enabled) {
return; return;
} }
// Create certificate manager // Create certificate manager
this.certManager = new AcmeCertManager({ this.port80Handler = new Port80Handler({
port: this.options.acme.port, port: this.options.acme.port,
contactEmail: this.options.acme.contactEmail, contactEmail: this.options.acme.contactEmail,
useProduction: this.options.acme.useProduction, useProduction: this.options.acme.useProduction,
@ -394,32 +394,32 @@ export class NetworkProxy {
}); });
// Register event handlers // Register event handlers
this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => { this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => {
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`); this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
}); });
// Start the manager // Start the handler
try { try {
await this.certManager.start(); await this.port80Handler.start();
this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`); this.log('info', `Port80Handler started on port ${this.options.acme.port}`);
// Add domains from proxy configs // Add domains from proxy configs
this.registerDomainsWithAcmeManager(); this.registerDomainsWithPort80Handler();
} catch (error) { } catch (error) {
this.log('error', `Failed to start ACME Certificate Manager: ${error}`); this.log('error', `Failed to start Port80Handler: ${error}`);
this.certManager = null; this.port80Handler = null;
} }
} }
/** /**
* Registers domains from proxy configs with the ACME manager * Registers domains from proxy configs with the Port80Handler
* @private * @private
*/ */
private registerDomainsWithAcmeManager(): void { private registerDomainsWithPort80Handler(): void {
if (!this.certManager) return; if (!this.port80Handler) return;
// Get all hostnames from proxy configs // Get all hostnames from proxy configs
this.proxyConfigs.forEach(config => { this.proxyConfigs.forEach(config => {
@ -461,26 +461,32 @@ export class NetworkProxy {
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`); this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
} }
// Update the certificate in the manager // Update the certificate in the handler
this.certManager.setCertificate(hostname, cert, key, expiryDate); this.port80Handler.setCertificate(hostname, cert, key, expiryDate);
// Also update our own certificate cache // Also update our own certificate cache
this.updateCertificateCache(hostname, cert, key, expiryDate); this.updateCertificateCache(hostname, cert, key, expiryDate);
this.log('info', `Loaded existing certificate for ${hostname}`); this.log('info', `Loaded existing certificate for ${hostname}`);
} else { } else {
// Register the domain for certificate issuance // Register the domain for certificate issuance with new domain options format
this.certManager.addDomain(hostname); const domainOptions: IDomainOptions = {
domainName: hostname,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`); this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
} }
} catch (error) { } catch (error) {
this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`); this.log('error', `Error registering domain ${hostname} with Port80Handler: ${error}`);
} }
}); });
} }
/** /**
* Handles newly issued or renewed certificates from ACME manager * Handles newly issued or renewed certificates from Port80Handler
* @private * @private
*/ */
private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void { private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void {
@ -556,13 +562,21 @@ export class NetworkProxy {
} }
// Check if we should trigger certificate issuance // Check if we should trigger certificate issuance
if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) { if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) {
// Check if this domain is already registered // Check if this domain is already registered
const certData = this.certManager.getCertificate(domain); const certData = this.port80Handler.getCertificate(domain);
if (!certData) { if (!certData) {
this.log('info', `No certificate found for ${domain}, registering for issuance`); this.log('info', `No certificate found for ${domain}, registering for issuance`);
this.certManager.addDomain(domain);
// Register with new domain options format
const domainOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
} }
} }
@ -587,9 +601,9 @@ export class NetworkProxy {
public async start(): Promise<void> { public async start(): Promise<void> {
this.startTime = Date.now(); this.startTime = Date.now();
// Initialize ACME certificate manager if enabled // Initialize Port80Handler if enabled
if (this.options.acme.enabled) { if (this.options.acme.enabled) {
await this.initializeAcmeManager(); await this.initializePort80Handler();
} }
// Create the HTTPS server // Create the HTTPS server
@ -1588,13 +1602,13 @@ export class NetworkProxy {
} }
this.connectionPool.clear(); this.connectionPool.clear();
// Stop ACME certificate manager if it's running // Stop Port80Handler if it's running
if (this.certManager) { if (this.port80Handler) {
try { try {
await this.certManager.stop(); await this.port80Handler.stop();
this.log('info', 'ACME Certificate Manager stopped'); this.log('info', 'Port80Handler stopped');
} catch (error) { } catch (error) {
this.log('error', 'Error stopping ACME Certificate Manager', error); this.log('error', 'Error stopping Port80Handler', error);
} }
} }
@ -1619,8 +1633,8 @@ export class NetworkProxy {
return false; return false;
} }
if (!this.certManager) { if (!this.port80Handler) {
this.log('error', 'ACME certificate manager is not initialized'); this.log('error', 'Port80Handler is not initialized');
return false; return false;
} }
@ -1631,7 +1645,14 @@ export class NetworkProxy {
} }
try { try {
this.certManager.addDomain(domain); // Use the new domain options format
const domainOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.log('info', `Certificate request submitted for domain: ${domain}`); this.log('info', `Certificate request submitted for domain: ${domain}`);
return true; return true;
} catch (error) { } catch (error) {

2045
ts/classes.nftablesproxy.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,58 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { IncomingMessage, ServerResponse } from 'http';
/** /**
* Represents a domain certificate with various status information * Custom error classes for better error handling
*/
export class Port80HandlerError extends Error {
constructor(message: string) {
super(message);
this.name = 'Port80HandlerError';
}
}
export class CertificateError extends Port80HandlerError {
constructor(
message: string,
public readonly domain: string,
public readonly isRenewal: boolean = false
) {
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
this.name = 'CertificateError';
}
}
export class ServerError extends Port80HandlerError {
constructor(message: string, public readonly code?: string) {
super(message);
this.name = 'ServerError';
}
}
/**
* Domain forwarding configuration
*/
export interface IForwardConfig {
ip: string;
port: number;
}
/**
* Domain configuration options
*/
export interface IDomainOptions {
domainName: string;
sslRedirect: boolean; // if true redirects the request to port 443
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
forward?: IForwardConfig; // forwards all http requests to that target
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
}
/**
* Represents a domain configuration with certificate status information
*/ */
interface IDomainCertificate { interface IDomainCertificate {
options: IDomainOptions;
certObtained: boolean; certObtained: boolean;
obtainingInProgress: boolean; obtainingInProgress: boolean;
certificate?: string; certificate?: string;
@ -15,9 +64,9 @@ interface IDomainCertificate {
} }
/** /**
* Configuration options for the ACME Certificate Manager * Configuration options for the Port80Handler
*/ */
interface IAcmeCertManagerOptions { interface IPort80HandlerOptions {
port?: number; port?: number;
contactEmail?: string; contactEmail?: string;
useProduction?: boolean; useProduction?: boolean;
@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions {
/** /**
* Certificate data that can be emitted via events or set from outside * Certificate data that can be emitted via events or set from outside
*/ */
interface ICertificateData { export interface ICertificateData {
domain: string; domain: string;
certificate: string; certificate: string;
privateKey: string; privateKey: string;
@ -37,34 +86,54 @@ interface ICertificateData {
} }
/** /**
* Events emitted by the ACME Certificate Manager * Events emitted by the Port80Handler
*/ */
export enum CertManagerEvents { export enum Port80HandlerEvents {
CERTIFICATE_ISSUED = 'certificate-issued', CERTIFICATE_ISSUED = 'certificate-issued',
CERTIFICATE_RENEWED = 'certificate-renewed', CERTIFICATE_RENEWED = 'certificate-renewed',
CERTIFICATE_FAILED = 'certificate-failed', CERTIFICATE_FAILED = 'certificate-failed',
CERTIFICATE_EXPIRING = 'certificate-expiring', CERTIFICATE_EXPIRING = 'certificate-expiring',
MANAGER_STARTED = 'manager-started', MANAGER_STARTED = 'manager-started',
MANAGER_STOPPED = 'manager-stopped', MANAGER_STOPPED = 'manager-stopped',
REQUEST_FORWARDED = 'request-forwarded',
} }
/** /**
* Improved ACME Certificate Manager with event emission and external certificate management * Certificate failure payload type
*/ */
export class AcmeCertManager extends plugins.EventEmitter { export interface ICertificateFailure {
domain: string;
error: string;
isRenewal: boolean;
}
/**
* Certificate expiry payload type
*/
export interface ICertificateExpiring {
domain: string;
expiryDate: Date;
daysRemaining: number;
}
/**
* Port80Handler with ACME certificate management and request forwarding capabilities
* Now with glob pattern support for domain matching
*/
export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>; private domainCertificates: Map<string, IDomainCertificate>;
private server: plugins.http.Server | null = null; private server: plugins.http.Server | null = null;
private acmeClient: plugins.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 renewalTimer: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
private options: Required<IAcmeCertManagerOptions>; private options: Required<IPort80HandlerOptions>;
/** /**
* Creates a new ACME Certificate Manager * Creates a new Port80Handler
* @param options Configuration options * @param options Configuration options
*/ */
constructor(options: IAcmeCertManagerOptions = {}) { constructor(options: IPort80HandlerOptions = {}) {
super(); super();
this.domainCertificates = new Map<string, IDomainCertificate>(); this.domainCertificates = new Map<string, IDomainCertificate>();
@ -73,7 +142,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
port: options.port ?? 80, port: options.port ?? 80,
contactEmail: options.contactEmail ?? 'admin@example.com', contactEmail: options.contactEmail ?? 'admin@example.com',
useProduction: options.useProduction ?? false, // Safer default: staging useProduction: options.useProduction ?? false, // Safer default: staging
renewThresholdDays: options.renewThresholdDays ?? 30, renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
httpsRedirectPort: options.httpsRedirectPort ?? 443, httpsRedirectPort: options.httpsRedirectPort ?? 443,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
}; };
@ -84,11 +153,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.server) { if (this.server) {
throw new Error('Server is already running'); throw new ServerError('Server is already running');
} }
if (this.isShuttingDown) { if (this.isShuttingDown) {
throw new Error('Server is shutting down'); throw new ServerError('Server is shutting down');
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -97,22 +166,39 @@ export class AcmeCertManager extends plugins.EventEmitter {
this.server.on('error', (error: NodeJS.ErrnoException) => { this.server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES') { 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.`)); reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
} else if (error.code === 'EADDRINUSE') { } else if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${this.options.port} is already in use.`)); reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
} else { } else {
reject(error); reject(new ServerError(error.message, error.code));
} }
}); });
this.server.listen(this.options.port, () => { this.server.listen(this.options.port, () => {
console.log(`AcmeCertManager is listening on port ${this.options.port}`); console.log(`Port80Handler is listening on port ${this.options.port}`);
this.startRenewalTimer(); this.startRenewalTimer();
this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port); this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
// Start certificate process for domains with acmeMaintenance enabled
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip glob patterns for certificate issuance
if (this.isGlobPattern(domain)) {
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
continue;
}
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
this.obtainCertificate(domain).catch(err => {
console.error(`Error obtaining initial certificate for ${domain}:`, err);
});
}
}
resolve(); resolve();
}); });
} catch (error) { } catch (error) {
reject(error); const message = error instanceof Error ? error.message : 'Unknown error starting server';
reject(new ServerError(message));
} }
}); });
} }
@ -138,7 +224,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
this.server.close(() => { this.server.close(() => {
this.server = null; this.server = null;
this.isShuttingDown = false; this.isShuttingDown = false;
this.emit(CertManagerEvents.MANAGER_STOPPED); this.emit(Port80HandlerEvents.MANAGER_STOPPED);
resolve(); resolve();
}); });
} else { } else {
@ -149,13 +235,41 @@ export class AcmeCertManager extends plugins.EventEmitter {
} }
/** /**
* Adds a domain to be managed for certificates * Adds a domain with configuration options
* @param domain The domain to add * @param options Domain configuration options
*/ */
public addDomain(domain: string): void { public addDomain(options: IDomainOptions): void {
if (!this.domainCertificates.has(domain)) { if (!options.domainName || typeof options.domainName !== 'string') {
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false }); throw new Port80HandlerError('Invalid domain name');
console.log(`Domain added: ${domain}`); }
const domainName = options.domainName;
if (!this.domainCertificates.has(domainName)) {
this.domainCertificates.set(domainName, {
options,
certObtained: false,
obtainingInProgress: false
});
console.log(`Domain added: ${domainName} with configuration:`, {
sslRedirect: options.sslRedirect,
acmeMaintenance: options.acmeMaintenance,
hasForward: !!options.forward,
hasAcmeForward: !!options.acmeForward
});
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
this.obtainCertificate(domainName).catch(err => {
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
});
}
} else {
// Update existing domain with new options
const existing = this.domainCertificates.get(domainName)!;
existing.options = options;
console.log(`Domain ${domainName} configuration updated`);
} }
} }
@ -177,10 +291,30 @@ export class AcmeCertManager extends plugins.EventEmitter {
* @param expiryDate Optional expiry date * @param expiryDate Optional expiry date
*/ */
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
if (!domain || !certificate || !privateKey) {
throw new Port80HandlerError('Domain, certificate and privateKey are required');
}
// Don't allow setting certificates for glob patterns
if (this.isGlobPattern(domain)) {
throw new Port80HandlerError('Cannot set certificate for glob pattern domains');
}
let domainInfo = this.domainCertificates.get(domain); let domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) { if (!domainInfo) {
domainInfo = { certObtained: false, obtainingInProgress: false }; // Create default domain options if not already configured
const defaultOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
domainInfo = {
options: defaultOptions,
certObtained: false,
obtainingInProgress: false
};
this.domainCertificates.set(domain, domainInfo); this.domainCertificates.set(domain, domainInfo);
} }
@ -192,27 +326,18 @@ export class AcmeCertManager extends plugins.EventEmitter {
if (expiryDate) { if (expiryDate) {
domainInfo.expiryDate = expiryDate; domainInfo.expiryDate = expiryDate;
} else { } else {
// Try to extract expiry date from certificate // Extract expiry date from certificate
try { domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
// 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}`); console.log(`Certificate set for ${domain}`);
// Emit certificate event // Emit certificate event
this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, { this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
domain, domain,
certificate, certificate,
privateKey, privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
}); });
} }
@ -221,6 +346,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
* @param domain The domain to get the certificate for * @param domain The domain to get the certificate for
*/ */
public getCertificate(domain: string): ICertificateData | null { public getCertificate(domain: string): ICertificateData | null {
// Can't get certificates for glob patterns
if (this.isGlobPattern(domain)) {
return null;
}
const domainInfo = this.domainCertificates.get(domain); const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) { if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
@ -231,10 +361,69 @@ export class AcmeCertManager extends plugins.EventEmitter {
domain, domain,
certificate: domainInfo.certificate, certificate: domainInfo.certificate,
privateKey: domainInfo.privateKey, privateKey: domainInfo.privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
}; };
} }
/**
* Check if a domain is a glob pattern
* @param domain Domain to check
* @returns True if the domain is a glob pattern
*/
private isGlobPattern(domain: string): boolean {
return domain.includes('*');
}
/**
* Get domain info for a specific domain, using glob pattern matching if needed
* @param requestDomain The actual domain from the request
* @returns The domain info or null if not found
*/
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
// Try direct match first
if (this.domainCertificates.has(requestDomain)) {
return {
domainInfo: this.domainCertificates.get(requestDomain)!,
pattern: requestDomain
};
}
// Then try glob patterns
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
return { domainInfo, pattern };
}
}
return null;
}
/**
* Check if a domain matches a glob pattern
* @param domain The domain to check
* @param pattern The pattern to match against
* @returns True if the domain matches the pattern
*/
private domainMatchesPattern(domain: string, pattern: string): boolean {
// Handle different glob pattern styles
if (pattern.startsWith('*.')) {
// *.example.com matches any subdomain
const suffix = pattern.substring(2);
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
} else if (pattern.endsWith('.*')) {
// example.* matches any TLD
const prefix = pattern.substring(0, pattern.length - 2);
const domainParts = domain.split('.');
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
} else if (pattern === '*') {
// Wildcard matches everything
return true;
} else {
// Exact match (shouldn't reach here as we check exact matches first)
return domain === pattern;
}
}
/** /**
* Lazy initialization of the ACME client * Lazy initialization of the ACME client
* @returns An ACME client instance * @returns An ACME client instance
@ -244,23 +433,28 @@ export class AcmeCertManager extends plugins.EventEmitter {
return this.acmeClient; return this.acmeClient;
} }
// Generate a new account key try {
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); // Generate a new account key
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
this.acmeClient = new plugins.acme.Client({
directoryUrl: this.options.useProduction this.acmeClient = new plugins.acme.Client({
? plugins.acme.directory.letsencrypt.production directoryUrl: this.options.useProduction
: plugins.acme.directory.letsencrypt.staging, ? plugins.acme.directory.letsencrypt.production
accountKey: this.accountKey, : plugins.acme.directory.letsencrypt.staging,
}); accountKey: this.accountKey,
});
// Create a new account
await this.acmeClient.createAccount({ // Create a new account
termsOfServiceAgreed: true, await this.acmeClient.createAccount({
contact: [`mailto:${this.options.contactEmail}`], termsOfServiceAgreed: true,
}); contact: [`mailto:${this.options.contactEmail}`],
});
return this.acmeClient;
return this.acmeClient;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
}
} }
/** /**
@ -279,22 +473,42 @@ export class AcmeCertManager extends plugins.EventEmitter {
// 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 // Get domain config, using glob pattern matching if needed
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { const domainMatch = this.getDomainInfoForRequest(domain);
this.handleAcmeChallenge(req, res, domain);
return; if (!domainMatch) {
}
if (!this.domainCertificates.has(domain)) {
res.statusCode = 404; res.statusCode = 404;
res.end('Domain not configured'); res.end('Domain not configured');
return; return;
} }
const domainInfo = this.domainCertificates.get(domain)!; const { domainInfo, pattern } = domainMatch;
const options = domainInfo.options;
// If certificate exists, redirect to HTTPS // If the request is for an ACME HTTP-01 challenge, handle it
if (domainInfo.certObtained) { if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) {
// Check if we should forward ACME requests
if (options.acmeForward) {
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
return;
}
// Only handle ACME challenges for non-glob patterns
if (!this.isGlobPattern(pattern)) {
this.handleAcmeChallenge(req, res, domain);
return;
}
}
// Check if we should forward non-ACME requests
if (options.forward) {
this.forwardRequest(req, res, options.forward, 'HTTP');
return;
}
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
// (Skip for glob patterns as they won't have certificates)
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
const httpsPort = this.options.httpsRedirectPort; const httpsPort = this.options.httpsRedirectPort;
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
@ -302,17 +516,94 @@ export class AcmeCertManager extends plugins.EventEmitter {
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 { return;
}
// Handle case where certificate maintenance is enabled but not yet obtained
// (Skip for glob patterns as they can't have certificates)
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
// Trigger certificate issuance if not already running // Trigger certificate issuance if not already running
if (!domainInfo.obtainingInProgress) { if (!domainInfo.obtainingInProgress) {
this.obtainCertificate(domain).catch(err => { this.obtainCertificate(domain).catch(err => {
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message }); const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain,
error: errorMessage,
isRenewal: false
});
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.');
return;
}
// Default response for unhandled request
res.statusCode = 404;
res.end('No handlers configured for this request');
}
/**
* Forwards an HTTP request to the specified target
* @param req The original request
* @param res The response object
* @param target The forwarding target (IP and port)
* @param requestType Type of request for logging
*/
private forwardRequest(
req: plugins.http.IncomingMessage,
res: plugins.http.ServerResponse,
target: IForwardConfig,
requestType: string
): void {
const options = {
hostname: target.ip,
port: target.port,
path: req.url,
method: req.method,
headers: { ...req.headers }
};
const domain = req.headers.host?.split(':')[0] || 'unknown';
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code
res.statusCode = proxyRes.statusCode || 500;
// Copy headers
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value) res.setHeader(key, value);
}
// Pipe response data
proxyRes.pipe(res);
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
domain,
requestType,
target: `${target.ip}:${target.port}`,
statusCode: proxyRes.statusCode
});
});
proxyReq.on('error', (error) => {
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
if (!res.headersSent) {
res.statusCode = 502;
res.end(`Proxy error: ${error.message}`);
} else {
res.end();
}
});
// Pipe original request to proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
} }
} }
@ -351,10 +642,21 @@ export class AcmeCertManager extends plugins.EventEmitter {
* @param isRenewal Whether this is a renewal attempt * @param isRenewal Whether this is a renewal attempt
*/ */
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> { private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
// Don't allow certificate issuance for glob patterns
if (this.isGlobPattern(domain)) {
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
}
// Get the domain info // Get the domain info
const domainInfo = this.domainCertificates.get(domain); const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) { if (!domainInfo) {
throw new Error(`Domain not found: ${domain}`); throw new CertificateError('Domain not found', domain, isRenewal);
}
// Verify that acmeMaintenance is enabled
if (!domainInfo.options.acmeMaintenance) {
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
return;
} }
// Prevent concurrent certificate issuance // Prevent concurrent certificate issuance
@ -377,40 +679,8 @@ export class AcmeCertManager extends plugins.EventEmitter {
// 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) { // Process each authorization
const challenge = authz.challenges.find(ch => ch.type === 'http-01'); await this.processAuthorizations(client, domain, authorizations);
if (!challenge) {
throw new Error('HTTP-01 challenge not found');
}
// Get the key authorization for the challenge
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
// Store the challenge data
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// ACME client type definition workaround - use compatible approach
// First check if challenge verification is needed
const authzUrl = authz.url;
try {
// Check if authzUrl exists and perform verification
if (authzUrl) {
await client.verifyChallenge(authz, challenge);
}
// 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 private key // Generate a CSR and private key
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
@ -436,28 +706,20 @@ export class AcmeCertManager extends plugins.EventEmitter {
delete domainInfo.challengeKeyAuthorization; delete domainInfo.challengeKeyAuthorization;
// Extract expiry date from certificate // Extract expiry date from certificate
try { domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
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 ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
// Emit the appropriate event // Emit the appropriate event
const eventType = isRenewal const eventType = isRenewal
? CertManagerEvents.CERTIFICATE_RENEWED ? Port80HandlerEvents.CERTIFICATE_RENEWED
: CertManagerEvents.CERTIFICATE_ISSUED; : Port80HandlerEvents.CERTIFICATE_ISSUED;
this.emitCertificateEvent(eventType, { this.emitCertificateEvent(eventType, {
domain, domain,
certificate, certificate,
privateKey, privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
}); });
} catch (error: any) { } catch (error: any) {
@ -473,17 +735,76 @@ export class AcmeCertManager extends plugins.EventEmitter {
} }
// Emit failure event // Emit failure event
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain, domain,
error: error.message || 'Unknown error', error: error.message || 'Unknown error',
isRenewal isRenewal
}); } as ICertificateFailure);
throw new CertificateError(
error.message || 'Certificate issuance failed',
domain,
isRenewal
);
} finally { } finally {
// Reset flag whether successful or not // Reset flag whether successful or not
domainInfo.obtainingInProgress = false; domainInfo.obtainingInProgress = false;
} }
} }
/**
* Process ACME authorizations by verifying and completing challenges
* @param client ACME client
* @param domain Domain name
* @param authorizations Authorizations to process
*/
private async processAuthorizations(
client: plugins.acme.Client,
domain: string,
authorizations: plugins.acme.Authorization[]
): Promise<void> {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
throw new CertificateError('Domain not found during authorization', domain);
}
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
if (!challenge) {
throw new CertificateError('HTTP-01 challenge not found', domain);
}
// Get the key authorization for the challenge
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
// Store the challenge data
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// ACME client type definition workaround - use compatible approach
// First check if challenge verification is needed
const authzUrl = authz.url;
try {
// Check if authzUrl exists and perform verification
if (authzUrl) {
await client.verifyChallenge(authz, challenge);
}
// Complete the challenge
await client.completeChallenge(challenge);
// Wait for validation
await client.waitForValidStatus(challenge);
console.log(`HTTP-01 challenge completed for ${domain}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
console.error(`Challenge error for ${domain}:`, error);
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
}
}
}
/** /**
* Starts the certificate renewal timer * Starts the certificate renewal timer
*/ */
@ -519,6 +840,16 @@ export class AcmeCertManager extends plugins.EventEmitter {
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
for (const [domain, domainInfo] of this.domainCertificates.entries()) { for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip glob patterns
if (this.isGlobPattern(domain)) {
continue;
}
// Skip domains with acmeMaintenance disabled
if (!domainInfo.options.acmeMaintenance) {
continue;
}
// Skip domains without certificates or already in renewal // Skip domains without certificates or already in renewal
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
continue; continue;
@ -534,26 +865,67 @@ export class AcmeCertManager extends plugins.EventEmitter {
// Check if certificate is near expiry // Check if certificate is near expiry
if (timeUntilExpiry <= renewThresholdMs) { if (timeUntilExpiry <= renewThresholdMs) {
console.log(`Certificate for ${domain} expires soon, renewing...`); console.log(`Certificate for ${domain} expires soon, renewing...`);
this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, {
const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000));
this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, {
domain, domain,
expiryDate: domainInfo.expiryDate, expiryDate: domainInfo.expiryDate,
daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)) daysRemaining
}); } as ICertificateExpiring);
// Start renewal process // Start renewal process
this.obtainCertificate(domain, true).catch(err => { this.obtainCertificate(domain, true).catch(err => {
console.error(`Error renewing certificate for ${domain}:`, err); const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.error(`Error renewing certificate for ${domain}:`, errorMessage);
}); });
} }
} }
} }
/**
* Extract expiry date from certificate using a more robust approach
* @param certificate Certificate PEM string
* @param domain Domain for logging
* @returns Extracted expiry date or default
*/
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
try {
// This is still using regex, but in a real implementation you would use
// a library like node-forge or x509 to properly parse the certificate
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
const expiryDate = new Date(matches[1]);
// Validate that we got a valid date
if (!isNaN(expiryDate.getTime())) {
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
return expiryDate;
}
}
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
return this.getDefaultExpiryDate();
} catch (error) {
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
return this.getDefaultExpiryDate();
}
}
/**
* Get a default expiry date (90 days from now)
* @returns Default expiry date
*/
private getDefaultExpiryDate(): Date {
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
}
/** /**
* Emits a certificate event with the certificate data * Emits a certificate event with the certificate data
* @param eventType The event type to emit * @param eventType The event type to emit
* @param data The certificate data * @param data The certificate data
*/ */
private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void { private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
this.emit(eventType, data); this.emit(eventType, data);
} }
} }

View File

@ -584,53 +584,16 @@ export class ConnectionHandler {
0x70, // unrecognized_name alert (code 112) 0x70, // unrecognized_name alert (code 112)
]); ]);
// Send a handshake_failure alert instead of unrecognized_name
const sslHandshakeFailureAlertData = Buffer.from([
0x15, // Alert record type
0x03,
0x03, // TLS 1.2 version
0x00,
0x02, // Length
0x01, // Warning alert level (not fatal)
0x28, // handshake_failure alert (40) instead of unrecognized_name (112)
]);
const closeNotifyAlert = Buffer.from([
0x15, // Alert record type
0x03,
0x03, // TLS 1.2 version
0x00,
0x02, // Length
0x01, // Warning alert level (1)
0x00, // close_notify alert (0)
]);
const certificateExpiredAlert = Buffer.from([
0x15, // Alert record type
0x03,
0x03, // TLS 1.2 version
0x00,
0x02, // Length
0x01, // Warning alert level (1)
0x2F, // certificate_expired alert (47)
]);
try { try {
// Use cork/uncork to ensure the alert is sent as a single packet // Use cork/uncork to ensure the alert is sent as a single packet
socket.cork(); socket.cork();
const writeSuccessful = socket.write(certificateExpiredAlert); const writeSuccessful = socket.write(serverNameUnknownAlertData);
socket.uncork(); socket.uncork();
socket.end();
// Function to handle the clean socket termination - but more gradually // Function to handle the clean socket termination - but more gradually
const finishConnection = () => { const finishConnection = () => {
// Give Chrome more time to process the alert before closing this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
// We won't call destroy() at all - just end() and let the socket close naturally
socket.end();
// Log the cleanup but wait for natural closure
setTimeout(() => {
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
}, 1000); // Longer delay to let socket cleanup happen naturally
}; };
if (writeSuccessful) { if (writeSuccessful) {

View File

@ -104,6 +104,7 @@ export interface IConnectionRecord {
lockedDomain?: string; // Used to lock this connection to the initial SNI lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
alertFallbackTimeout?: NodeJS.Timeout; // Timer for fallback after alert
lastActivity: number; // Last activity timestamp for inactivity detection lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup pendingData: Buffer[]; // Buffer to hold data during connection setup
pendingDataSize: number; // Track total size of pending data pendingDataSize: number; // Track total size of pending data

258
ts/classes.pp.tlsalert.ts Normal file
View File

@ -0,0 +1,258 @@
import * as net from 'net';
/**
* TlsAlert class for managing TLS alert messages
*/
export class TlsAlert {
// TLS Alert Levels
static readonly LEVEL_WARNING = 0x01;
static readonly LEVEL_FATAL = 0x02;
// TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2)
static readonly CLOSE_NOTIFY = 0x00;
static readonly UNEXPECTED_MESSAGE = 0x0A;
static readonly BAD_RECORD_MAC = 0x14;
static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only
static readonly RECORD_OVERFLOW = 0x16;
static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below
static readonly HANDSHAKE_FAILURE = 0x28;
static readonly NO_CERTIFICATE = 0x29; // SSLv3 only
static readonly BAD_CERTIFICATE = 0x2A;
static readonly UNSUPPORTED_CERTIFICATE = 0x2B;
static readonly CERTIFICATE_REVOKED = 0x2C;
static readonly CERTIFICATE_EXPIRED = 0x2F;
static readonly CERTIFICATE_UNKNOWN = 0x30;
static readonly ILLEGAL_PARAMETER = 0x2F;
static readonly UNKNOWN_CA = 0x30;
static readonly ACCESS_DENIED = 0x31;
static readonly DECODE_ERROR = 0x32;
static readonly DECRYPT_ERROR = 0x33;
static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only
static readonly PROTOCOL_VERSION = 0x46;
static readonly INSUFFICIENT_SECURITY = 0x47;
static readonly INTERNAL_ERROR = 0x50;
static readonly INAPPROPRIATE_FALLBACK = 0x56;
static readonly USER_CANCELED = 0x5A;
static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below
static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3
static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3
static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3
static readonly UNRECOGNIZED_NAME = 0x70;
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71;
static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below
static readonly UNKNOWN_PSK_IDENTITY = 0x73;
static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3
static readonly NO_APPLICATION_PROTOCOL = 0x78;
/**
* Create a TLS alert buffer with the specified level and description code
*
* @param level Alert level (warning or fatal)
* @param description Alert description code
* @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303)
* @returns Buffer containing the TLS alert message
*/
static create(
level: number,
description: number,
tlsVersion: [number, number] = [0x03, 0x03]
): Buffer {
return Buffer.from([
0x15, // Alert record type
tlsVersion[0],
tlsVersion[1], // TLS version (default to TLS 1.2: 0x0303)
0x00,
0x02, // Length
level, // Alert level
description, // Alert description
]);
}
/**
* Create a warning-level TLS alert
*
* @param description Alert description code
* @returns Buffer containing the warning-level TLS alert message
*/
static createWarning(description: number): Buffer {
return this.create(this.LEVEL_WARNING, description);
}
/**
* Create a fatal-level TLS alert
*
* @param description Alert description code
* @returns Buffer containing the fatal-level TLS alert message
*/
static createFatal(description: number): Buffer {
return this.create(this.LEVEL_FATAL, description);
}
/**
* Send a TLS alert to a socket and optionally close the connection
*
* @param socket The socket to send the alert to
* @param level Alert level (warning or fatal)
* @param description Alert description code
* @param closeAfterSend Whether to close the connection after sending the alert
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent
*/
static async send(
socket: net.Socket,
level: number,
description: number,
closeAfterSend: boolean = false,
closeDelay: number = 200
): Promise<void> {
const alert = this.create(level, description);
return new Promise<void>((resolve, reject) => {
try {
// Ensure the alert is written as a single packet
socket.cork();
const writeSuccessful = socket.write(alert, (err) => {
if (err) {
reject(err);
return;
}
if (closeAfterSend) {
setTimeout(() => {
socket.end();
resolve();
}, closeDelay);
} else {
resolve();
}
});
socket.uncork();
// If write wasn't successful immediately, wait for drain
if (!writeSuccessful && !closeAfterSend) {
socket.once('drain', () => {
resolve();
});
}
} catch (err) {
reject(err);
}
});
}
/**
* Pre-defined TLS alert messages
*/
static readonly alerts = {
// Warning level alerts
closeNotify: TlsAlert.createWarning(TlsAlert.CLOSE_NOTIFY),
unsupportedExtension: TlsAlert.createWarning(TlsAlert.UNSUPPORTED_EXTENSION),
certificateRequired: TlsAlert.createWarning(TlsAlert.CERTIFICATE_REQUIRED),
unrecognizedName: TlsAlert.createWarning(TlsAlert.UNRECOGNIZED_NAME),
noRenegotiation: TlsAlert.createWarning(TlsAlert.NO_RENEGOTIATION),
userCanceled: TlsAlert.createWarning(TlsAlert.USER_CANCELED),
// Warning level alerts for session resumption
certificateExpiredWarning: TlsAlert.createWarning(TlsAlert.CERTIFICATE_EXPIRED),
handshakeFailureWarning: TlsAlert.createWarning(TlsAlert.HANDSHAKE_FAILURE),
insufficientSecurityWarning: TlsAlert.createWarning(TlsAlert.INSUFFICIENT_SECURITY),
// Fatal level alerts
unexpectedMessage: TlsAlert.createFatal(TlsAlert.UNEXPECTED_MESSAGE),
badRecordMac: TlsAlert.createFatal(TlsAlert.BAD_RECORD_MAC),
recordOverflow: TlsAlert.createFatal(TlsAlert.RECORD_OVERFLOW),
handshakeFailure: TlsAlert.createFatal(TlsAlert.HANDSHAKE_FAILURE),
badCertificate: TlsAlert.createFatal(TlsAlert.BAD_CERTIFICATE),
certificateExpired: TlsAlert.createFatal(TlsAlert.CERTIFICATE_EXPIRED),
certificateUnknown: TlsAlert.createFatal(TlsAlert.CERTIFICATE_UNKNOWN),
illegalParameter: TlsAlert.createFatal(TlsAlert.ILLEGAL_PARAMETER),
unknownCA: TlsAlert.createFatal(TlsAlert.UNKNOWN_CA),
accessDenied: TlsAlert.createFatal(TlsAlert.ACCESS_DENIED),
decodeError: TlsAlert.createFatal(TlsAlert.DECODE_ERROR),
decryptError: TlsAlert.createFatal(TlsAlert.DECRYPT_ERROR),
protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION),
insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY),
internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR),
unrecognizedNameFatal: TlsAlert.createFatal(TlsAlert.UNRECOGNIZED_NAME),
};
/**
* Utility method to send a warning-level unrecognized_name alert
* Specifically designed for SNI issues to encourage the client to retry with SNI
*
* @param socket The socket to send the alert to
* @returns Promise that resolves when the alert has been sent
*/
static async sendSniRequired(socket: net.Socket): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.UNRECOGNIZED_NAME);
}
/**
* Utility method to send a close_notify alert and close the connection
*
* @param socket The socket to send the alert to
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent and the connection closed
*/
static async sendCloseNotify(socket: net.Socket, closeDelay: number = 200): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.CLOSE_NOTIFY, true, closeDelay);
}
/**
* Utility method to send a certificate_expired alert to force new TLS session
*
* @param socket The socket to send the alert to
* @param fatal Whether to send as a fatal alert (default: false)
* @param closeAfterSend Whether to close the connection after sending the alert (default: true)
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent
*/
static async sendCertificateExpired(
socket: net.Socket,
fatal: boolean = false,
closeAfterSend: boolean = true,
closeDelay: number = 200
): Promise<void> {
const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING;
return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay);
}
/**
* Send a sequence of alerts to force SNI from clients
* This combines multiple alerts to ensure maximum browser compatibility
*
* @param socket The socket to send the alerts to
* @returns Promise that resolves when all alerts have been sent
*/
static async sendForceSniSequence(socket: net.Socket): Promise<void> {
try {
// Send unrecognized_name (warning)
socket.cork();
socket.write(this.alerts.unrecognizedName);
socket.uncork();
// Give the socket time to send the alert
return new Promise((resolve) => {
setTimeout(resolve, 50);
});
} catch (err) {
return Promise.reject(err);
}
}
/**
* Send a fatal level alert that immediately terminates the connection
*
* @param socket The socket to send the alert to
* @param description Alert description code
* @param closeDelay Milliseconds to wait before closing the connection (default: 100ms)
* @returns Promise that resolves when the alert has been sent and the connection closed
*/
static async sendFatalAndClose(
socket: net.Socket,
description: number,
closeDelay: number = 100
): Promise<void> {
return this.send(socket, this.LEVEL_FATAL, description, true, closeDelay);
}
}

View File

@ -1,6 +1,7 @@
export * from './classes.iptablesproxy.js'; export * from './classes.nftablesproxy.js';
export * from './classes.networkproxy.js'; export * from './classes.networkproxy.js';
export * from './classes.pp.portproxy.js';
export * from './classes.port80handler.js'; export * from './classes.port80handler.js';
export * from './classes.sslredirect.js'; export * from './classes.sslredirect.js';
export * from './classes.pp.portproxy.js';
export * from './classes.pp.snihandler.js'; export * from './classes.pp.snihandler.js';
export * from './classes.pp.interfaces.js';