Compare commits

...

13 Commits

Author SHA1 Message Date
09ad7644f4 10.0.3
Some checks failed
Default (tags) / security (push) Successful in 46s
Default (tags) / test (push) Failing after 1m19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-04 12:21:02 +00:00
f72f884eda fix(smartproxy): Update dependency versions (@push.rocks/smartacme to ^7.2.4, @push.rocks/smartnetwork to ^4.0.1, ws to ^8.18.2) and export common types via index.ts for easier imports. 2025-05-04 12:21:02 +00:00
73f3dfcad4 10.0.2
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 1m18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-03 19:02:26 +00:00
8291f1f33a fix(tlsalert): Centralize plugin imports in TlsAlert and update plan checklist 2025-05-03 19:02:26 +00:00
f512fb4252 10.0.1
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 1m18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-03 13:27:59 +00:00
1f3ee1eafc fix(docs): Improve mermaid diagram formatting in readme.md using HTML <br> tags for line breaks 2025-05-03 13:27:59 +00:00
910c8160f6 10.0.0
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 1m19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-03 13:19:23 +00:00
0e634c46a6 BREAKING CHANGE(smartproxy): Update documentation and refactor core proxy components; remove legacy performRenewals method from SmartProxy; update router type imports and adjust test suites for improved coverage 2025-05-03 13:19:23 +00:00
32b4e32bf0 9.0.0
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-02 14:58:33 +00:00
878e76ab23 BREAKING CHANGE(acme): Refactor ACME configuration and certificate provisioning by replacing legacy port80HandlerConfig with unified acme options and updating CertProvisioner event subscriptions 2025-05-02 14:58:33 +00:00
edd8ca8d70 8.0.0
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-02 11:19:14 +00:00
8a396a04fa BREAKING CHANGE(certProvisioner): Refactor: Introduce unified CertProvisioner to centralize certificate provisioning and renewal; remove legacy ACME config from Port80Handler and update SmartProxy to delegate certificate lifecycle management. 2025-05-02 11:19:14 +00:00
09aadc702e update 2025-05-01 15:39:20 +00:00
23 changed files with 1031 additions and 1147 deletions

View File

