Compare commits

...

48 Commits

Author SHA1 Message Date
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
0fb5e5ea50 4.1.13
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-17 13:15:12 +00:00
35f6739b3c fix(tls-handshake): Set certificate_expired TLS alert level to warning instead of fatal to allow graceful termination. 2025-03-17 13:15:12 +00:00
4634c68ea6 4.1.12
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-17 13:09:54 +00:00
e126032b61 fix(classes.pp.connectionhandler): Replace unrecognized_name alert data with certificate_expired alert in TLS handshake handling for session resumption without SNI 2025-03-17 13:09:54 +00:00
7797c799dd 4.1.11
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-17 13:00:02 +00:00
e8639e1b01 fix(connectionhandler): Increase delay before cleaning up connections when session resumption is blocked due to missing SNI, allowing more natural socket termination. 2025-03-17 13:00:02 +00:00
60a0ad106d 4.1.10
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-16 14:49:25 +00:00
a70c123007 fix(connectionhandler): Increase delay timings for TLS alert transmission in session ticket blocking to allow graceful socket termination 2025-03-16 14:49:25 +00:00
46aa7620b0 4.1.9
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 1m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-16 14:13:36 +00:00
f72db86e37 fix(ConnectionHandler): Replace closeNotify alert with handshake failure alert in TLS ClientHello handling to properly signal missing SNI and enforce session ticket restrictions. 2025-03-16 14:13:35 +00:00
d612df107e 4.1.8
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 1m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-16 14:02:18 +00:00
1c34578c36 fix(ConnectionHandler/tls): Change the TLS alert sent when a ClientHello lacks SNI: use the close_notify alert instead of handshake_failure to prompt immediate retry with SNI. 2025-03-16 14:02:18 +00:00
1f9943b5a7 4.1.7
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-16 13:47:34 +00:00
67ddf97547 fix(classes.pp.connectionhandler): Improve TLS alert handling in ClientHello when SNI is missing and session tickets are disallowed 2025-03-16 13:47:34 +00:00
8a96b45ece 4.1.6
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-16 13:28:48 +00:00
2b6464acd5 fix(tls): Refine TLS ClientHello handling when allowSessionTicket is false by replacing extensive alert timeout logic with a concise warning alert and short delay, encouraging immediate client retry with proper SNI 2025-03-16 13:28:48 +00:00
efbb4335d7 4.1.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-16 13:19:37 +00:00
9dd402054d fix(TLS/ConnectionHandler): Improve handling of TLS session resumption without SNI by sending an unrecognized_name alert instead of immediately terminating the connection. This change adds a grace period for the client to retry the handshake with proper SNI and cleans up the connection if no valid response is received. 2025-03-16 13:19:37 +00:00
6c1efc1dc0 4.1.4
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-15 19:10:54 +00:00
cad0e6a2b2 fix(ConnectionHandler): Refactor ConnectionHandler code formatting for improved readability and consistency in log messages and whitespace handling 2025-03-15 19:10:54 +00:00
794e1292e5 4.1.3
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-15 18:51:50 +00:00
ee79f9ab7c fix(connectionhandler): Improve handling of TLS ClientHello messages when allowSessionTicket is disabled and no SNI is provided by sending a warning alert (unrecognized_name, code 0x70) with a proper callback and delay to ensure the alert is transmitted before closing the connection. 2025-03-15 18:51:50 +00:00
107bc3b50b 4.1.2
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 1m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-15 17:16:18 +00:00
97982976c8 fix(connectionhandler): Send proper TLS alert before terminating connections when SNI is missing and session tickets are disallowed. 2025-03-15 17:16:18 +00:00
fe60f88746 4.1.1
Some checks failed
Default (tags) / security (push) Failing after 12m44s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-15 17:00:11 +00:00
252a987344 fix(tls): Enforce strict SNI handling in TLS connections by terminating ClientHello messages lacking SNI when session tickets are disallowed and removing legacy session cache code. 2025-03-15 17:00:10 +00:00
677d30563f 4.1.0
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-14 11:34:53 +00:00
9aa747b5d4 feat(SniHandler): Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios. 2025-03-14 11:34:52 +00:00
10 changed files with 1048 additions and 264 deletions

View File