@ -1,5 +1,53 @@
# Changelog
## 2025-05-04 - 10.0.3 - fix(smartproxy)
Update dependency versions (@push.rocks/smartacme to ^7.2.4, @push.rocks/smartnetwork to ^4.0.1, ws to ^8.18.2) and export common types via index.ts for easier imports.
- Upgrade @push.rocks/smartacme from ^7.2.3 to ^7.2.4
- Upgrade @push.rocks/smartnetwork from ^4.0.0 to ^4.0.1
- Upgrade ws from ^8.18.1 to ^8.18.2
- Export common types from ts/common/types.ts in index.ts
## 2025-05-03 - 10.0.2 - fix(tlsalert)
Centralize plugin imports in TlsAlert and update plan checklist
- Mark the 'Centralize plugin imports in ts/plugins.ts' item as complete in readme.plan.md
- Replace direct 'net' imports with a centralized 'plugins' import in ts/smartproxy/classes.pp.tlsalert.ts
- Update all socket type references from net.Socket to plugins.net.Socket for consistency
## 2025-05-03 - 10.0.1 - fix(docs)
Improve mermaid diagram formatting in readme.md using HTML <br> tags for line breaks
- Replaced newline characters with <br> in the SmartProxy Components diagram nodes for better HTML rendering
- Improved visual clarity of the architectural diagrams in the readme
## 2025-05-03 - 10.0.0 - BREAKING CHANGE(smartproxy)
Update documentation and refactor core proxy components; remove legacy performRenewals method from SmartProxy; update router type imports and adjust test suites for improved coverage
- Expanded README with detailed Quick Start examples for HTTP/HTTPS reverse proxy, ACME integration, HTTP→HTTPS redirect, nftables port forwarding, and SNI-based TCP proxying
- Updated readme.plan.md checkboxes to show completed tasks
- Refactored ProxyRouter to import types via plugins.tsclass, ensuring consistency in type imports
- Removed deprecated performRenewals method from SmartProxy, constituting a breaking change for users relying on it
- Updated multiple test suites (router, networkproxy, certprovisioner, etc.) to reflect new behaviors and improved diagnostics
## 2025-05-02 - 9.0.0 - BREAKING CHANGE(acme)
Refactor ACME configuration and certificate provisioning by replacing legacy port80HandlerConfig with unified acme options and updating CertProvisioner event subscriptions
- Remove deprecated port80HandlerConfig references and merge configuration into a single acme options schema
- Use buildPort80Handler factory for consistent Port80Handler instantiation
- Integrate subscribeToPort80Handler utility in CertProvisioner and NetworkProxyBridge for event management
- Update types in common modules and IPortProxySettings to reflect unified acme configurations
- Adjust documentation (readme.plan.md and code-level comments) to reflect the new refactored flow
## 2025-05-02 - 8.0.0 - BREAKING CHANGE(certProvisioner)
Refactor: Introduce unified CertProvisioner to centralize certificate provisioning and renewal; remove legacy ACME config from Port80Handler and update SmartProxy to delegate certificate lifecycle management.
- Removed deprecated acme properties and renewal scheduler from IPort80HandlerOptions and Port80Handler.
- Created new CertProvisioner component in ts/smartproxy/classes.pp.certprovisioner.ts to handle static and HTTP-01 certificate workflows.
- Updated SmartProxy to initialize CertProvisioner and re-emit certificate events.
- Eliminated legacy renewal logic and associated autoRenew settings from Port80Handler.
- Adjusted tests to reflect changes in certificate provisioning and renewal behavior.
## 2025-05-01 - 7.2.0 - feat(ACME/Certificate)
Introduce certificate provider hook and observable certificate events; remove legacy ACME flow

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "7.2.0",
"version": "10.0.3",
"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",
@ -24,18 +24,19 @@
},
"dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartacme": "^7.2.3",
"@push.rocks/smartacme": "^7.2.4",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartnetwork": "^4.0.0",
"@push.rocks/smartnetwork": "^4.0.1",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^9.1.0",
"@types/minimatch": "^5.1.2",
"@types/ws": "^8.18.1",
"minimatch": "^10.0.1",
"pretty-ms": "^9.2.0",
"ws": "^8.18.1"
"ws": "^8.18.2"
},
"files": [
"ts/**/*",

47
pnpm-lock.yaml generated
View File

@ -12,14 +12,14 @@ importers:
specifier: ^6.2.2
version: 6.2.2
'@push.rocks/smartacme':
specifier: ^7.2.3
version: 7.2.3(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)
specifier: ^7.2.4
version: 7.2.4(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)
'@push.rocks/smartdelay':
specifier: ^3.0.5
version: 3.0.5
'@push.rocks/smartnetwork':
specifier: ^4.0.0
version: 4.0.0
specifier: ^4.0.1
version: 4.0.1
'@push.rocks/smartpromise':
specifier: ^4.2.3
version: 4.2.3
@ -29,6 +29,9 @@ importers:
'@push.rocks/smartstring':
specifier: ^4.0.15
version: 4.0.15
'@push.rocks/taskbuffer':
specifier: ^3.1.7
version: 3.1.7
'@tsclass/tsclass':
specifier: ^9.1.0
version: 9.1.0
@ -45,8 +48,8 @@ importers:
specifier: ^9.2.0
version: 9.2.0
ws:
specifier: ^8.18.1
version: 8.18.1
specifier: ^8.18.2
version: 8.18.2
devDependencies:
'@git.zone/tsbuild':
specifier: ^2.3.2
@ -352,8 +355,8 @@ packages:
'@cloudflare/workers-types@4.20250303.0':
resolution: {integrity: sha512-O7F7nRT4bbmwHf3gkRBLfJ7R6vHIJ/oZzWdby6obOiw2yavUfp/AIwS7aO2POu5Cv8+h3TXS3oHs3kKCZLraUA==}
'@cloudflare/workers-types@4.20250430.0':
resolution: {integrity: sha512-JWAX7ZhQ7KjkdJwASgG58MZ/pQ15brlnZ9/0YBwDQ0hrJ/LaK392aTRFlj2r/PRKDZ5dOuujRywNYaNpfeFiEA==}
'@cloudflare/workers-types@4.20250504.0':
resolution: {integrity: sha512-/70Kb5vrqj+O0krOuS8LVLiCeDuCGzQy4X+wGGs4/rHv0gZJulv7Uj5YlUjIaRemK/Dyrzlk7WNJwTy8yv0cIw==}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
@ -869,8 +872,8 @@ packages:
'@push.rocks/qenv@6.1.0':
resolution: {integrity: sha512-1FUFMlSVwFSFg8LbqfkzJ2LLP4lMGApUtgOpsvrde6+AxBmB4gjoNgCUH7z3xXfDAtYqcrtSELXBNE0xVL1MqQ==}
'@push.rocks/smartacme@7.2.3':
resolution: {integrity: sha512-PTwn/Zf7l+IMWqeiQ8mTxi7fdrtObQH13YzF65si/VxXTqHeZ7zvisLLKZcMEgSaOj1aQ/Ku83gaO8YqO4gDig==}
'@push.rocks/smartacme@7.2.4':
resolution: {integrity: sha512-0ciewRheDAwv0ER0ZyLQVLAn0ZoG1++ibSZ14HoXn8GOTOLyuRLWLNDQL2fI4LtLxeaNYmQUS+f7tt4KaZb/UA==}
'@push.rocks/smartarchive@3.0.8':
resolution: {integrity: sha512-1jPmR0b7hXmjYQoRiTlRXrIbZcdcFmSdGOfznufjcDpGPe86Km0d8TBnzqghTx4dTihzKC67IxAaz/DM3lvxpA==}
@ -971,8 +974,8 @@ packages:
'@push.rocks/smartnetwork@3.0.2':
resolution: {integrity: sha512-s6CNGzQ1n/d/6cOKXbxeW6/tO//dr1woLqI01g7XhqTriw0nsm2G2kWaZh2J0VOguGNWBgQVCIpR0LjdRNWb3g==}
'@push.rocks/smartnetwork@4.0.0':
resolution: {integrity: sha512-hLE1JNrBjlWtibgFz7t2aMfP15VOfPFyKMpo6FI0JdhmJfD3V5w/nFpSdD6WdXeXUBjCVTJ3C6SrRl8izoG55g==}
'@push.rocks/smartnetwork@4.0.1':
resolution: {integrity: sha512-zLH88bKY6/cK6vVnCW4Fsugu4T+l6OerWWappit+BecdnQ6vrgShXSAa13JIkkWkWcs4dxEirlEfycQEEQw8BQ==}
'@push.rocks/smartnpm@2.0.4':
resolution: {integrity: sha512-ljRPqnUsXzL5qnuAEt5POy0NnfKs7eYPuuJPJjYiK9VUdP/CyF4h14qTB4H816vNEuF7VU/ASRtz0qDlXmrztg==}
@ -4619,8 +4622,8 @@ packages:
utf-8-validate:
optional: true
ws@8.18.1:
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
ws@8.18.2:
resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -4757,7 +4760,7 @@ snapshots:
'@api.global/typedrequest': 3.1.10
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 3.0.1
'@cloudflare/workers-types': 4.20250430.0
'@cloudflare/workers-types': 4.20250504.0
'@design.estate/dees-comms': 1.0.27
'@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.0.34
@ -5668,7 +5671,7 @@ snapshots:
'@cloudflare/workers-types@4.20250303.0': {}
'@cloudflare/workers-types@4.20250430.0': {}
'@cloudflare/workers-types@4.20250504.0': {}
'@colors/colors@1.6.0': {}
@ -5947,7 +5950,7 @@ snapshots:
'@push.rocks/tapbundle': 5.6.3(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)
'@types/ws': 8.18.1
figures: 6.1.0
ws: 8.18.1
ws: 8.18.2
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
@ -6281,7 +6284,7 @@ snapshots:
'@push.rocks/smartlog': 3.0.7
'@push.rocks/smartpath': 5.0.18
'@push.rocks/smartacme@7.2.3(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)':
'@push.rocks/smartacme@7.2.4(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)':
dependencies:
'@api.global/typedserver': 3.0.74
'@apiclient.xyz/cloudflare': 6.4.1
@ -6603,7 +6606,7 @@ snapshots:
public-ip: 6.0.2
systeminformation: 5.25.11
'@push.rocks/smartnetwork@4.0.0':
'@push.rocks/smartnetwork@4.0.1':
dependencies:
'@push.rocks/smartping': 1.0.8
'@push.rocks/smartpromise': 4.2.3
@ -6920,7 +6923,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.0.7
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.7
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9
@ -10542,7 +10545,7 @@ snapshots:
debug: 4.4.0
devtools-protocol: 0.0.1413902
typed-query-selector: 2.12.0
ws: 8.18.1
ws: 8.18.2
transitivePeerDependencies:
- bare-buffer
- bufferutil
@ -11301,7 +11304,7 @@ snapshots:
ws@8.17.1: {}
ws@8.18.1: {}
ws@8.18.2: {}
xml-js@1.6.11:
dependencies:

701
readme.md
View File

@ -1,11 +1,178 @@
# @push.rocks/smartproxy
A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.
A high-performance proxy toolkit for Node.js, offering:
- HTTP/HTTPS reverse proxy with TLS termination and WebSocket support
- Automatic ACME certificate management (HTTP-01)
- Low-level port forwarding via nftables
- HTTP-to-HTTPS and custom URL redirects
- Advanced TCP/SNI-based proxying with IP filtering and rules
## Exports
The following classes and interfaces are provided:
- **NetworkProxy** (ts/networkproxy/classes.np.networkproxy.ts)
HTTP/HTTPS reverse proxy with TLS termination, WebSocket support,
connection pooling, and optional ACME integration.
- **Port80Handler** (ts/port80handler/classes.port80handler.ts)
ACME HTTP-01 challenge handler and certificate manager.
- **NfTablesProxy** (ts/nfttablesproxy/classes.nftablesproxy.ts)
Low-level port forwarding using nftables NAT rules.
- **Redirect**, **SslRedirect** (ts/redirect/classes.redirect.ts)
HTTP/HTTPS redirect server and shortcut for HTTP→HTTPS.
- **SmartProxy** (ts/smartproxy/classes.smartproxy.ts)
TCP/SNI-based proxy with dynamic routing, IP filtering, and unified certificates.
- **SniHandler** (ts/smartproxy/classes.pp.snihandler.ts)
Static utilities to extract SNI hostnames from TLS handshakes.
- **Interfaces**
- IPortProxySettings, IDomainConfig (ts/smartproxy/classes.pp.interfaces.ts)
- INetworkProxyOptions (ts/networkproxy/classes.np.types.ts)
- IAcmeOptions, IDomainOptions, IForwardConfig (ts/common/types.ts)
- INfTableProxySettings (ts/nfttablesproxy/classes.nftablesproxy.ts)
## Installation
Install via npm:
```bash
npm install @push.rocks/smartproxy
```
## Quick Start
### 1. HTTP(S) Reverse Proxy (NetworkProxy)
```typescript
import { NetworkProxy } from '@push.rocks/smartproxy';
const proxy = new NetworkProxy({ port: 443 });
await proxy.start();
await proxy.updateProxyConfigs([
{
hostName: 'example.com',
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
publicKey: fs.readFileSync('cert.pem', 'utf8'),
privateKey: fs.readFileSync('key.pem', 'utf8'),
}
]);
// Add default headers to all responses
await proxy.addDefaultHeaders({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
});
// ...
await proxy.stop();
```
### 2. HTTP→HTTPS Redirect (Redirect / SslRedirect)
```typescript
import { Redirect, SslRedirect } from '@push.rocks/smartproxy';
import * as fs from 'fs';
// Custom redirect rules
const redirect = new Redirect({
httpPort: 80,
httpsPort: 443,
sslOptions: {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
},
rules: [
{
fromProtocol: 'http',
fromHost: '*',
toProtocol: 'https',
toHost: '$1',
statusCode: 301
}
]
});
await redirect.start();
// Quick HTTP→HTTPS helper on port 80
const quick = new SslRedirect(80);
await quick.start();
```
### 3. Automatic Certificates (ACME Port80Handler)
```typescript
import { Port80Handler } from '@push.rocks/smartproxy';
// Configure ACME on port 80 with contact email
const acme = new Port80Handler({
port: 80,
contactEmail: 'admin@example.com',
useProduction: true,
renewThresholdDays: 30
});
acme.on('certificate-issued', evt => {
console.log(`Certificate ready for ${evt.domain}, expires ${evt.expiryDate}`);
});
await acme.start();
acme.addDomain({
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true
});
```
### 4. Low-Level Port Forwarding (NfTablesProxy)
```typescript
import { NfTablesProxy } from '@push.rocks/smartproxy';
// Forward port 80→8080 with source IP preservation
const nft = new NfTablesProxy({
fromPort: 80,
toPort: 8080,
toHost: 'localhost',
preserveSourceIP: true,
deleteOnExit: true
});
await nft.start();
// ...
await nft.stop();
```
### 5. TCP/SNI Proxy (SmartProxy)
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
const smart = new SmartProxy({
fromPort: 443,
toPort: 8443,
domainConfigs: [
{
domains: ['example.com', '*.example.com'],
allowedIPs: ['*'],
targetIPs: ['127.0.0.1'],
}
],
sniEnabled: true
});
smart.on('certificate', evt => console.log(evt));
await smart.start();
// Update domains later
await smart.updateDomainConfigs([/* new configs */]);
```
### 6. SNI Utilities (SniHandler)
```js
import { SniHandler } from '@push.rocks/smartproxy';
// Extract SNI from a TLS ClientHello buffer
const sni = SniHandler.extractSNI(buffer);
// Reassemble fragmented ClientHello
const complete = SniHandler.handleFragmentedClientHello(buf, connId);
```
## API Reference
For full configuration options and type definitions, see the TypeScript interfaces in the `ts/` directory:
- `INetworkProxyOptions` (ts/networkproxy/classes.np.types.ts)
- `IAcmeOptions`, `IDomainOptions`, `IForwardConfig` (ts/common/types.ts)
- `INfTableProxySettings` (ts/nfttablesproxy/classes.nftablesproxy.ts)
- `IPortProxySettings`, `IDomainConfig` (ts/smartproxy/classes.pp.interfaces.ts)
## Architecture & Flow Diagrams
### Component Architecture
The diagram below illustrates the main components of SmartProxy and how they interact:
```mermaid
flowchart TB
@ -13,12 +180,12 @@ flowchart TB
subgraph "SmartProxy Components"
direction TB
HTTP80[HTTP Port 80\nSslRedirect]
HTTPS443[HTTPS Port 443\nNetworkProxy]
SmartProxy[SmartProxy\nwith SNI routing]
HTTP80["HTTP Port 80<br>Redirect / SslRedirect"]
HTTPS443["HTTPS Port 443<br>NetworkProxy"]
SmartProxy["SmartProxy<br>(TCP/SNI Proxy)"]
NfTables[NfTablesProxy]
Router[ProxyRouter]
ACME[Port80Handler\nACME/Let's Encrypt]
ACME["Port80Handler<br>(ACME HTTP-01)"]
Certs[(SSL Certificates)]
end
@ -190,470 +357,104 @@ sequenceDiagram
## Features
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
- **TCP Connection Handling** - Advanced connection handling with SNI inspection and domain-based routing
- **Enhanced TLS Handling** - Robust TLS handshake processing with improved certificate error handling
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
- **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding
- HTTP/HTTPS Reverse Proxy (NetworkProxy)
• TLS termination, virtual-host routing, HTTP/2 & WebSocket support, pooling & metrics
## Certificate Provider Hook & Events
- Automatic ACME Certificates (Port80Handler)
• HTTP-01 challenge handling, certificate issuance/renewal, pluggable storage
You can customize how certificates are provisioned per domain by using the `certProvider` callback and listen for certificate events emitted by `SmartProxy`.
- Low-Level Port Forwarding (NfTablesProxy)
• nftables NAT rules for ports/ranges, IPv4/IPv6, IP filtering, QoS & ipset support
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
import * as fs from 'fs';
- Custom Redirects (Redirect / SslRedirect)
• URL redirects with wildcard host/path, template variables & status codes
// Example certProvider: static for a specific domain, HTTP-01 otherwise
const certProvider = async (domain: string) => {
if (domain === 'static.example.com') {
// Load from disk or vault
return {
id: 'static-cert',
domainName: domain,
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: fs.readFileSync('/etc/ssl/private/static.key', 'utf8'),
publicKey: fs.readFileSync('/etc/ssl/certs/static.crt', 'utf8'),
csr: ''
};
}
// Fallback to ACME HTTP-01 challenge
return 'http01';
};
- TCP/SNI Proxy (SmartProxy)
• SNI-based routing, IP allow/block lists, port ranges, timeouts & graceful shutdown
const proxy = new SmartProxy({
fromPort: 80,
toPort: 8080,
domainConfigs: [{
domains: ['static.example.com', 'dynamic.example.com'],
allowedIPs: ['*']
}],
certProvider
});
- SNI Utilities (SniHandler)
• Robust ClientHello parsing, fragmentation & session resumption support
// Listen for certificate issuance or renewal
proxy.on('certificate', (evt) => {
console.log(`Certificate for ${evt.domain} ready, expires on ${evt.expiryDate}`);
});
## Certificate Hooks & Events
await proxy.start();
```
Listen for certificate events via EventEmitter:
- **Port80Handler**:
- `certificate-issued`, `certificate-renewed`, `certificate-failed`
- `manager-started`, `manager-stopped`, `request-forwarded`
- **SmartProxy**:
- `certificate` (domain, publicKey, privateKey, expiryDate, source, isRenewal)
Provide a `certProvider(domain)` in SmartProxy settings to supply static certs or return `'http01'`.
## Configuration Options
### backendProtocol
### NetworkProxy (INetworkProxyOptions)
- `port` (number, required)
- `backendProtocol` ('http1'|'http2', default 'http1')
- `maxConnections` (number, default 10000)
- `keepAliveTimeout` (ms, default 120000)
- `headersTimeout` (ms, default 60000)
- `cors` (object)
- `connectionPoolSize` (number, default 50)
- `logLevel` ('error'|'warn'|'info'|'debug')
- `acme` (IAcmeOptions)
- `useExternalPort80Handler` (boolean)
- `portProxyIntegration` (boolean)
Type: 'http1' | 'http2' (default: 'http1')
### Port80Handler (IAcmeOptions)
- `enabled` (boolean, default true)
- `port` (number, default 80)
- `contactEmail` (string)
- `useProduction` (boolean, default false)
- `renewThresholdDays` (number, default 30)
- `autoRenew` (boolean, default true)
- `certificateStore` (string)
- `skipConfiguredCerts` (boolean)
- `domainForwards` (IDomainForwardConfig[])
Controls the protocol used when proxying requests to backend services. By default, the proxy uses HTTP/1.x (`http.request`). Setting `backendProtocol: 'http2'` establishes HTTP/2 client sessions (`http2.connect`) to your backends for full end-to-end HTTP/2 support (assuming your backend servers support HTTP/2).
### NfTablesProxy (INfTableProxySettings)
- `fromPort` / `toPort` (number|range|array)
- `toHost` (string, default 'localhost')
- `preserveSourceIP`, `deleteOnExit`, `protocol`, `enableLogging`, `ipv6Support` (booleans)
- `allowedSourceIPs`, `bannedSourceIPs` (string[])
- `useIPSets` (boolean, default true)
- `qos`, `netProxyIntegration` (objects)
Example:
```js
import { NetworkProxy } from '@push.rocks/smartproxy';
### Redirect / SslRedirect
- Constructor options: `httpPort`, `httpsPort`, `sslOptions`, `rules` (RedirectRule[])
const proxy = new NetworkProxy({
port: 8443,
backendProtocol: 'http2',
// other options...
});
proxy.start();
```
- **Basic Authentication** - Support for basic auth on proxied routes
- **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues
## Installation
```bash
npm install @push.rocks/smartproxy
```
## Usage
### Basic Reverse Proxy Setup
```typescript
import { NetworkProxy } from '@push.rocks/smartproxy';
// Create a reverse proxy listening on port 443
const proxy = new NetworkProxy({
port: 443
});
// Define reverse proxy configurations
const proxyConfigs = [
{
hostName: 'example.com',
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
publicKey: 'your-cert-content',
privateKey: 'your-key-content',
rewriteHostHeader: true
},
{
hostName: 'api.example.com',
destinationIps: ['127.0.0.1'],
destinationPorts: [4000],
publicKey: 'your-cert-content',
privateKey: 'your-key-content',
// Optional basic auth
authentication: {
type: 'Basic',
user: 'admin',
pass: 'secret'
}
}
];
// Start the proxy and update configurations
(async () => {
await proxy.start();
await proxy.updateProxyConfigs(proxyConfigs);
// Add default headers to all responses
await proxy.addDefaultHeaders({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
});
})();
```
### HTTP to HTTPS Redirection
```typescript
import { SslRedirect } from '@push.rocks/smartproxy';
// Create and start HTTP to HTTPS redirect service on port 80
const redirector = new SslRedirect(80);
redirector.start();
```
### TCP Connection Handling with Domain-based Routing
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
// Configure SmartProxy with domain-based routing
const smartProxy = new SmartProxy({
fromPort: 443,
toPort: 8443,
targetIP: 'localhost', // Default target host
sniEnabled: true, // Enable SNI inspection
// Enhanced reliability settings
initialDataTimeout: 60000, // 60 seconds for initial TLS handshake
socketTimeout: 3600000, // 1 hour socket timeout
maxConnectionLifetime: 3600000, // 1 hour connection lifetime
inactivityTimeout: 3600000, // 1 hour inactivity timeout
maxPendingDataSize: 10 * 1024 * 1024, // 10MB buffer for large TLS handshakes
// Browser compatibility enhancement
enableTlsDebugLogging: false, // Enable for troubleshooting TLS issues
// Port and IP configuration
globalPortRanges: [{ from: 443, to: 443 }],
defaultAllowedIPs: ['*'], // Allow all IPs by default
// Socket optimizations for better connection stability
noDelay: true, // Disable Nagle's algorithm
keepAlive: true, // Enable TCP keepalive
enableKeepAliveProbes: true, // Enhanced keepalive for stability
// Domain-specific routing configuration
domainConfigs: [
{
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
allowedIPs: ['192.168.1.*'], // Restrict access by IP
blockedIPs: ['192.168.1.100'], // Block specific IPs
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
portRanges: [{ from: 443, to: 443 }],
connectionTimeout: 7200000 // Domain-specific timeout (2 hours)
}
],
preserveSourceIP: true
});
smartProxy.start();
```
### NfTables Port Forwarding
```typescript
import { NfTablesProxy } from '@push.rocks/smartproxy';
// Basic usage - forward single port
const basicProxy = new NfTablesProxy({
fromPort: 80,
toPort: 8080,
toHost: 'localhost',
preserveSourceIP: true,
deleteOnExit: true // Automatically clean up rules on process exit
});
// Forward port ranges
const rangeProxy = new NfTablesProxy({
fromPort: { from: 3000, to: 3010 }, // Forward ports 3000-3010
toPort: { from: 8000, to: 8010 }, // To ports 8000-8010
protocol: 'tcp', // TCP protocol (default)
ipv6Support: true, // Enable IPv6 support
enableLogging: true // Enable detailed logging
});
// Multiple port specifications with IP filtering
const advancedProxy = new NfTablesProxy({
fromPort: [80, 443, { from: 8000, to: 8010 }], // Multiple ports/ranges
toPort: [8080, 8443, { from: 18000, to: 18010 }],
allowedSourceIPs: ['10.0.0.0/8', '192.168.1.0/24'], // Only allow these IPs
bannedSourceIPs: ['192.168.1.100'], // Explicitly block these IPs
useIPSets: true, // Use IP sets for efficient IP management
forceCleanSlate: false // Clean all NfTablesProxy rules before starting
});
// Advanced features: QoS, connection tracking, and NetworkProxy integration
const advancedProxy = new NfTablesProxy({
fromPort: 443,
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: {
enabled: true,
redirectLocalhost: true, // Redirect localhost traffic to NetworkProxy
sslTerminationPort: 8443 // Port where NetworkProxy handles SSL
}
});
// Start any of the proxies
await basicProxy.start();
```
### Automatic HTTPS Certificate Management
```typescript
import { Port80Handler } from '@push.rocks/smartproxy';
// Create an ACME handler for Let's Encrypt
const acmeHandler = new Port80Handler({
port: 80,
contactEmail: 'admin@example.com',
useProduction: true, // Use Let's Encrypt production servers (default is staging)
renewThresholdDays: 30, // Renew certificates 30 days before expiry
httpsRedirectPort: 443 // Redirect HTTP to HTTPS on this port
});
// Add domains to manage certificates for
acmeHandler.addDomain({
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
### NetworkProxy Options
| Option | Description | Default |
|----------------|---------------------------------------------------|---------|
| `port` | Port to listen on for HTTPS connections | - |
| `maxConnections` | Maximum concurrent connections | 10000 |
| `keepAliveTimeout` | Keep-alive timeout in milliseconds | 60000 |
| `headersTimeout` | Headers timeout in milliseconds | 60000 |
| `logLevel` | Logging level ('error', 'warn', 'info', 'debug') | 'info' |
| `cors` | CORS configuration object | - |
| `rewriteHostHeader` | Whether to rewrite the Host header | false |
### SmartProxy Settings
| Option | Description | Default |
|---------------------------|--------------------------------------------------------|-------------|
| `fromPort` | Port to listen on | - |
| `toPort` | Destination port to forward to | - |
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
| `defaultAllowedIPs` | IP patterns allowed by default | - |
| `defaultBlockedIPs` | IP patterns blocked by default | - |
| `preserveSourceIP` | Preserve the original client IP | false |
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 3600000 |
| `initialDataTimeout` | Timeout for initial data/handshake in ms | 60000 |
| `socketTimeout` | Socket inactivity timeout in ms | 3600000 |
| `inactivityTimeout` | Connection inactivity check timeout in ms | 3600000 |
| `inactivityCheckInterval` | How often to check for inactive connections in ms | 60000 |
| `maxPendingDataSize` | Maximum bytes to buffer during connection setup | 10485760 |
| `globalPortRanges` | Array of port ranges to listen on | - |
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
| `gracefulShutdownTimeout` | Time in ms to wait during shutdown | 30000 |
| `noDelay` | Disable Nagle's algorithm | true |
| `keepAlive` | Enable TCP keepalive | true |
| `keepAliveInitialDelay` | Initial delay before sending keepalive probes in ms | 30000 |
| `enableKeepAliveProbes` | Enable enhanced TCP keep-alive probes | false |
| `enableTlsDebugLogging` | Enable detailed TLS handshake debugging | false |
| `enableDetailedLogging` | Enable detailed connection logging | false |
| `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true |
### NfTablesProxy Settings
| Option | Description | Default |
|-----------------------|---------------------------------------------------|-------------|
| `fromPort` | Source port(s) or range(s) to forward from | - |
| `toPort` | Destination port(s) or range(s) to forward to | - |
| `toHost` | Destination host to forward to | 'localhost' |
| `preserveSourceIP` | Preserve the original client IP | false |
| `deleteOnExit` | Remove nftables rules when process exits | false |
| `protocol` | Protocol to forward ('tcp', 'udp', or 'all') | 'tcp' |
| `enableLogging` | Enable detailed logging | false |
| `logFormat` | Format for logs ('plain' or 'json') | 'plain' |
| `ipv6Support` | Enable IPv6 support | false |
| `allowedSourceIPs` | Array of IP addresses/CIDR allowed to connect | - |
| `bannedSourceIPs` | Array of IP addresses/CIDR blocked from connecting | - |
| `useIPSets` | Use nftables sets for efficient IP management | true |
| `forceCleanSlate` | Clear all NfTablesProxy rules before starting | false |
| `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) | - |
## Advanced Features
### TLS Handshake Optimization
The enhanced `SmartProxy` implementation includes significant improvements for TLS handshake handling:
- Robust SNI extraction with improved error handling
- Increased buffer size for complex TLS handshakes (10MB)
- Longer initial handshake timeout (60 seconds)
- Detection and tracking of TLS connection states
- Optional detailed TLS debug logging for troubleshooting
- Browser compatibility fixes for Chrome certificate errors
```typescript
// Example configuration to solve Chrome certificate errors
const portProxy = new SmartProxy({
// ... other settings
initialDataTimeout: 60000, // Give browser more time for handshake
maxPendingDataSize: 10 * 1024 * 1024, // Larger buffer for complex handshakes
enableTlsDebugLogging: true, // Enable when troubleshooting
});
```
### Connection Management and Monitoring
The `SmartProxy` class includes built-in connection tracking and monitoring:
- Automatic cleanup of idle connections with configurable timeouts
- Timeouts for connections that exceed maximum lifetime
- Detailed logging of connection states
- Termination statistics
- Randomized timeouts to prevent "thundering herd" problems
- Per-domain timeout configuration
### WebSocket Support
The `NetworkProxy` class provides WebSocket support with:
- WebSocket connection proxying
- Automatic heartbeat monitoring
- Connection cleanup for inactive WebSockets
### SNI-based Routing
The `SmartProxy` class can inspect the SNI (Server Name Indication) field in TLS handshakes to route connections based on the requested domain:
- Multiple backend targets per domain
- Round-robin load balancing
- Domain-specific allowed IP ranges
- Protection against SNI renegotiation attacks
### Enhanced NfTables Management
The `NfTablesProxy` class offers advanced capabilities:
- Support for multiple port ranges and individual ports
- More efficient IP filtering using nftables sets
- IPv6 support with full feature parity
- 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
- Comprehensive cleanup on shutdown
### Port80Handler with Glob Pattern Support
The `Port80Handler` class 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
### SmartProxy (IPortProxySettings)
- `fromPort`, `toPort` (number)
- `domainConfigs` (IDomainConfig[])
- `sniEnabled`, `defaultAllowedIPs`, `preserveSourceIP` (booleans)
- Timeouts: `initialDataTimeout`, `socketTimeout`, `inactivityTimeout`, etc.
- Socket opts: `noDelay`, `keepAlive`, `enableKeepAliveProbes`
- `acme` (IAcmeOptions), `certProvider` (callback)
- `useNetworkProxy` (number[]), `networkProxyPort` (number)
## Troubleshooting
### Browser Certificate Errors
### NetworkProxy
- Verify ports, certificates and `rejectUnauthorized` for TLS errors
- Configure CORS or use `addDefaultHeaders` for preflight issues
- Increase `maxConnections` or `connectionPoolSize` under load
If you experience certificate errors in browsers, especially in Chrome, try these solutions:
### Port80Handler
- Run as root or grant CAP_NET_BIND_SERVICE for port 80
- Inspect `certificate-failed` events and switch staging/production
1. **Increase Initial Data Timeout**: Set `initialDataTimeout` to 60 seconds or higher
2. **Increase Buffer Size**: Set `maxPendingDataSize` to 10MB or higher
3. **Enable TLS Debug Logging**: Set `enableTlsDebugLogging: true` to troubleshoot handshake issues
4. **Enable Keep-Alive Probes**: Set `enableKeepAliveProbes: true` for better connection stability
5. **Check Certificate Chain**: Ensure your certificate chain is complete and in the correct order
### NfTablesProxy
- Ensure `nft` is installed and run with sufficient privileges
- Use `forceCleanSlate:true` to clear conflicting rules
```typescript
// Configuration to fix Chrome certificate errors
const smartProxy = new SmartProxy({
// ... other settings
initialDataTimeout: 60000,
maxPendingDataSize: 10 * 1024 * 1024,
enableTlsDebugLogging: true,
enableKeepAliveProbes: true
});
```
### Redirect / SslRedirect
- Check `fromHost`/`fromPath` patterns and Host headers
- Validate `sslOptions` key/cert correctness
### Connection Stability
For improved connection stability in high-traffic environments:
1. **Set Appropriate Timeouts**: Use longer timeouts for long-lived connections
2. **Use Domain-Specific Timeouts**: Configure per-domain timeouts for different types of services
3. **Enable TCP Keep-Alive**: Ensure `keepAlive` is set to `true`
4. **Monitor Connection Statistics**: Enable detailed logging to track termination reasons
5. **Fine-tune Inactivity Checks**: Adjust `inactivityCheckInterval` based on your traffic patterns
### NfTables Troubleshooting
If you're experiencing issues with NfTablesProxy:
1. **Enable Detailed Logging**: Set `enableLogging: true` to see all rule operations
2. **Force Clean Slate**: Use `forceCleanSlate: true` to remove any lingering rules
3. **Use IP Sets**: Enable `useIPSets: true` for cleaner rule management
4. **Check Permissions**: Ensure your process has sufficient permissions to modify nftables
5. **Verify IPv6 Support**: If using `ipv6Support: true`, ensure ip6tables is available
### SmartProxy & SniHandler
- Increase `initialDataTimeout`/`maxPendingDataSize` for large ClientHello
- Enable `enableTlsDebugLogging` to trace handshake
- Ensure `allowSessionTicket` and fragmentation support for resumption
## License and Legal Information

View File

@ -1,31 +1,29 @@
## Plan: Integrate @push.rocks/smartacme into Port80Handler
# Project Simplification Plan
- [x] read the complete README of @push.rocks/smartacme and understand the API.
- [x] Add imports to ts/plugins.ts:
- import * as smartacme from '@push.rocks/smartacme';
- export { smartacme };
- [x] In Port80Handler.start():
- Instantiate SmartAcme and use the in memory certmanager.
- use the DisklessHttp01Handler implemented in classes.port80handler.ts
- Call `await smartAcme.start()` before binding HTTP server.
- [x] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code.
- [x] Update `handleRequest()` to let DisklessHttp01Handler serve challenges.
- [x] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo.
## Plan: Certificate Provider Hook & Observable Emission
This document outlines a roadmap to simplify and refactor the SmartProxy & NetworkProxy codebase for better maintainability, reduced duplication, and clearer configuration.
- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts):
- Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`.
- Add optional `certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>`.
- [x] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts):
- Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`.
- Call `super()` in constructor.
- In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain:
- Invoke `certProvider(domain)` if provided, defaulting to `'http01'`.
- If result is `'http01'`, register domain with `Port80Handler` for ACME challenges.
- If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge`
- Subscribe to `Port80HandlerEvents.CERTIFICATE_ISSUED` and `CERTIFICATE_RENEWED` and re-emit on `SmartProxy` as `'certificate'` events (include `domain`, `publicKey`, `privateKey`, `expiryDate`, `source: 'http01'`, `isRenewal` flag).
- [x] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts):
- Add public method `applyExternalCertificate(data: ICertificateData): void` to forward static certs into `NetworkProxy`.
- [ ] Define `SmartProxy` `'certificate'` event interface in TypeScript and update documentation.
- [ ] Update README with usage examples showing `certProvider` callback and listening for `'certificate'` events.
## Goals
- Eliminate duplicate code and shared types
- Unify certificate management flow across components
- Simplify configuration schemas and option handling
- Centralize plugin imports and module interfaces
- Strengthen type safety and linting
- Improve test coverage and CI integration
## Plan
- [x] Extract all shared interfaces and types (e.g., certificate, proxy, domain configs) into a common `ts/common` module
- [x] Consolidate ACME/Port80Handler logic:
- [x] Merge standalone Port80Handler into a single certificate service
- [x] Remove duplicate ACME setup in SmartProxy and NetworkProxy
- [x] Unify configuration options:
- [x] Merge `INetworkProxyOptions.acme`, `IPort80HandlerOptions`, and `port80HandlerConfig` into one schema
- [x] Deprecate old option names and provide clear upgrade path
- [x] Centralize plugin imports in `ts/plugins.ts` and update all modules to use it
- [x] Remove legacy or unused code paths (e.g., old HTTP/2 fallback logic if obsolete)
- [ ] Enhance and expand test coverage:
- Add unit tests for certificate issuance, renewal, and error handling
- Add integration tests for HTTP challenge routing and request forwarding
- [ ] Update main README.md with architecture overview and configuration guide
- [ ] Review and prune external dependencies no longer needed
Once these steps are complete, the project will be cleaner, easier to understand, and simpler to extend.

View File

@ -0,0 +1,140 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import { CertProvisioner } from '../ts/smartproxy/classes.pp.certprovisioner.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from '../ts/smartproxy/classes.pp.interfaces.js';
import type { ICertificateData } from '../ts/port80handler/classes.port80handler.js';
// Fake Port80Handler stub
class FakePort80Handler extends plugins.EventEmitter {
public domainsAdded: string[] = [];
public renewCalled: string[] = [];
addDomain(opts: { domainName: string; sslRedirect: boolean; acmeMaintenance: boolean }) {
this.domainsAdded.push(opts.domainName);
}
async renewCertificate(domain: string): Promise<void> {
this.renewCalled.push(domain);
}
}
// Fake NetworkProxyBridge stub
class FakeNetworkProxyBridge {
public appliedCerts: ICertificateData[] = [];
applyExternalCertificate(cert: ICertificateData) {
this.appliedCerts.push(cert);
}
}
tap.test('CertProvisioner handles static provisioning', async () => {
const domain = 'static.com';
const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
// certProvider returns static certificate
const certProvider = async (d: string): Promise<ISmartProxyCertProvisionObject> => {
expect(d).toEqual(domain);
return {
domainName: domain,
publicKey: 'CERT',
privateKey: 'KEY',
validUntil: Date.now() + 3600 * 1000
};
};
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1, // low renew threshold
1, // short interval
false // disable auto renew for unit test
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.start();
// Static flow: no addDomain, certificate applied via bridge
expect(fakePort80.domainsAdded.length).toEqual(0);
expect(fakeBridge.appliedCerts.length).toEqual(1);
expect(events.length).toEqual(1);
const evt = events[0];
expect(evt.domain).toEqual(domain);
expect(evt.certificate).toEqual('CERT');
expect(evt.privateKey).toEqual('KEY');
expect(evt.isRenewal).toEqual(false);
expect(evt.source).toEqual('static');
});
tap.test('CertProvisioner handles http01 provisioning', async () => {
const domain = 'http01.com';
const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
// certProvider returns http01 directive
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => 'http01';
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.start();
// HTTP-01 flow: addDomain called, no static cert applied
expect(fakePort80.domainsAdded).toEqual([domain]);
expect(fakeBridge.appliedCerts.length).toEqual(0);
expect(events.length).toEqual(0);
});
tap.test('CertProvisioner on-demand http01 renewal', async () => {
const domain = 'renew.com';
const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => 'http01';
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
// requestCertificate should call renewCertificate
await prov.requestCertificate(domain);
expect(fakePort80.renewCalled).toEqual([domain]);
});
tap.test('CertProvisioner on-demand static provisioning', async () => {
const domain = 'ondemand.com';
const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => ({
domainName: domain,
publicKey: 'PKEY',
privateKey: 'PRIV',
validUntil: Date.now() + 1000
});
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.requestCertificate(domain);
expect(fakeBridge.appliedCerts.length).toEqual(1);
expect(events.length).toEqual(1);
expect(events[0].domain).toEqual(domain);
expect(events[0].source).toEqual('static');
});
export default tap.start();

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '7.2.0',
version: '10.0.3',
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,4 @@
import * as http from 'http';
import * as url from 'url';
import * as tsclass from '@tsclass/tsclass';
import * as plugins from './plugins.js';
/**
* Optional path pattern configuration that can be added to proxy configs
@ -13,7 +11,7 @@ export interface IPathPatternConfig {
* Interface for router result with additional metadata
*/
export interface IRouterResult {
config: tsclass.network.IReverseProxyConfig;
config: plugins.tsclass.network.IReverseProxyConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
@ -36,11 +34,11 @@ export interface IRouterResult {
*/
export class ProxyRouter {
// Store original configs for reference
private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = [];
private reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
// Default config to use when no match is found (optional)
private defaultConfig?: tsclass.network.IReverseProxyConfig;
private defaultConfig?: plugins.tsclass.network.IReverseProxyConfig;
// Store path patterns separately since they're not in the original interface
private pathPatterns: Map<tsclass.network.IReverseProxyConfig, string> = new Map();
private pathPatterns: Map<plugins.tsclass.network.IReverseProxyConfig, string> = new Map();
// Logger interface
private logger: {
error: (message: string, data?: any) => void;
@ -50,7 +48,7 @@ export class ProxyRouter {
};
constructor(
configs?: tsclass.network.IReverseProxyConfig[],
configs?: plugins.tsclass.network.IReverseProxyConfig[],
logger?: {
error: (message: string, data?: any) => void;
warn: (message: string, data?: any) => void;
@ -68,7 +66,7 @@ export class ProxyRouter {
* Sets a new set of reverse configs to be routed to
* @param reverseCandidatesArg Array of reverse proxy configurations
*/
public setNewProxyConfigs(reverseCandidatesArg: tsclass.network.IReverseProxyConfig[]): void {
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]): void {
this.reverseProxyConfigs = [...reverseCandidatesArg];
// Find default config if any (config with "*" as hostname)
@ -82,7 +80,7 @@ export class ProxyRouter {
* @param req The incoming HTTP request
* @returns The matching proxy config or undefined if no match found
*/
public routeReq(req: http.IncomingMessage): tsclass.network.IReverseProxyConfig {
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
const result = this.routeReqWithDetails(req);
return result ? result.config : undefined;
}
@ -92,7 +90,7 @@ export class ProxyRouter {
* @param req The incoming HTTP request
* @returns Detailed routing result including matched config and path information
*/
public routeReqWithDetails(req: http.IncomingMessage): IRouterResult | undefined {
public routeReqWithDetails(req: plugins.http.IncomingMessage): IRouterResult | undefined {
// Extract and validate host header
const originalHost = req.headers.host;
if (!originalHost) {
@ -101,7 +99,7 @@ export class ProxyRouter {
}
// Parse URL for path matching
const parsedUrl = url.parse(req.url || '/');
const parsedUrl = plugins.url.parse(req.url || '/');
const urlPath = parsedUrl.pathname || '/';
// Extract hostname without port
@ -351,7 +349,7 @@ export class ProxyRouter {
* Gets all currently active proxy configurations
* @returns Array of all active configurations
*/
public getProxyConfigs(): tsclass.network.IReverseProxyConfig[] {
public getProxyConfigs(): plugins.tsclass.network.IReverseProxyConfig[] {
return [...this.reverseProxyConfigs];
}
@ -375,7 +373,7 @@ export class ProxyRouter {
* @param pathPattern Optional path pattern for route matching
*/
public addProxyConfig(
config: tsclass.network.IReverseProxyConfig,
config: plugins.tsclass.network.IReverseProxyConfig,
pathPattern?: string
): void {
this.reverseProxyConfigs.push(config);
@ -393,7 +391,7 @@ export class ProxyRouter {
* @returns Boolean indicating if the config was found and updated
*/
public setPathPattern(
config: tsclass.network.IReverseProxyConfig,
config: plugins.tsclass.network.IReverseProxyConfig,
pathPattern: string
): boolean {
const exists = this.reverseProxyConfigs.includes(config);

23
ts/common/acmeFactory.ts Normal file
View File

@ -0,0 +1,23 @@
import * as fs from 'fs';
import * as path from 'path';
import type { IAcmeOptions } from './types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
/**
* Factory to create a Port80Handler with common setup.
* Ensures the certificate store directory exists and instantiates the handler.
* @param options Port80Handler configuration options
* @returns A new Port80Handler instance
*/
export function buildPort80Handler(
options: IAcmeOptions
): Port80Handler {
if (options.certificateStore) {
const certStorePath = path.resolve(options.certificateStore);
if (!fs.existsSync(certStorePath)) {
fs.mkdirSync(certStorePath, { recursive: true });
console.log(`Created certificate store directory: ${certStorePath}`);
}
}
return new Port80Handler(options);
}

34
ts/common/eventUtils.ts Normal file
View File

@ -0,0 +1,34 @@
import type { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from './types.js';
import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from './types.js';
/**
* Subscribers callback definitions for Port80Handler events
*/
export interface Port80HandlerSubscribers {
onCertificateIssued?: (data: ICertificateData) => void;
onCertificateRenewed?: (data: ICertificateData) => void;
onCertificateFailed?: (data: ICertificateFailure) => void;
onCertificateExpiring?: (data: ICertificateExpiring) => void;
}
/**
* Subscribes to Port80Handler events based on provided callbacks
*/
export function subscribeToPort80Handler(
handler: Port80Handler,
subscribers: Port80HandlerSubscribers
): void {
if (subscribers.onCertificateIssued) {
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued);
}
if (subscribers.onCertificateRenewed) {
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed);
}
if (subscribers.onCertificateFailed) {
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed);
}
if (subscribers.onCertificateExpiring) {
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring);
}
}

93
ts/common/types.ts Normal file
View File

@ -0,0 +1,93 @@
import * as plugins from '../plugins.js';
/**
* Shared types for certificate management and domain options
*/
/**
* 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
}
/**
* Certificate data that can be emitted via events or set from outside
*/
export interface ICertificateData {
domain: string;
certificate: string;
privateKey: string;
expiryDate: Date;
}
/**
* Events emitted by the Port80Handler
*/
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',
}
/**
* Certificate failure payload type
*/
export interface ICertificateFailure {
domain: string;
error: string;
isRenewal: boolean;
}
/**
* Certificate expiry payload type
*/
export interface ICertificateExpiring {
domain: string;
expiryDate: Date;
daysRemaining: number;
}
/**
* Forwarding configuration for specific domains in ACME setup
*/
export interface IDomainForwardConfig {
domain: string;
forwardConfig?: IForwardConfig;
acmeForwardConfig?: IForwardConfig;
sslRedirect?: boolean;
}
/**
* Unified ACME configuration options used across proxies and handlers
*/
export interface IAcmeOptions {
enabled?: boolean; // Whether ACME is enabled
port?: number; // Port to listen on for ACME challenges (default: 80)
contactEmail?: string; // Email for Let's Encrypt account
useProduction?: boolean; // Use production environment (default: staging)
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
renewThresholdDays?: number; // Days before expiry to renew certificates
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
}
export interface IReverseProxyConfig extends plugins.tsclass.network.IReverseProxyConfig {}

View File

@ -5,3 +5,6 @@ export * from './redirect/classes.redirect.js';
export * from './smartproxy/classes.smartproxy.js';
export * from './smartproxy/classes.pp.snihandler.js';
export * from './smartproxy/classes.pp.interfaces.js';
import * as types from './common/types.js';
export { types };

View File

@ -3,7 +3,11 @@ import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js';
import { Port80Handler, Port80HandlerEvents, type IDomainOptions } from '../port80handler/classes.port80handler.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { buildPort80Handler } from '../common/acmeFactory.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { IDomainOptions } from '../common/types.js';
/**
* Manages SSL certificates for NetworkProxy including ACME integration
@ -101,12 +105,14 @@ export class CertificateManager {
this.port80Handler = handler;
this.externalPort80Handler = true;
// Register event handlers
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.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
// Subscribe to Port80Handler events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: this.handleCertificateIssued.bind(this),
onCertificateRenewed: this.handleCertificateIssued.bind(this),
onCertificateFailed: this.handleCertificateFailed.bind(this),
onCertificateExpiring: (data) => {
this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
}
});
this.logger.info('External Port80Handler connected to CertificateManager');
@ -348,26 +354,24 @@ export class CertificateManager {
return null;
}
// Create certificate manager
this.port80Handler = new Port80Handler({
// Build and configure Port80Handler
this.port80Handler = buildPort80Handler({
port: this.options.acme.port,
contactEmail: this.options.acme.contactEmail,
useProduction: this.options.acme.useProduction,
renewThresholdDays: this.options.acme.renewThresholdDays,
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
renewCheckIntervalHours: 24, // Check daily for renewals
enabled: this.options.acme.enabled,
autoRenew: this.options.acme.autoRenew,
certificateStore: this.options.acme.certificateStore,
skipConfiguredCerts: this.options.acme.skipConfiguredCerts
});
// Register event handlers
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.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
// Subscribe to Port80Handler events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: this.handleCertificateIssued.bind(this),
onCertificateRenewed: this.handleCertificateIssued.bind(this),
onCertificateFailed: this.handleCertificateFailed.bind(this),
onCertificateExpiring: (data) => {
this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
}
});
// Start the handler

View File

@ -1,5 +1,10 @@
import * as plugins from '../plugins.js';
/**
* Configuration options for NetworkProxy
*/
import type { IAcmeOptions } from '../common/types.js';
/**
* Configuration options for NetworkProxy
*/
@ -24,16 +29,7 @@ export interface INetworkProxyOptions {
backendProtocol?: 'http1' | 'http2';
// ACME certificate management options
acme?: {
enabled?: boolean; // Whether to enable automatic certificate management
port?: number; // Port to listen on for ACME challenges (default: 80)
contactEmail?: string; // Email for Let's Encrypt account
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
certificateStore?: string; // Directory to store certificates (default: ./certs)
skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured
};
acme?: IAcmeOptions;
}
/**

View File

@ -7,7 +7,6 @@ import * as tls from 'tls';
import * as url from 'url';
import * as http2 from 'http2';
export { EventEmitter, http, https, net, tls, url, http2 };
// tsclass scope
@ -25,7 +24,19 @@ import * as smartstring from '@push.rocks/smartstring';
import * as smartacme from '@push.rocks/smartacme';
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js';
export { lik, smartdelay, smartrequest, smartpromise, smartstring, smartacme, smartacmePlugins, smartacmeHandlers };
import * as taskbuffer from '@push.rocks/taskbuffer';
export {
lik,
smartdelay,
smartrequest,
smartpromise,
smartstring,
smartacme,
smartacmePlugins,
smartacmeHandlers,
taskbuffer,
};
// third party scope
import prettyMs from 'pretty-ms';

View File

@ -1,7 +1,15 @@
import * as plugins from '../plugins.js';
import { IncomingMessage, ServerResponse } from 'http';
import * as fs from 'fs';
import * as path from 'path';
import { Port80HandlerEvents } from '../common/types.js';
import type {
IForwardConfig,
IDomainOptions,
ICertificateData,
ICertificateFailure,
ICertificateExpiring,
IAcmeOptions
} from '../common/types.js';
// (fs and path I/O moved to CertProvisioner)
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
class DisklessHttp01Handler {
private storage: Map<string, string>;
@ -46,24 +54,6 @@ export class ServerError extends Port80HandlerError {
}
}
/**
* 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
@ -81,59 +71,8 @@ interface IDomainCertificate {
/**
* Configuration options for the Port80Handler
*/
interface IPort80HandlerOptions {
port?: number;
contactEmail?: string;
useProduction?: boolean;
renewThresholdDays?: number;
httpsRedirectPort?: number;
renewCheckIntervalHours?: number;
enabled?: boolean; // Whether ACME is enabled at all
autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
}
// Port80Handler options moved to common types
/**
* Certificate data that can be emitted via events or set from outside
*/
export interface ICertificateData {
domain: string;
certificate: string;
privateKey: string;
expiryDate: Date;
}
/**
* Events emitted by the Port80Handler
*/
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',
}
/**
* Certificate failure payload type
*/
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
@ -146,15 +85,16 @@ export class Port80Handler extends plugins.EventEmitter {
// SmartAcme instance for certificate management
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private server: plugins.http.Server | null = null;
private renewalTimer: NodeJS.Timeout | null = null;
// Renewal scheduling is handled externally by SmartProxy
// (Removed internal renewal timer)
private isShuttingDown: boolean = false;
private options: Required<IPort80HandlerOptions>;
private options: Required<IAcmeOptions>;
/**
* Creates a new Port80Handler
* @param options Configuration options
*/
constructor(options: IPort80HandlerOptions = {}) {
constructor(options: IAcmeOptions = {}) {
super();
this.domainCertificates = new Map<string, IDomainCertificate>();
@ -163,13 +103,14 @@ export class Port80Handler extends plugins.EventEmitter {
port: options.port ?? 80,
contactEmail: options.contactEmail ?? 'admin@example.com',
useProduction: options.useProduction ?? false, // Safer default: staging
renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
httpsRedirectPort: options.httpsRedirectPort ?? 443,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
enabled: options.enabled ?? true, // Enable by default
autoRenew: options.autoRenew ?? true, // Auto-renew by default
certificateStore: options.certificateStore ?? './certs', // Default store location
skipConfiguredCerts: options.skipConfiguredCerts ?? false
certificateStore: options.certificateStore ?? './certs',
skipConfiguredCerts: options.skipConfiguredCerts ?? false,
renewThresholdDays: options.renewThresholdDays ?? 30,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
autoRenew: options.autoRenew ?? true,
domainForwards: options.domainForwards ?? []
};
}
@ -204,10 +145,6 @@ export class Port80Handler extends plugins.EventEmitter {
return new Promise((resolve, reject) => {
try {
// Load certificates from store if enabled
if (this.options.certificateStore) {
this.loadCertificatesFromStore();
}
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
@ -223,7 +160,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.server.listen(this.options.port, () => {
console.log(`Port80Handler is listening on port ${this.options.port}`);
this.startRenewalTimer();
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
// Start certificate process for domains with acmeMaintenance enabled
@ -260,11 +196,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.isShuttingDown = true;
// Stop the renewal timer
if (this.renewalTimer) {
clearInterval(this.renewalTimer);
this.renewalTimer = null;
}
return new Promise<void>((resolve) => {
if (this.server) {
@ -379,10 +310,7 @@ export class Port80Handler extends plugins.EventEmitter {
console.log(`Certificate set for ${domain}`);
// Save certificate to store if enabled
if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
// (Persistence of certificates moved to CertProvisioner)
// Emit certificate event
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
@ -417,134 +345,7 @@ export class Port80Handler extends plugins.EventEmitter {
};
}
/**
* Saves a certificate to the filesystem store
* @param domain The domain for the certificate
* @param certificate The certificate (PEM format)
* @param privateKey The private key (PEM format)
* @private
*/
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
// Skip if certificate store is not enabled
if (!this.options.certificateStore) return;
try {
const storePath = this.options.certificateStore;
// Ensure the directory exists
if (!fs.existsSync(storePath)) {
fs.mkdirSync(storePath, { recursive: true });
console.log(`Created certificate store directory: ${storePath}`);
}
const certPath = path.join(storePath, `${domain}.cert.pem`);
const keyPath = path.join(storePath, `${domain}.key.pem`);
// Write certificate and private key files
fs.writeFileSync(certPath, certificate);
fs.writeFileSync(keyPath, privateKey);
// Set secure permissions for private key
try {
fs.chmodSync(keyPath, 0o600);
} catch (err) {
console.log(`Warning: Could not set secure permissions on ${keyPath}`);
}
console.log(`Saved certificate for ${domain} to ${certPath}`);
} catch (err) {
console.error(`Error saving certificate for ${domain}:`, err);
}
}
/**
* Loads certificates from the certificate store
* @private
*/
private loadCertificatesFromStore(): void {
if (!this.options.certificateStore) return;
try {
const storePath = this.options.certificateStore;
// Ensure the directory exists
if (!fs.existsSync(storePath)) {
fs.mkdirSync(storePath, { recursive: true });
console.log(`Created certificate store directory: ${storePath}`);
return;
}
// Get list of certificate files
const files = fs.readdirSync(storePath);
const certFiles = files.filter(file => file.endsWith('.cert.pem'));
// Load each certificate
for (const certFile of certFiles) {
const domain = certFile.replace('.cert.pem', '');
const keyFile = `${domain}.key.pem`;
// Skip if key file doesn't exist
if (!files.includes(keyFile)) {
console.log(`Warning: Found certificate for ${domain} but no key file`);
continue;
}
// Skip if we should skip configured certs
if (this.options.skipConfiguredCerts) {
const domainInfo = this.domainCertificates.get(domain);
if (domainInfo && domainInfo.certObtained) {
console.log(`Skipping already configured certificate for ${domain}`);
continue;
}
}
// Load certificate and key
try {
const certificate = fs.readFileSync(path.join(storePath, certFile), 'utf8');
const privateKey = fs.readFileSync(path.join(storePath, keyFile), 'utf8');
// Extract expiry date
let expiryDate: Date | undefined;
try {
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
expiryDate = new Date(matches[1]);
}
} catch (err) {
console.log(`Warning: Could not extract expiry date from certificate for ${domain}`);
}
// Check if domain is already registered
let domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
// Register domain if not already registered
domainInfo = {
options: {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
},
certObtained: false,
obtainingInProgress: false
};
this.domainCertificates.set(domain, domainInfo);
}
// Set certificate
domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey;
domainInfo.certObtained = true;
domainInfo.expiryDate = expiryDate;
console.log(`Loaded certificate for ${domain} from store, valid until ${expiryDate?.toISOString() || 'unknown'}`);
} catch (err) {
console.error(`Error loading certificate for ${domain}:`, err);
}
}
} catch (err) {
console.error('Error loading certificates from store:', err);
}
}
/**
* Check if a domain is a glob pattern
@ -634,13 +435,19 @@ export class Port80Handler extends plugins.EventEmitter {
const { domainInfo, pattern } = domainMatch;
const options = domainInfo.options;
// Serve or forward ACME HTTP-01 challenge requests
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) {
// Handle ACME HTTP-01 challenge requests or forwarding
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
// Forward ACME requests if configured
if (options.acmeForward) {
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
return;
}
// If not managing ACME for this domain, return 404
if (!options.acmeMaintenance) {
res.statusCode = 404;
res.end('Not found');
return;
}
// Serve challenge response from in-memory storage
const token = req.url.split('/').pop() || '';
const keyAuth = this.acmeHttp01Storage.get(token);
@ -804,9 +611,7 @@ export class Port80Handler extends plugins.EventEmitter {
domainInfo.expiryDate = expiryDate;
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
// Persistence moved to CertProvisioner
const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED
: Port80HandlerEvents.CERTIFICATE_ISSUED;
@ -830,89 +635,6 @@ export class Port80Handler extends plugins.EventEmitter {
}
}
/**
* Starts the certificate renewal timer
*/
private startRenewalTimer(): void {
if (this.renewalTimer) {
clearInterval(this.renewalTimer);
}
// Convert hours to milliseconds
const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000;
this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval);
// Prevent the timer from keeping the process alive
if (this.renewalTimer.unref) {
this.renewalTimer.unref();
}
console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`);
}
/**
* Checks for certificates that need renewal
*/
private checkForRenewals(): void {
if (this.isShuttingDown) {
return;
}
// Skip renewal if auto-renewal is disabled
if (this.options.autoRenew === false) {
console.log('Auto-renewal is disabled, skipping certificate renewal check');
return;
}
console.log('Checking for certificates that need renewal...');
const now = new Date();
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip 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
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
continue;
}
// Skip domains without expiry dates
if (!domainInfo.expiryDate) {
continue;
}
const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime();
// Check if certificate is near expiry
if (timeUntilExpiry <= renewThresholdMs) {
console.log(`Certificate for ${domain} expires soon, renewing...`);
const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000));
this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, {
domain,
expiryDate: domainInfo.expiryDate,
daysRemaining
} as ICertificateExpiring);
// Start renewal process
this.obtainCertificate(domain, true).catch(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
@ -1038,7 +760,19 @@ export class Port80Handler extends plugins.EventEmitter {
* Gets configuration details
* @returns Current configuration
*/
public getConfig(): Required<IPort80HandlerOptions> {
public getConfig(): Required<IAcmeOptions> {
return { ...this.options };
}
/**
* Request a certificate renewal for a specific domain.
* @param domain The domain to renew.
*/
public async renewCertificate(domain: string): Promise<void> {
if (!this.domainCertificates.has(domain)) {
throw new Port80HandlerError(`Domain not managed: ${domain}`);
}
// Trigger renewal via ACME
await this.obtainCertificate(domain, true);
}
}

View File

@ -0,0 +1,188 @@
import * as plugins from '../plugins.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { ICertificateData } from '../common/types.js';
import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
/**
* CertProvisioner manages certificate provisioning and renewal workflows,
* unifying static certificates and HTTP-01 challenges via Port80Handler.
*/
export class CertProvisioner extends plugins.EventEmitter {
private domainConfigs: IDomainConfig[];
private port80Handler: Port80Handler;
private networkProxyBridge: NetworkProxyBridge;
private certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>;
private renewThresholdDays: number;
private renewCheckIntervalHours: number;
private autoRenew: boolean;
private renewManager?: plugins.taskbuffer.TaskManager;
// Track provisioning type per domain: 'http01' or 'static'
private provisionMap: Map<string, 'http01' | 'static'>;
/**
* @param domainConfigs Array of domain configuration objects
* @param port80Handler HTTP-01 challenge handler instance
* @param networkProxyBridge Bridge for applying external certificates
* @param certProvider Optional callback returning a static cert or 'http01'
* @param renewThresholdDays Days before expiry to trigger renewals
* @param renewCheckIntervalHours Interval in hours to check for renewals
* @param autoRenew Whether to automatically schedule renewals
*/
constructor(
domainConfigs: IDomainConfig[],
port80Handler: Port80Handler,
networkProxyBridge: NetworkProxyBridge,
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>,
renewThresholdDays: number = 30,
renewCheckIntervalHours: number = 24,
autoRenew: boolean = true,
forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = []
) {
super();
this.domainConfigs = domainConfigs;
this.port80Handler = port80Handler;
this.networkProxyBridge = networkProxyBridge;
this.certProvider = certProvider;
this.renewThresholdDays = renewThresholdDays;
this.renewCheckIntervalHours = renewCheckIntervalHours;
this.autoRenew = autoRenew;
this.provisionMap = new Map();
this.forwardConfigs = forwardConfigs;
}
/**
* Start initial provisioning and schedule renewals.
*/
public async start(): Promise<void> {
// Subscribe to Port80Handler certificate events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: false });
},
onCertificateRenewed: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: true });
}
});
// Apply external forwarding for ACME challenges (e.g. Synology)
for (const f of this.forwardConfigs) {
this.port80Handler.addDomain({
domainName: f.domain,
sslRedirect: f.sslRedirect,
acmeMaintenance: false,
forward: f.forwardConfig,
acmeForward: f.acmeForwardConfig
});
}
// Initial provisioning for all domains
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
for (const domain of domains) {
// Skip wildcard domains
if (domain.includes('*')) continue;
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
if (this.certProvider) {
try {
provision = await this.certProvider(domain);
} catch (err) {
console.error(`certProvider error for ${domain}:`, err);
}
}
if (provision === 'http01') {
this.provisionMap.set(domain, 'http01');
this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true });
} else {
this.provisionMap.set(domain, 'static');
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
}
}
// Schedule renewals if enabled
if (this.autoRenew) {
this.renewManager = new plugins.taskbuffer.TaskManager();
const renewTask = new plugins.taskbuffer.Task({
name: 'CertificateRenewals',
taskFunction: async () => {
for (const [domain, type] of this.provisionMap.entries()) {
// Skip wildcard domains
if (domain.includes('*')) continue;
try {
if (type === 'http01') {
await this.port80Handler.renewCertificate(domain);
} else if (type === 'static' && this.certProvider) {
const provision2 = await this.certProvider(domain);
if (provision2 !== 'http01') {
const certObj = provision2 as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: true });
}
}
} catch (err) {
console.error(`Renewal error for ${domain}:`, err);
}
}
}
});
const hours = this.renewCheckIntervalHours;
const cronExpr = `0 0 */${hours} * * *`;
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
this.renewManager.start();
}
}
/**
* Stop all scheduled renewal tasks.
*/
public async stop(): Promise<void> {
// Stop scheduled renewals
if (this.renewManager) {
this.renewManager.stop();
}
}
/**
* Request a certificate on-demand for the given domain.
* @param domain Domain name to provision
*/
public async requestCertificate(domain: string): Promise<void> {
// Skip wildcard domains
if (domain.includes('*')) {
throw new Error(`Cannot request certificate for wildcard domain: ${domain}`);
}
// Determine provisioning method
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
if (this.certProvider) {
provision = await this.certProvider(domain);
}
if (provision === 'http01') {
await this.port80Handler.renewCertificate(domain);
} else {
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
}
}
}

View File

@ -21,6 +21,7 @@ export interface IDomainConfig {
}
/** Port proxy settings including global allowed port ranges */
import type { IAcmeOptions } from '../common/types.js';
export interface IPortProxySettings {
fromPort: number;
toPort: number;
@ -83,43 +84,9 @@ export interface IPortProxySettings {
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
// Port80Handler configuration (replaces ACME configuration)
port80HandlerConfig?: {
enabled?: boolean; // Whether to enable automatic certificate management
port?: number; // Port to listen on for ACME challenges (default: 80)
contactEmail?: string; // Email for Let's Encrypt account
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
certificateStore?: string; // Directory to store certificates (default: ./certs)
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
// Domain-specific forwarding configurations
domainForwards?: Array<{
domain: string;
forwardConfig?: {
ip: string;
port: number;
};
acmeForwardConfig?: {
ip: string;
port: number;
};
}>;
};
// ACME configuration options for SmartProxy
acme?: IAcmeOptions;
// Legacy ACME configuration (deprecated, use port80HandlerConfig instead)
acme?: {
enabled?: boolean;
port?: number;
contactEmail?: string;
useProduction?: boolean;
renewThresholdDays?: number;
autoRenew?: boolean;
certificateStore?: string;
skipConfiguredCerts?: boolean;
};
/**
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
* or a static certificate object for immediate provisioning.

View File

@ -1,6 +1,9 @@
import * as plugins from '../plugins.js';
import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.js';
import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { ICertificateData } from '../common/types.js';
import type { IConnectionRecord, IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
/**
@ -18,9 +21,11 @@ export class NetworkProxyBridge {
public setPort80Handler(handler: Port80Handler): void {
this.port80Handler = handler;
// Register for certificate events
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateEvent.bind(this));
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateEvent.bind(this));
// Subscribe to certificate events
subscribeToPort80Handler(handler, {
onCertificateIssued: this.handleCertificateEvent.bind(this),
onCertificateRenewed: this.handleCertificateEvent.bind(this)
});
// If NetworkProxy is already initialized, connect it with Port80Handler
if (this.networkProxy) {
@ -43,10 +48,6 @@ export class NetworkProxyBridge {
useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available
};
// Copy ACME settings for backward compatibility (if port80HandlerConfig not set)
if (!this.settings.port80HandlerConfig && this.settings.acme) {
networkProxyOptions.acme = { ...this.settings.acme };
}
this.networkProxy = new NetworkProxy(networkProxyOptions);
@ -288,7 +289,7 @@ export class NetworkProxyBridge {
);
// Log ACME-eligible domains
const acmeEnabled = this.settings.port80HandlerConfig?.enabled || this.settings.acme?.enabled;
const acmeEnabled = !!this.settings.acme?.enabled;
if (acmeEnabled) {
const acmeEligibleDomains = proxyConfigs
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
@ -349,7 +350,7 @@ export class NetworkProxyBridge {
return false;
}
if (!this.settings.port80HandlerConfig?.enabled && !this.settings.acme?.enabled) {
if (!this.settings.acme?.enabled) {
console.log('Cannot request certificate - ACME is not enabled');
return false;
}

View File

@ -117,10 +117,6 @@ export class PortRangeManager {
}
}
// Add ACME HTTP challenge port if enabled
if (this.settings.acme?.enabled && this.settings.acme.port) {
ports.add(this.settings.acme.port);
}
// Add global port ranges
if (this.settings.globalPortRanges) {
@ -202,12 +198,6 @@ export class PortRangeManager {
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
}
// Check ACME port
if (this.settings.acme?.enabled && this.settings.acme.port) {
if (portMappings.has(this.settings.acme.port)) {
warnings.push(`ACME HTTP challenge port ${this.settings.acme.port} is also used in port ranges`);
}
}
return warnings;
}

View File

@ -1,4 +1,4 @@
import * as net from 'net';
import * as plugins from '../plugins.js';
/**
* TlsAlert class for managing TLS alert messages
@ -99,7 +99,7 @@ export class TlsAlert {
* @returns Promise that resolves when the alert has been sent
*/
static async send(
socket: net.Socket,
socket: plugins.net.Socket,
level: number,
description: number,
closeAfterSend: boolean = false,
@ -183,7 +183,7 @@ export class TlsAlert {
* @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> {
static async sendSniRequired(socket: plugins.net.Socket): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.UNRECOGNIZED_NAME);
}
@ -194,7 +194,7 @@ export class TlsAlert {
* @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> {
static async sendCloseNotify(socket: plugins.net.Socket, closeDelay: number = 200): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.CLOSE_NOTIFY, true, closeDelay);
}
@ -208,7 +208,7 @@ export class TlsAlert {
* @returns Promise that resolves when the alert has been sent
*/
static async sendCertificateExpired(
socket: net.Socket,
socket: plugins.net.Socket,
fatal: boolean = false,
closeAfterSend: boolean = true,
closeDelay: number = 200
@ -224,7 +224,7 @@ export class TlsAlert {
* @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> {
static async sendForceSniSequence(socket: plugins.net.Socket): Promise<void> {
try {
// Send unrecognized_name (warning)
socket.cork();
@ -249,7 +249,7 @@ export class TlsAlert {
* @returns Promise that resolves when the alert has been sent and the connection closed
*/
static async sendFatalAndClose(
socket: net.Socket,
socket: plugins.net.Socket,
description: number,
closeDelay: number = 100
): Promise<void> {

View File

@ -8,9 +8,10 @@ import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js';
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js';
import * as path from 'path';
import * as fs from 'fs';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { CertProvisioner } from './classes.pp.certprovisioner.js';
import type { ICertificateData } from '../common/types.js';
import { buildPort80Handler } from '../common/acmeFactory.js';
/**
* SmartProxy - Main class that coordinates all components
@ -32,6 +33,8 @@ export class SmartProxy extends plugins.EventEmitter {
// Port80Handler for ACME certificate management
private port80Handler: Port80Handler | null = null;
// CertProvisioner for unified certificate workflows
private certProvisioner?: CertProvisioner;
constructor(settingsArg: IPortProxySettings) {
super();
@ -63,41 +66,25 @@ export class SmartProxy extends plugins.EventEmitter {
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
networkProxyPort: settingsArg.networkProxyPort || 8443,
port80HandlerConfig: settingsArg.port80HandlerConfig || {},
acme: settingsArg.acme || {},
globalPortRanges: settingsArg.globalPortRanges || [],
};
// Set port80HandlerConfig defaults, using legacy acme config if available
if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) {
if (this.settings.acme) {
// Migrate from legacy acme config
this.settings.port80HandlerConfig = {
enabled: this.settings.acme.enabled,
port: this.settings.acme.port || 80,
contactEmail: this.settings.acme.contactEmail || 'admin@example.com',
useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false, // Default to true
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
httpsRedirectPort: this.settings.fromPort,
renewCheckIntervalHours: 24
};
} else {
// Set defaults if no config provided
this.settings.port80HandlerConfig = {
enabled: false,
port: 80,
contactEmail: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
httpsRedirectPort: this.settings.fromPort,
renewCheckIntervalHours: 24
};
}
// Set default ACME options if not provided
if (!this.settings.acme || Object.keys(this.settings.acme).length === 0) {
this.settings.acme = {
enabled: false,
port: 80,
contactEmail: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
httpsRedirectPort: this.settings.fromPort,
renewCheckIntervalHours: 24,
domainForwards: []
};
}
// Initialize component managers
@ -135,127 +122,20 @@ export class SmartProxy extends plugins.EventEmitter {
* Initialize the Port80Handler for ACME certificate management
*/
private async initializePort80Handler(): Promise<void> {
const config = this.settings.port80HandlerConfig;
if (!config || !config.enabled) {
console.log('Port80Handler is disabled in configuration');
const config = this.settings.acme!;
if (!config.enabled) {
console.log('ACME is disabled in configuration');
return;
}
try {
// Ensure the certificate store directory exists
if (config.certificateStore) {
const certStorePath = path.resolve(config.certificateStore);
if (!fs.existsSync(certStorePath)) {
fs.mkdirSync(certStorePath, { recursive: true });
console.log(`Created certificate store directory: ${certStorePath}`);
}
}
// Create Port80Handler with options from config
this.port80Handler = new Port80Handler({
port: config.port,
contactEmail: config.contactEmail,
useProduction: config.useProduction,
renewThresholdDays: config.renewThresholdDays,
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort,
renewCheckIntervalHours: config.renewCheckIntervalHours,
enabled: config.enabled,
autoRenew: config.autoRenew,
certificateStore: config.certificateStore,
skipConfiguredCerts: config.skipConfiguredCerts
// Build and start the Port80Handler
this.port80Handler = buildPort80Handler({
...config,
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort
});
// Register domain forwarding configurations
if (config.domainForwards) {
for (const forward of config.domainForwards) {
this.port80Handler.addDomain({
domainName: forward.domain,
sslRedirect: true,
acmeMaintenance: true,
forward: forward.forwardConfig,
acmeForward: forward.acmeForwardConfig
});
console.log(`Registered domain forwarding for ${forward.domain}`);
}
}
// Provision certificates per domain via certProvider or HTTP-01
for (const domainConfig of this.settings.domainConfigs) {
for (const domain of domainConfig.domains) {
// Skip wildcard domains
if (domain.includes('*')) continue;
// Determine provisioning method
let provision = 'http01' as string | plugins.tsclass.network.ICert;
if (this.settings.certProvider) {
try {
provision = await this.settings.certProvider(domain);
} catch (err) {
console.log(`certProvider error for ${domain}: ${err}`);
}
}
if (provision === 'http01') {
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`);
} else {
// Static certificate provided
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
console.log(`Applied static certificate for ${domain} from certProvider`);
}
}
}
// Set up event listeners
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => {
console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
// Re-emit on SmartProxy
this.emit('certificate', {
domain: certData.domain,
publicKey: certData.certificate,
privateKey: certData.privateKey,
expiryDate: certData.expiryDate,
source: 'http01',
isRenewal: false
});
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => {
console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
// Re-emit on SmartProxy
this.emit('certificate', {
domain: certData.domain,
publicKey: certData.certificate,
privateKey: certData.privateKey,
expiryDate: certData.expiryDate,
source: 'http01',
isRenewal: true
});
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (failureData) => {
console.log(`Certificate ${failureData.isRenewal ? 'renewal' : 'issuance'} failed for ${failureData.domain}: ${failureData.error}`);
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (expiryData) => {
console.log(`Certificate for ${expiryData.domain} is expiring in ${expiryData.daysRemaining} days`);
});
// Share Port80Handler with NetworkProxyBridge
// Share Port80Handler with NetworkProxyBridge before start
this.networkProxyBridge.setPort80Handler(this.port80Handler);
// Start Port80Handler
await this.port80Handler.start();
console.log(`Port80Handler started on port ${config.port}`);
} catch (err) {
@ -275,6 +155,37 @@ export class SmartProxy extends plugins.EventEmitter {
// Initialize Port80Handler if enabled
await this.initializePort80Handler();
// Initialize CertProvisioner for unified certificate workflows
if (this.port80Handler) {
const acme = this.settings.acme!;
this.certProvisioner = new CertProvisioner(
this.settings.domainConfigs,
this.port80Handler,
this.networkProxyBridge,
this.settings.certProvider,
acme.renewThresholdDays!,
acme.renewCheckIntervalHours!,
acme.autoRenew!,
acme.domainForwards?.map(f => ({
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || false
})) || []
);
this.certProvisioner.on('certificate', (certData) => {
this.emit('certificate', {
domain: certData.domain,
publicKey: certData.certificate,
privateKey: certData.privateKey,
expiryDate: certData.expiryDate,
source: certData.source,
isRenewal: certData.isRenewal
});
});
await this.certProvisioner.start();
console.log('CertProvisioner started');
}
// Initialize and start NetworkProxy if needed
if (
@ -403,6 +314,11 @@ export class SmartProxy extends plugins.EventEmitter {
public async stop() {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop CertProvisioner if active
if (this.certProvisioner) {
await this.certProvisioner.stop();
console.log('CertProvisioner stopped');
}
// Stop the Port80Handler if running
if (this.port80Handler) {
@ -469,7 +385,7 @@ export class SmartProxy extends plugins.EventEmitter {
}
// If Port80Handler is running, provision certificates per new domain
if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) {
if (this.port80Handler && this.settings.acme?.enabled) {
for (const domainConfig of newDomainConfigs) {
for (const domain of domainConfig.domains) {
if (domain.includes('*')) continue;
@ -505,72 +421,6 @@ export class SmartProxy extends plugins.EventEmitter {
}
}
/**
* Updates the Port80Handler configuration
*/
public async updatePort80HandlerConfig(config: IPortProxySettings['port80HandlerConfig']): Promise<void> {
if (!config) return;
console.log('Updating Port80Handler configuration');
// Update the settings
this.settings.port80HandlerConfig = {
...this.settings.port80HandlerConfig,
...config
};
// Check if we need to restart Port80Handler
let needsRestart = false;
// Restart if enabled state changed
if (this.port80Handler && config.enabled === false) {
needsRestart = true;
} else if (!this.port80Handler && config.enabled === true) {
needsRestart = true;
} else if (this.port80Handler && (
config.port !== undefined ||
config.contactEmail !== undefined ||
config.useProduction !== undefined ||
config.renewThresholdDays !== undefined ||
config.renewCheckIntervalHours !== undefined
)) {
// Restart if critical settings changed
needsRestart = true;
}
if (needsRestart) {
// Stop if running
if (this.port80Handler) {
try {
await this.port80Handler.stop();
this.port80Handler = null;
console.log('Stopped Port80Handler for configuration update');
} catch (err) {
console.log(`Error stopping Port80Handler: ${err}`);
}
}
// Start with new config if enabled
if (this.settings.port80HandlerConfig.enabled) {
await this.initializePort80Handler();
console.log('Restarted Port80Handler with new configuration');
}
} else if (this.port80Handler) {
// Just update domain forwards if they changed
if (config.domainForwards) {
for (const forward of config.domainForwards) {
this.port80Handler.addDomain({
domainName: forward.domain,
sslRedirect: true,
acmeMaintenance: true,
forward: forward.forwardConfig,
acmeForward: forward.acmeForwardConfig
});
}
console.log('Updated domain forwards in Port80Handler');
}
}
}
/**
* Request a certificate for a specific domain
@ -664,7 +514,7 @@ export class SmartProxy extends plugins.EventEmitter {
networkProxyConnections,
terminationStats,
acmeEnabled: !!this.port80Handler,
port80HandlerPort: this.port80Handler ? this.settings.port80HandlerConfig?.port : null
port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null
};
}
@ -715,7 +565,7 @@ export class SmartProxy extends plugins.EventEmitter {
status: 'valid',
expiryDate: expiryDate.toISOString(),
daysRemaining,
renewalNeeded: daysRemaining <= this.settings.port80HandlerConfig.renewThresholdDays
renewalNeeded: daysRemaining <= (this.settings.acme?.renewThresholdDays ?? 0)
};
} else {
certificateStatus[domain] = {
@ -725,11 +575,12 @@ export class SmartProxy extends plugins.EventEmitter {
}
}
const acme = this.settings.acme!;
return {
enabled: true,
port: this.settings.port80HandlerConfig.port,
useProduction: this.settings.port80HandlerConfig.useProduction,
autoRenew: this.settings.port80HandlerConfig.autoRenew,
port: acme.port!,
useProduction: acme.useProduction!,
autoRenew: acme.autoRenew!,
certificates: certificateStatus
};
}