@ -1,5 +1,163 @@
# Changelog
## 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)
Set certificate_expired TLS alert level to warning instead of fatal to allow graceful termination.
- In the TLS handshake alert for certificate_expired (0x2F), changed the alert level from 0x02 (fatal) to 0x01 (warning).
- This change avoids abrupt connection termination, enabling a smoother handling of certificate expiration alerts.
## 2025-03-17 - 4.1.12 - fix(classes.pp.connectionhandler)
Replace unrecognized_name alert data with certificate_expired alert in TLS handshake handling for session resumption without SNI
- Switched the alert payload from serverNameUnknownAlertData to a new certificateExpiredAlert buffer
- Now sends a fatal certificate_expired alert (code 47) instead of a warning unrecognized_name alert
- Improves TLS error reporting and encourages immediate disconnection when a ClientHello lacks SNI and session tickets are disallowed
## 2025-03-17 - 4.1.11 - fix(connectionhandler)
Increase delay before cleaning up connections when session resumption is blocked due to missing SNI, allowing more natural socket termination.
- Changed cleanup delay in ts/classes.pp.connectionhandler.ts from 300ms to 1000ms.
- This fix ensures that sockets get sufficient time to terminate gracefully.
## 2025-03-16 - 4.1.10 - fix(connectionhandler)
Increase delay timings for TLS alert transmission in session ticket blocking to allow graceful socket termination
- Updated finishConnection: replaced immediate socket.destroy with a graceful end call
- Increased delay after successful write from 50ms to 200ms to allow alert processing
- Raised safety timeout from 250ms to 400ms when waiting for 'drain' event
## 2025-03-16 - 4.1.9 - fix(ConnectionHandler)
Replace closeNotify alert with handshake failure alert in TLS ClientHello handling to properly signal missing SNI and enforce session ticket restrictions.
- Switched alert data sent on missing SNI from closeNotifyAlert to sslHandshakeFailureAlertData.
- Ensures consistent TLS alert behavior during handshake failure.
## 2025-03-16 - 4.1.8 - fix(ConnectionHandler/tls)
Change the TLS alert sent when a ClientHello lacks SNI: use the close_notify alert instead of handshake_failure to prompt immediate retry with SNI.
- Replaced the previously sent handshake_failure alert (code 0x28) with a close_notify alert (code 0x00) in the TLS session resumption handling in ConnectionHandler.
- This change encourages clients to immediately retry and include SNI when allowSessionTicket is false.
## 2025-03-16 - 4.1.7 - fix(classes.pp.connectionhandler)
Improve TLS alert handling in ClientHello when SNI is missing and session tickets are disallowed
- Replace the unrecognized_name alert with a handshake_failure alert to ensure better client behavior.
- Refactor the alert sending mechanism using cork/uncork and add a safety timeout for the drain event.
- Enhance logging for debugging TLS handshake failures when SNI is absent.
## 2025-03-16 - 4.1.6 - fix(tls)
Refine TLS ClientHello handling when allowSessionTicket is false by replacing extensive alert timeout logic with a concise warning alert and short delay, encouraging immediate client retry with proper SNI
- Update the TLS alert sending mechanism to use cork/uncork and a short, fixed delay instead of long timeouts
- Remove redundant event listeners and excessive cleanup logic after sending the alert
- Improve code clarity and encourage clients (e.g., Chrome) to retry handshake with SNI more responsively
## 2025-03-16 - 4.1.5 - fix(TLS/ConnectionHandler)
Improve handling of TLS session resumption without SNI by sending an 'unrecognized_name' alert instead of immediately terminating the connection. This change adds a grace period for the client to retry the handshake with proper SNI and cleans up the connection if no valid response is received.
- Send a TLS warning (unrecognized_name alert, code 112) when a ClientHello is received without SNI and session tickets are disallowed.
- Utilize socket cork/uncork to ensure the alert is sent as a single packet.
- Add a 5-second alert timeout and a subsequent 30-second grace period to allow clients to initiate a new handshake with SNI.
- Clean up and terminate the connection if no valid SNI is provided after the grace period, logging appropriate termination reasons.
## 2025-03-15 - 4.1.4 - fix(ConnectionHandler)
Refactor ConnectionHandler code formatting for improved readability and consistency in log messages and whitespace handling
- Standardized indentation and spacing in method signatures and log statements
- Aligned inline comments and string concatenations for clarity
- Minor refactoring of parameter formatting without changing functionality
## 2025-03-15 - 4.1.3 - fix(connectionhandler)
Improve handling of TLS ClientHello messages when allowSessionTicket is disabled and no SNI is provided by sending a warning alert (unrecognized_name, code 0x70) with a proper callback and delay to ensure the alert is transmitted before closing the connection.
- Replace the fatal alert (0x02/0x40) with a warning alert (0x01/0x70) to notify clients to send SNI.
- Use socket.write callback to wait 100ms after sending the alert before terminating the connection.
- Remove the previous short (50ms) delay in favor of a more reliable delay mechanism before cleanup.
## 2025-03-15 - 4.1.2 - fix(connectionhandler)
Send proper TLS alert before terminating connections when SNI is missing and session tickets are disallowed.
- Added logic to transmit a fatal TLS alert (Handshake Failure) before closing the connection when no SNI is present with allowSessionTicket=false.
- Introduced a slight 50ms delay after sending the alert to ensure the client receives the alert properly.
- Applied these changes both for the initial ClientHello and when handling subsequent TLS data.
## 2025-03-15 - 4.1.1 - fix(tls)
Enforce strict SNI handling in TLS connections by terminating ClientHello messages lacking SNI when session tickets are disallowed and removing legacy session cache code.
- In classes.pp.connectionhandler.ts, if allowSessionTicket is false and no SNI is extracted from a ClientHello, the connection is terminated to force a new handshake with SNI.
- In classes.pp.snihandler.ts, removed session cache and related cleanup functions used for tab reactivation, simplifying SNI extraction logic.
- Improved logging in TLS processing to aid in diagnosing handshake and session resumption issues.
## 2025-03-14 - 4.1.0 - feat(SniHandler)
Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios.
- Introduce initSessionCacheCleanup, cleanupSessionCache, createClientKey, cacheSession, and getCachedSession methods to manage SNI information.
- Cache SNI based on client IP and client random to improve handling of fragmented ClientHello messages and tab reactivation.
- Update processTlsPacket to leverage cached SNI when standard extraction fails, reducing redundant extraction and enhancing connection racing behavior.
## 2025-03-14 - 4.0.0 - BREAKING CHANGE(core)
refactor: reorganize internal module structure to use 'classes.pp.*' modules

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "4.0.0",
"version": "4.2.6",
"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.",
"main": "dist_ts/index.js",

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '4.0.0',
version: '4.2.6',
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,6 +1,6 @@
import * as plugins from './plugins.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 path from 'path';
import { fileURLToPath } from 'url';
@ -72,8 +72,8 @@ export class NetworkProxy {
private defaultCertificates: { key: string; cert: string };
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
// ACME certificate manager
private certManager: AcmeCertManager | null = null;
// Port80Handler for certificate management
private port80Handler: Port80Handler | null = null;
private certificateStoreDir: string;
// 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 async initializeAcmeManager(): Promise<void> {
private async initializePort80Handler(): Promise<void> {
if (!this.options.acme.enabled) {
return;
}
// Create certificate manager
this.certManager = new AcmeCertManager({
this.port80Handler = new Port80Handler({
port: this.options.acme.port,
contactEmail: this.options.acme.contactEmail,
useProduction: this.options.acme.useProduction,
@ -394,32 +394,32 @@ export class NetworkProxy {
});
// Register event handlers
this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => {
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => {
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
});
// Start the manager
// Start the handler
try {
await this.certManager.start();
this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`);
await this.port80Handler.start();
this.log('info', `Port80Handler started on port ${this.options.acme.port}`);
// Add domains from proxy configs
this.registerDomainsWithAcmeManager();
this.registerDomainsWithPort80Handler();
} catch (error) {
this.log('error', `Failed to start ACME Certificate Manager: ${error}`);
this.certManager = null;
this.log('error', `Failed to start Port80Handler: ${error}`);
this.port80Handler = null;
}
}
/**
* Registers domains from proxy configs with the ACME manager
* Registers domains from proxy configs with the Port80Handler
* @private
*/
private registerDomainsWithAcmeManager(): void {
if (!this.certManager) return;
private registerDomainsWithPort80Handler(): void {
if (!this.port80Handler) return;
// Get all hostnames from proxy configs
this.proxyConfigs.forEach(config => {
@ -461,26 +461,32 @@ export class NetworkProxy {
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
}
// Update the certificate in the manager
this.certManager.setCertificate(hostname, cert, key, expiryDate);
// Update the certificate in the handler
this.port80Handler.setCertificate(hostname, cert, key, expiryDate);
// Also update our own certificate cache
this.updateCertificateCache(hostname, cert, key, expiryDate);
this.log('info', `Loaded existing certificate for ${hostname}`);
} else {
// Register the domain for certificate issuance
this.certManager.addDomain(hostname);
// Register the domain for certificate issuance with new domain options format
const domainOptions: IDomainOptions = {
domainName: hostname,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
}
} 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 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
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
const certData = this.certManager.getCertificate(domain);
const certData = this.port80Handler.getCertificate(domain);
if (!certData) {
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> {
this.startTime = Date.now();
// Initialize ACME certificate manager if enabled
// Initialize Port80Handler if enabled
if (this.options.acme.enabled) {
await this.initializeAcmeManager();
await this.initializePort80Handler();
}
// Create the HTTPS server
@ -1588,13 +1602,13 @@ export class NetworkProxy {
}
this.connectionPool.clear();
// Stop ACME certificate manager if it's running
if (this.certManager) {
// Stop Port80Handler if it's running
if (this.port80Handler) {
try {
await this.certManager.stop();
this.log('info', 'ACME Certificate Manager stopped');
await this.port80Handler.stop();
this.log('info', 'Port80Handler stopped');
} 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;
}
if (!this.certManager) {
this.log('error', 'ACME certificate manager is not initialized');
if (!this.port80Handler) {
this.log('error', 'Port80Handler is not initialized');
return false;
}
@ -1631,7 +1645,14 @@ export class NetworkProxy {
}
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}`);
return true;
} catch (error) {

View File

@ -1,9 +1,58 @@
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 {
options: IDomainOptions;
certObtained: boolean;
obtainingInProgress: boolean;
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;
contactEmail?: string;
useProduction?: boolean;
@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions {
/**
* Certificate data that can be emitted via events or set from outside
*/
interface ICertificateData {
export interface ICertificateData {
domain: string;
certificate: string;
privateKey: string;
@ -37,34 +86,53 @@ 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_RENEWED = 'certificate-renewed',
CERTIFICATE_FAILED = 'certificate-failed',
CERTIFICATE_EXPIRING = 'certificate-expiring',
MANAGER_STARTED = 'manager-started',
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
*/
export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>;
private server: plugins.http.Server | null = null;
private acmeClient: plugins.acme.Client | null = null;
private accountKey: string | null = null;
private renewalTimer: NodeJS.Timeout | null = null;
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
*/
constructor(options: IAcmeCertManagerOptions = {}) {
constructor(options: IPort80HandlerOptions = {}) {
super();
this.domainCertificates = new Map<string, IDomainCertificate>();
@ -73,7 +141,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
port: options.port ?? 80,
contactEmail: options.contactEmail ?? 'admin@example.com',
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,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
};
@ -84,11 +152,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
*/
public async start(): Promise<void> {
if (this.server) {
throw new Error('Server is already running');
throw new ServerError('Server is already running');
}
if (this.isShuttingDown) {
throw new Error('Server is shutting down');
throw new ServerError('Server is shutting down');
}
return new Promise((resolve, reject) => {
@ -97,22 +165,33 @@ export class AcmeCertManager extends plugins.EventEmitter {
this.server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES') {
reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`));
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') {
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 {
reject(error);
reject(new ServerError(error.message, error.code));
}
});
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.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()) {
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
this.obtainCertificate(domain).catch(err => {
console.error(`Error obtaining initial certificate for ${domain}:`, err);
});
}
}
resolve();
});
} catch (error) {
reject(error);
const message = error instanceof Error ? error.message : 'Unknown error starting server';
reject(new ServerError(message));
}
});
}
@ -138,7 +217,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
this.server.close(() => {
this.server = null;
this.isShuttingDown = false;
this.emit(CertManagerEvents.MANAGER_STOPPED);
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
resolve();
});
} else {
@ -149,13 +228,41 @@ export class AcmeCertManager extends plugins.EventEmitter {
}
/**
* Adds a domain to be managed for certificates
* @param domain The domain to add
* Adds a domain with configuration options
* @param options Domain configuration options
*/
public addDomain(domain: string): void {
if (!this.domainCertificates.has(domain)) {
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
console.log(`Domain added: ${domain}`);
public addDomain(options: IDomainOptions): void {
if (!options.domainName || typeof options.domainName !== 'string') {
throw new Port80HandlerError('Invalid domain name');
}
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, start certificate process immediately
if (options.acmeMaintenance && this.server) {
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 +284,25 @@ export class AcmeCertManager extends plugins.EventEmitter {
* @param expiryDate Optional expiry date
*/
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
if (!domain || !certificate || !privateKey) {
throw new Port80HandlerError('Domain, certificate and privateKey are required');
}
let domainInfo = this.domainCertificates.get(domain);
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);
}
@ -192,27 +314,18 @@ export class AcmeCertManager extends plugins.EventEmitter {
if (expiryDate) {
domainInfo.expiryDate = expiryDate;
} else {
// Try to extract expiry date from certificate
try {
// This is a simplistic approach - in a real implementation, use a proper
// certificate parsing library like node-forge or x509
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
domainInfo.expiryDate = new Date(matches[1]);
}
} catch (error) {
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
}
// Extract expiry date from certificate
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
}
console.log(`Certificate set for ${domain}`);
// Emit certificate event
this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, {
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
domain,
certificate,
privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
});
}
@ -231,7 +344,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
domain,
certificate: domainInfo.certificate,
privateKey: domainInfo.privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
};
}
@ -244,23 +357,28 @@ export class AcmeCertManager extends plugins.EventEmitter {
return this.acmeClient;
}
// Generate a new account key
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
this.acmeClient = new plugins.acme.Client({
directoryUrl: this.options.useProduction
? plugins.acme.directory.letsencrypt.production
: plugins.acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.contactEmail}`],
});
return this.acmeClient;
try {
// Generate a new account key
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
this.acmeClient = new plugins.acme.Client({
directoryUrl: this.options.useProduction
? plugins.acme.directory.letsencrypt.production
: plugins.acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.contactEmail}`],
});
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,12 +397,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
// Extract domain (ignoring any port in the Host header)
const domain = hostHeader.split(':')[0];
// If the request is for an ACME HTTP-01 challenge, handle it
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
this.handleAcmeChallenge(req, res, domain);
return;
}
// Check if domain is configured
if (!this.domainCertificates.has(domain)) {
res.statusCode = 404;
res.end('Domain not configured');
@ -292,9 +405,28 @@ export class AcmeCertManager extends plugins.EventEmitter {
}
const domainInfo = this.domainCertificates.get(domain)!;
const options = domainInfo.options;
// If certificate exists, redirect to HTTPS
if (domainInfo.certObtained) {
// If the request is for an ACME HTTP-01 challenge, handle it
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;
}
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
if (domainInfo.certObtained && options.sslRedirect) {
const httpsPort = this.options.httpsRedirectPort;
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
@ -302,17 +434,93 @@ export class AcmeCertManager extends plugins.EventEmitter {
res.statusCode = 301;
res.setHeader('Location', redirectUrl);
res.end(`Redirecting to ${redirectUrl}`);
} else {
return;
}
// Handle case where certificate maintenance is enabled but not yet obtained
if (options.acmeMaintenance && !domainInfo.certObtained) {
// Trigger certificate issuance if not already running
if (!domainInfo.obtainingInProgress) {
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);
});
}
res.statusCode = 503;
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();
}
}
@ -354,7 +562,13 @@ export class AcmeCertManager extends plugins.EventEmitter {
// Get the domain info
const domainInfo = this.domainCertificates.get(domain);
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
@ -377,40 +591,8 @@ export class AcmeCertManager extends plugins.EventEmitter {
// Get the authorizations for the order
const authorizations = await client.getAuthorizations(order);
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
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;
}
}
// Process each authorization
await this.processAuthorizations(client, domain, authorizations);
// Generate a CSR and private key
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
@ -436,28 +618,20 @@ export class AcmeCertManager extends plugins.EventEmitter {
delete domainInfo.challengeKeyAuthorization;
// Extract expiry date from certificate
try {
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
domainInfo.expiryDate = new Date(matches[1]);
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
}
} catch (error) {
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
}
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
// Emit the appropriate event
const eventType = isRenewal
? CertManagerEvents.CERTIFICATE_RENEWED
: CertManagerEvents.CERTIFICATE_ISSUED;
? Port80HandlerEvents.CERTIFICATE_RENEWED
: Port80HandlerEvents.CERTIFICATE_ISSUED;
this.emitCertificateEvent(eventType, {
domain,
certificate,
privateKey,
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
});
} catch (error: any) {
@ -473,17 +647,76 @@ export class AcmeCertManager extends plugins.EventEmitter {
}
// Emit failure event
this.emit(CertManagerEvents.CERTIFICATE_FAILED, {
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain,
error: error.message || 'Unknown error',
isRenewal
});
} as ICertificateFailure);
throw new CertificateError(
error.message || 'Certificate issuance failed',
domain,
isRenewal
);
} finally {
// Reset flag whether successful or not
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
*/
@ -519,6 +752,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip domains with acmeMaintenance disabled
if (!domainInfo.options.acmeMaintenance) {
continue;
}
// Skip domains without certificates or already in renewal
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
continue;
@ -534,26 +772,67 @@ export class AcmeCertManager extends plugins.EventEmitter {
// Check if certificate is near expiry
if (timeUntilExpiry <= renewThresholdMs) {
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,
expiryDate: domainInfo.expiryDate,
daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000))
});
daysRemaining
} as ICertificateExpiring);
// Start renewal process
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
* @param eventType The event type to emit
* @param data The certificate data
*/
private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void {
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
this.emit(eventType, data);
}
}

View File

@ -1,5 +1,9 @@
import * as plugins from './plugins.js';
import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
import type {
IConnectionRecord,
IDomainConfig,
IPortProxySettings,
} from './classes.pp.interfaces.js';
import { ConnectionManager } from './classes.pp.connectionmanager.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
@ -73,8 +77,8 @@ export class ConnectionHandler {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` +
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
`Active connections: ${this.connectionManager.getConnectionCount()}`
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
`Active connections: ${this.connectionManager.getConnectionCount()}`
);
} else {
console.log(
@ -94,7 +98,10 @@ export class ConnectionHandler {
/**
* Handle a connection that should be forwarded to NetworkProxy
*/
private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
private handleNetworkProxyConnection(
socket: plugins.net.Socket,
record: IConnectionRecord
): void {
const connectionId = record.id;
let initialDataReceived = false;
@ -104,7 +111,7 @@ export class ConnectionHandler {
console.log(
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
);
// Add a grace period instead of immediate termination
setTimeout(() => {
if (!initialDataReceived) {
@ -144,7 +151,7 @@ export class ConnectionHandler {
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
console.log(
`[${connectionId}] Non-TLS connection detected on port 443. ` +
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'non_tls_blocked';
@ -159,8 +166,8 @@ export class ConnectionHandler {
if (this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
// Check session tickets if they're disabled
if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) {
// Check for ClientHello to extract SNI - but don't enforce it for NetworkProxy
if (this.tlsManager.isClientHello(chunk)) {
// Create connection info for SNI extraction
const connInfo = {
sourceIp: record.remoteIP,
@ -169,14 +176,14 @@ export class ConnectionHandler {
destPort: socket.localPort || 0,
};
// Extract SNI for domain-specific NetworkProxy handling
// Extract SNI for domain-specific NetworkProxy handling if available
const serverName = this.tlsManager.extractSNI(chunk, connInfo);
// For NetworkProxy connections, we'll allow session tickets even without SNI
// We'll only use the serverName if available to determine the specific NetworkProxy port
if (serverName) {
// If we got an SNI, check for domain-specific NetworkProxy settings
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
// Save domain config and SNI in connection record
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
record.domainConfig = domainConfig;
record.lockedDomain = serverName;
@ -201,6 +208,14 @@ export class ConnectionHandler {
);
return;
}
} else if (
this.settings.allowSessionTicket === false &&
this.settings.enableDetailedLogging
) {
// Log that we're allowing a session resumption without SNI for NetworkProxy
console.log(
`[${connectionId}] Allowing session resumption without SNI for NetworkProxy forwarding`
);
}
}
@ -215,14 +230,10 @@ export class ConnectionHandler {
);
} else {
// If not TLS, use normal direct connection
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`);
this.setupDirectConnection(
socket,
record,
undefined,
undefined,
chunk
console.log(
`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`
);
this.setupDirectConnection(socket, record, undefined, undefined, chunk);
}
});
}
@ -255,7 +266,7 @@ export class ConnectionHandler {
console.log(
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
);
// Add a grace period instead of immediate termination
setTimeout(() => {
if (!initialDataReceived) {
@ -340,14 +351,13 @@ export class ConnectionHandler {
record.domainConfig = domainConfig;
// Check if this domain should use NetworkProxy (domain-specific setting)
if (domainConfig &&
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
this.networkProxyBridge.getNetworkProxy()) {
if (
domainConfig &&
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
this.networkProxyBridge.getNetworkProxy()
) {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`
);
console.log(`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`);
}
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
@ -369,23 +379,24 @@ export class ConnectionHandler {
// IP validation
if (domainConfig) {
const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig);
// Skip IP validation if allowedIPs is empty
if (
domainConfig.allowedIPs.length > 0 &&
!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)
!this.securityManager.isIPAuthorized(
record.remoteIP,
ipRules.allowedIPs,
ipRules.blockedIPs
)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join(
', '
)}`
`Connection rejected: IP ${
record.remoteIP
} not allowed for domain ${domainConfig.domains.join(', ')}`
);
}
} else if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0
) {
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (
!this.securityManager.isIPAuthorized(
record.remoteIP,
@ -452,28 +463,36 @@ export class ConnectionHandler {
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.domainConfigManager.findDomainConfigForPort(localPort);
if (forcedDomain) {
const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain);
if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) {
if (
!this.securityManager.isIPAuthorized(
record.remoteIP,
ipRules.allowedIPs,
ipRules.blockedIPs
)
) {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(
`[${connectionId}] Connection from ${
record.remoteIP
} rejected: IP not allowed for domain ${forcedDomain.domains.join(
', '
)} on port ${localPort}.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(
', '
)}.`
`[${connectionId}] Port-based connection from ${
record.remoteIP
} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`
);
}
setupConnection('', undefined, forcedDomain, localPort);
return;
}
@ -491,14 +510,14 @@ export class ConnectionHandler {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
// Block non-TLS connections on port 443
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
console.log(
`[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` +
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'non_tls_blocked';
@ -531,6 +550,77 @@ export class ConnectionHandler {
// Extract SNI
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
// If allowSessionTicket is false and this is a ClientHello with no SNI, terminate the connection
if (
this.settings.allowSessionTicket === false &&
this.tlsManager.isClientHello(chunk) &&
!serverName
) {
// Block ClientHello without SNI when allowSessionTicket is false
console.log(
`[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` +
`Sending warning unrecognized_name alert to encourage immediate retry with SNI.`
);
// Set the termination reason first
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
this.connectionManager.incrementTerminationStat(
'incoming',
'session_ticket_blocked_no_sni'
);
}
// Create a warning-level alert for unrecognized_name
// This encourages Chrome to retry immediately with SNI
const serverNameUnknownAlertData = Buffer.from([
0x15, // Alert record type
0x03,
0x03, // TLS 1.2 version
0x00,
0x02, // Length
0x01, // Warning alert level (not fatal)
0x70, // unrecognized_name alert (code 112)
]);
try {
// Use cork/uncork to ensure the alert is sent as a single packet
socket.cork();
const writeSuccessful = socket.write(serverNameUnknownAlertData);
socket.uncork();
socket.end();
// Function to handle the clean socket termination - but more gradually
const finishConnection = () => {
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
};
if (writeSuccessful) {
// Wait longer before ending connection to ensure alert is processed by client
setTimeout(finishConnection, 200); // Increased from 50ms to 200ms
} else {
// If the kernel buffer was full, wait for the drain event
socket.once('drain', () => {
// Wait longer after drain as well
setTimeout(finishConnection, 200);
});
// Safety timeout is increased too
setTimeout(() => {
socket.removeAllListeners('drain');
finishConnection();
}, 400); // Increased from 250ms to 400ms
}
} catch (err) {
// If we can't send the alert, fall back to immediate termination
console.log(`[${connectionId}] Error sending TLS alert: ${err.message}`);
socket.end();
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
}
return;
}
}
// Lock the connection to the negotiated SNI.
@ -577,23 +667,21 @@ export class ConnectionHandler {
overridePort?: number
): void {
const connectionId = record.id;
// Determine target host
const targetHost = domainConfig
? this.domainConfigManager.getTargetIP(domainConfig)
const targetHost = domainConfig
? this.domainConfigManager.getTargetIP(domainConfig)
: this.settings.targetIP!;
// Determine target port
const targetPort = overridePort !== undefined
? overridePort
: this.settings.toPort;
const targetPort = overridePort !== undefined ? overridePort : this.settings.toPort;
// Setup connection options
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: targetPort,
};
// Preserve source IP if configured
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
@ -850,18 +938,20 @@ export class ConnectionHandler {
// Process any remaining data in the queue before switching to piping
processDataQueue();
// Set up piping immediately
pipingEstablished = true;
// Flush all pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`);
console.log(
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
);
}
// Write pending data immediately
targetSocket.write(combinedData, (err) => {
if (err) {
@ -869,19 +959,19 @@ export class ConnectionHandler {
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
}
});
// Clear the buffer now that we've processed it
record.pendingData = [];
record.pendingDataSize = 0;
}
// Setup piping in both directions without any delays
socket.pipe(targetSocket);
targetSocket.pipe(socket);
// Resume the socket to ensure data flows
socket.resume();
// Process any data that might be queued in the interim
if (dataQueue.length > 0) {
// Write any remaining queued data directly to the target socket
@ -892,7 +982,7 @@ export class ConnectionHandler {
dataQueue.length = 0;
queueSize = 0;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
@ -957,15 +1047,12 @@ export class ConnectionHandler {
}
// Set connection timeout
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(
record,
(record, reason) => {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
);
this.connectionManager.initiateCleanupOnce(record, reason);
}
);
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
);
this.connectionManager.initiateCleanupOnce(record, reason);
});
// Mark TLS handshake as complete for TLS connections
if (record.isTLS) {
@ -979,4 +1066,4 @@ export class ConnectionHandler {
}
});
}
}
}

View File

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

View File

@ -22,27 +22,6 @@ export class SniHandler {
private static fragmentedBuffers: Map<string, Buffer> = new Map();
private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
/**
* Extract the client random value from a ClientHello message
*
* @param buffer - The buffer containing the ClientHello
* @returns The 32-byte client random or undefined if extraction fails
*/
private static extractClientRandom(buffer: Buffer): Buffer | undefined {
try {
if (!this.isClientHello(buffer) || buffer.length < 46) {
return undefined;
}
// In a ClientHello message, the client random starts at position 11
// after record header (5 bytes), handshake type (1 byte),
// handshake length (3 bytes), and client version (2 bytes)
return buffer.slice(11, 11 + 32);
} catch (error) {
return undefined;
}
}
/**
* Checks if a buffer contains a TLS handshake message (record type 22)
* @param buffer - The buffer to check
@ -1174,7 +1153,7 @@ export class SniHandler {
*
* The method uses connection tracking to handle fragmented ClientHello
* messages and various TLS 1.3 behaviors, including Chrome's connection
* racing patterns.
* racing patterns and tab reactivation behaviors.
*
* @param buffer - The buffer containing TLS data
* @param connectionInfo - Connection metadata (IPs and ports)
@ -1217,7 +1196,7 @@ export class SniHandler {
// Handle application data with cached SNI (for connection racing)
if (this.isTlsApplicationData(buffer)) {
// First check if explicit cachedSni was provided
// If explicit cachedSni was provided, use it
if (cachedSni) {
log(`Using provided cached SNI for application data: ${cachedSni}`);
return cachedSni;

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.networkproxy.js';
export * from './classes.pp.portproxy.js';
export * from './classes.port80handler.js';
export * from './classes.sslredirect.js';
export * from './classes.pp.portproxy.js';
export * from './classes.pp.snihandler.js';
export * from './classes.pp.interfaces.js';