Compare commits

...

15 Commits

Author SHA1 Message Date
a59ebd6202 7.2.0
Some checks failed
Default (tags) / security (push) Successful in 46s
Default (tags) / test (push) Failing after 1m12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-01 12:13:18 +00:00
0d8740d812 feat(ACME/Certificate): Introduce certificate provider hook and observable certificate events; remove legacy ACME flow 2025-05-01 12:13:18 +00:00
e6a138279d before refactor 2025-05-01 11:48:04 +00:00
a30571dae2 7.1.4
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 1m11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-30 13:39:42 +00:00
24d6d6982d fix(dependencies): Update dependency versions in package.json 2025-04-30 13:39:42 +00:00
cfa19f27cc 7.1.3
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 1m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-28 15:37:35 +00:00
03cc490b8a fix(docs): Update project hints documentation in readme.hints.md 2025-04-28 15:37:35 +00:00
2616b24d61 7.1.2
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 1m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-19 18:42:36 +00:00
46214f5380 fix(networkproxy/requesthandler): Improve HTTP/2 request handling and error management in the proxy request handler; add try-catch around routing and update header processing to support per-backend protocol overrides. 2025-04-19 18:42:36 +00:00
d8383311be 7.1.1
Some checks failed
Default (tags) / security (push) Successful in 23s
Default (tags) / test (push) Failing after 1m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-19 18:32:46 +00:00
578d11344f fix(commit-info): Update commit metadata and synchronize project configuration (no code changes) 2025-04-19 18:32:46 +00:00
ce3d0feb77 7.1.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 1m8s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-19 18:31:31 +00:00
04abab505b feat(core): Add backendProtocol option to support HTTP/2 client sessions alongside HTTP/1. This update enhances NetworkProxy's core functionality by integrating HTTP/2 support in server creation and request handling, while updating plugin exports and documentation accordingly. 2025-04-19 18:31:10 +00:00
e69c55de3b 7.0.1
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 1m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-05 08:54:35 +00:00
9a9bcd2df0 fix(package.json): Update packageManager field in package.json to specify the pnpm version for improved reproducibility. 2025-04-05 08:54:34 +00:00
15 changed files with 2156 additions and 579 deletions

View File

@ -1,5 +1,63 @@
# Changelog # Changelog
## 2025-05-01 - 7.2.0 - feat(ACME/Certificate)
Introduce certificate provider hook and observable certificate events; remove legacy ACME flow
- Extended IPortProxySettings with a new certProvider callback that allows returning a static certificate or 'http01' for ACME challenges.
- Updated Port80Handler to leverage SmartAcme's getCertificateForDomain and removed outdated methods such as getAcmeClient and processAuthorizations.
- Enhanced SmartProxy to extend EventEmitter, invoking certProvider on non-wildcard domains and re-emitting certificate events (with domain, publicKey, privateKey, expiryDate, source, and isRenewal flag).
- Updated NetworkProxyBridge to support applying external certificates via a new applyExternalCertificate method.
- Revised documentation (readme.md and readme.plan.md) to include usage examples for the new certificate provider hook.
## 2025-04-30 - 7.1.4 - fix(dependencies)
Update dependency versions in package.json
- Bump @git.zone/tsbuild from ^2.2.6 to ^2.3.2
- Bump @push.rocks/tapbundle from ^5.5.10 to ^6.0.0
- Bump @types/node from ^22.13.10 to ^22.15.3
- Bump typescript from ^5.8.2 to ^5.8.3
- Bump @push.rocks/lik from ^6.1.0 to ^6.2.2
- Add @push.rocks/smartnetwork at ^4.0.0
- Bump @push.rocks/smartrequest from ^2.0.23 to ^2.1.0
- Bump @tsclass/tsclass from ^5.0.0 to ^9.1.0
- Bump @types/ws from ^8.18.0 to ^8.18.1
- Update ws to ^8.18.1
## 2025-04-28 - 7.1.3 - fix(docs)
Update project hints documentation in readme.hints.md
- Added comprehensive hints covering project overview, repository structure, and development setup.
- Outlined testing framework, coding conventions, and key components including ProxyRouter and SmartProxy.
- Included detailed information on TSConfig settings, Mermaid diagrams, CLI usage, and future TODOs.
## 2025-04-19 - 7.1.2 - fix(networkproxy/requesthandler)
Improve HTTP/2 request handling and error management in the proxy request handler; add try-catch around routing and update header processing to support per-backend protocol overrides.
- Wrapped the routing call (router.routeReq) in a try-catch block to better handle errors and missing host headers.
- Returns a 500 error and increments failure metrics if routing fails.
- Refactored HTTP/2 branch to copy all headers appropriately and map response headers into HTTP/1 response.
- Added support for per-backend protocol override via the new backendProtocol option in IReverseProxyConfig.
## 2025-04-19 - 7.1.1 - fix(commit-info)
Update commit metadata and synchronize project configuration (no code changes)
- Verified that all files remain unchanged
- Commit reflects a metadata or build system sync without functional modifications
## 2025-04-19 - 7.1.0 - feat(core)
Add backendProtocol option to support HTTP/2 client sessions alongside HTTP/1. This update enhances NetworkProxy's core functionality by integrating HTTP/2 support in server creation and request handling, while updating plugin exports and documentation accordingly.
- Introduced 'backendProtocol' configuration option (http1 | http2) with default 'http1'.
- Updated creation of secure server to use http2.createSecureServer with HTTP/1 fallback.
- Enhanced request handling to establish HTTP/2 client sessions when backendProtocol is set to 'http2'.
- Exported http2 module in plugins.
- Updated readme.md to document backendProtocol usage with example code.
## 2025-04-05 - 7.0.1 - fix(package.json)
Update packageManager field in package.json to specify the pnpm version for improved reproducibility.
- Added the packageManager field to clearly specify the pnpm version and its checksum.
## 2025-04-04 - 7.0.0 - BREAKING CHANGE(redirect) ## 2025-04-04 - 7.0.0 - BREAKING CHANGE(redirect)
Remove deprecated SSL redirect implementation and update exports to use the new redirect module Remove deprecated SSL redirect implementation and update exports to use the new redirect module

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "7.0.0", "version": "7.2.0",
"private": false, "private": false,
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.", "description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@ -15,23 +15,24 @@
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.2.6", "@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^1.0.77", "@git.zone/tstest": "^1.0.77",
"@push.rocks/tapbundle": "^5.5.10", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.13.10", "@types/node": "^22.15.3",
"typescript": "^5.8.2" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.1.0", "@push.rocks/lik": "^6.2.2",
"@push.rocks/smartacme": "^7.2.3",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartnetwork": "^4.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.0.23", "@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@tsclass/tsclass": "^5.0.0", "@tsclass/tsclass": "^9.1.0",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/ws": "^8.18.0", "@types/ws": "^8.18.1",
"acme-client": "^5.4.0",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"pretty-ms": "^9.2.0", "pretty-ms": "^9.2.0",
"ws": "^8.18.1" "ws": "^8.18.1"
@ -83,5 +84,6 @@
"mongodb-memory-server", "mongodb-memory-server",
"puppeteer" "puppeteer"
] ]
} },
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
} }

1852
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +1,64 @@
# SmartProxy Project Hints
## Project Overview
- Package: `@push.rocks/smartproxy` high-performance proxy supporting HTTP(S), TCP, WebSocket, and ACME integration.
- Written in TypeScript, compiled output in `dist_ts/`, uses ESM with NodeNext resolution.
## Repository Structure
- `ts/` TypeScript source files:
- `index.ts` exports main modules.
- `plugins.ts` centralizes native and third-party imports.
- Subdirectories: `networkproxy/`, `nftablesproxy/`, `port80handler/`, `redirect/`, `smartproxy/`.
- Key classes: `ProxyRouter` (`classes.router.ts`), `SmartProxy` (`classes.smartproxy.ts`), plus handlers/managers.
- `dist_ts/` transpiled `.js` and `.d.ts` files mirroring `ts/` structure.
- `test/` test suites in TypeScript:
- `test.router.ts` routing logic (hostname matching, wildcards, path parameters, config management).
- `test.smartproxy.ts` proxy behavior tests (TCP forwarding, SNI handling, concurrency, chaining, timeouts).
- `test/helpers/` utilities (e.g., certificates).
- `assets/certs/` placeholder certificates for ACME and TLS.
## Development Setup
- Requires `pnpm` (v10+).
- Install dependencies: `pnpm install`.
- Build: `pnpm build` (runs `tsbuild --web --allowimplicitany`).
- Test: `pnpm test` (runs `tstest test/`).
- Format: `pnpm format` (runs `gitzone format`).
## Testing Framework
- Uses `@push.rocks/tapbundle` (`tap`, `expect`, `expactAsync`).
- Test files: must start with `test.` and use `.ts` extension.
- Run specific tests via `tsx`, e.g., `tsx test/test.router.ts`.
## Coding Conventions
- Import modules via `plugins.ts`:
```ts
import * as plugins from './plugins.ts';
const server = new plugins.http.Server();
```
- Reference plugins with full path: `plugins.acme`, `plugins.smartdelay`, `plugins.minimatch`, etc.
- Path patterns support globs (`*`) and parameters (`:param`) in `ProxyRouter`.
- Wildcard hostname matching leverages `minimatch` patterns.
## Key Components
- **ProxyRouter**
- Methods: `routeReq`, `routeReqWithDetails`.
- Hostname matching: case-insensitive, strips port, supports exact, wildcard, TLD, complex patterns.
- Path routing: exact, wildcard, parameter extraction (`pathParams`), returns `pathMatch` and `pathRemainder`.
- Config API: `setNewProxyConfigs`, `addProxyConfig`, `removeProxyConfig`, `getHostnames`, `getProxyConfigs`.
- **SmartProxy**
- Manages one or more `net.Server` instances to forward TCP streams.
- Options: `preserveSourceIP`, `defaultAllowedIPs`, `globalPortRanges`, `sniEnabled`.
- DomainConfigManager: round-robin selection for multiple target IPs.
- Graceful shutdown in `stop()`, ensures no lingering servers or sockets.
## Notable Points
- **TSConfig**: `module: NodeNext`, `verbatimModuleSyntax`, allows `.js` extension imports in TS.
- Mermaid diagrams and architecture flows in `readme.md` illustrate component interactions and protocol flows.
- CLI entrypoint (`cli.js`) supports command-line usage (ACME, proxy controls).
- ACME and certificate handling via `Port80Handler` and `helpers.certificates.ts`.
## TODOs / Considerations
- Ensure import extensions in source match build outputs (`.ts` vs `.js`).
- Update `plugins.ts` when adding new dependencies.
- Maintain test coverage for new routing or proxy features.
- Keep `ts/` and `dist_ts/` in sync after refactors.

View File

@ -197,7 +197,71 @@ sequenceDiagram
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS - **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol - **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
- **IP Filtering** - Control access with IP allow/block lists using glob patterns - **IP Filtering** - Control access with IP allow/block lists using glob patterns
- **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding - **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding
## Certificate Provider Hook & Events
You can customize how certificates are provisioned per domain by using the `certProvider` callback and listen for certificate events emitted by `SmartProxy`.
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
import * as fs from 'fs';
// 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';
};
const proxy = new SmartProxy({
fromPort: 80,
toPort: 8080,
domainConfigs: [{
domains: ['static.example.com', 'dynamic.example.com'],
allowedIPs: ['*']
}],
certProvider
});
// Listen for certificate issuance or renewal
proxy.on('certificate', (evt) => {
console.log(`Certificate for ${evt.domain} ready, expires on ${evt.expiryDate}`);
});
await proxy.start();
```
## Configuration Options
### backendProtocol
Type: 'http1' | 'http2' (default: 'http1')
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).
Example:
```js
import { NetworkProxy } from '@push.rocks/smartproxy';
const proxy = new NetworkProxy({
port: 8443,
backendProtocol: 'http2',
// other options...
});
proxy.start();
```
- **Basic Authentication** - Support for basic auth on proxied routes - **Basic Authentication** - Support for basic auth on proxied routes
- **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts - **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues - **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues

31
readme.plan.md Normal file
View File

@ -0,0 +1,31 @@
## Plan: Integrate @push.rocks/smartacme into Port80Handler
- [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
- [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.

View File

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

View File

@ -12,12 +12,16 @@ import { Port80Handler } from '../port80handler/classes.port80handler.js';
* automatic certificate management, and high-performance connection pooling. * automatic certificate management, and high-performance connection pooling.
*/ */
export class NetworkProxy implements IMetricsTracker { export class NetworkProxy implements IMetricsTracker {
// Provide a minimal JSON representation to avoid circular references during deep equality checks
public toJSON(): any {
return {};
}
// Configuration // Configuration
public options: INetworkProxyOptions; public options: INetworkProxyOptions;
public proxyConfigs: IReverseProxyConfig[] = []; public proxyConfigs: IReverseProxyConfig[] = [];
// Server instances // Server instances (HTTP/2 with HTTP/1 fallback)
public httpsServer: plugins.https.Server; public httpsServer: any;
// Core components // Core components
private certificateManager: CertificateManager; private certificateManager: CertificateManager;
@ -66,6 +70,8 @@ export class NetworkProxy implements IMetricsTracker {
connectionPoolSize: optionsArg.connectionPoolSize || 50, connectionPoolSize: optionsArg.connectionPoolSize || 50,
portProxyIntegration: optionsArg.portProxyIntegration || false, portProxyIntegration: optionsArg.portProxyIntegration || false,
useExternalPort80Handler: optionsArg.useExternalPort80Handler || false, useExternalPort80Handler: optionsArg.useExternalPort80Handler || false,
// Backend protocol (http1 or http2)
backendProtocol: optionsArg.backendProtocol || 'http1',
// Default ACME options // Default ACME options
acme: { acme: {
enabled: optionsArg.acme?.enabled || false, enabled: optionsArg.acme?.enabled || false,
@ -185,33 +191,35 @@ export class NetworkProxy implements IMetricsTracker {
await this.certificateManager.initializePort80Handler(); await this.certificateManager.initializePort80Handler();
} }
// Create the HTTPS server // Create HTTP/2 server with HTTP/1 fallback
this.httpsServer = plugins.https.createServer( this.httpsServer = plugins.http2.createSecureServer(
{ {
key: this.certificateManager.getDefaultCertificates().key, key: this.certificateManager.getDefaultCertificates().key,
cert: this.certificateManager.getDefaultCertificates().cert, cert: this.certificateManager.getDefaultCertificates().cert,
SNICallback: (domain, cb) => this.certificateManager.handleSNI(domain, cb) allowHTTP1: true,
}, ALPNProtocols: ['h2', 'http/1.1']
(req, res) => this.requestHandler.handleRequest(req, res) }
); );
// Configure server timeouts // Track raw TCP connections for metrics and limits
this.httpsServer.keepAliveTimeout = this.options.keepAliveTimeout;
this.httpsServer.headersTimeout = this.options.headersTimeout;
// Setup connection tracking
this.setupConnectionTracking(); this.setupConnectionTracking();
// Share HTTPS server with certificate manager // Handle incoming HTTP/2 streams
this.httpsServer.on('stream', (stream: any, headers: any) => {
this.requestHandler.handleHttp2(stream, headers);
});
// Handle HTTP/1.x fallback requests
this.httpsServer.on('request', (req: any, res: any) => {
this.requestHandler.handleRequest(req, res);
});
// Share server with certificate manager for dynamic contexts
this.certificateManager.setHttpsServer(this.httpsServer); this.certificateManager.setHttpsServer(this.httpsServer);
// Setup WebSocket support on HTTP/1 fallback
// Setup WebSocket support
this.webSocketHandler.initialize(this.httpsServer); this.webSocketHandler.initialize(this.httpsServer);
// Start metrics logging
// Start metrics collection
this.setupMetricsCollection(); this.setupMetricsCollection();
// Start periodic connection pool cleanup
// Setup connection pool cleanup interval
this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup(); this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup();
// Start the server // Start the server

View File

@ -18,6 +18,8 @@ export class RequestHandler {
private defaultHeaders: { [key: string]: string } = {}; private defaultHeaders: { [key: string]: string } = {};
private logger: ILogger; private logger: ILogger;
private metricsTracker: IMetricsTracker | null = null; private metricsTracker: IMetricsTracker | null = null;
// HTTP/2 client sessions for backend proxying
private h2Sessions: Map<string, plugins.http2.ClientHttp2Session> = new Map();
constructor( constructor(
private options: INetworkProxyOptions, private options: INetworkProxyOptions,
@ -131,6 +133,69 @@ export class RequestHandler {
// Apply default headers // Apply default headers
this.applyDefaultHeaders(res); this.applyDefaultHeaders(res);
// Determine routing configuration
let proxyConfig: IReverseProxyConfig | undefined;
try {
proxyConfig = this.router.routeReq(req);
} catch (err) {
this.logger.error('Error routing request', err);
res.statusCode = 500;
res.end('Internal Server Error');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
if (!proxyConfig) {
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
res.statusCode = 404;
res.end('Not Found: No proxy configuration for this host');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
// Determine protocol to backend (per-domain override or global)
const backendProto = proxyConfig.backendProtocol || this.options.backendProtocol;
if (backendProto === 'http2') {
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
const key = `${destination.host}:${destination.port}`;
let session = this.h2Sessions.get(key);
if (!session || session.closed || (session as any).destroyed) {
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
this.h2Sessions.set(key, session);
session.on('error', () => this.h2Sessions.delete(key));
session.on('close', () => this.h2Sessions.delete(key));
}
// Build headers for HTTP/2 request
const hdrs: Record<string, any> = {
':method': req.method,
':path': req.url,
':authority': `${destination.host}:${destination.port}`
};
for (const [hk, hv] of Object.entries(req.headers)) {
if (typeof hv === 'string') hdrs[hk] = hv;
}
const h2Stream = session.request(hdrs);
req.pipe(h2Stream);
h2Stream.on('response', (hdrs2: any) => {
const status = (hdrs2[':status'] as number) || 502;
res.statusCode = status;
// Copy headers from HTTP/2 response to HTTP/1 response
for (const [hk, hv] of Object.entries(hdrs2)) {
if (!hk.startsWith(':') && hv != null) {
res.setHeader(hk, hv as string | string[]);
}
}
h2Stream.pipe(res);
});
h2Stream.on('error', (err) => {
res.statusCode = 502;
res.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
return;
}
try { try {
// Find target based on hostname // Find target based on hostname
const proxyConfig = this.router.routeReq(req); const proxyConfig = this.router.routeReq(req);
@ -275,4 +340,119 @@ export class RequestHandler {
} }
} }
} }
/**
* Handle HTTP/2 stream requests by proxying to HTTP/1 backends
*/
public async handleHttp2(stream: any, headers: any): Promise<void> {
const startTime = Date.now();
const method = headers[':method'] || 'GET';
const path = headers[':path'] || '/';
// If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions
if (this.options.backendProtocol === 'http2') {
const authority = headers[':authority'] as string || '';
const host = authority.split(':')[0];
const fakeReq: any = { headers: { host }, method: headers[':method'], url: headers[':path'], socket: (stream.session as any).socket };
const proxyConfig = this.router.routeReq(fakeReq);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
const destination = this.connectionPool.getNextTarget(proxyConfig.destinationIps, proxyConfig.destinationPorts[0]);
const key = `${destination.host}:${destination.port}`;
let session = this.h2Sessions.get(key);
if (!session || session.closed || (session as any).destroyed) {
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
this.h2Sessions.set(key, session);
session.on('error', () => this.h2Sessions.delete(key));
session.on('close', () => this.h2Sessions.delete(key));
}
// Build headers for backend HTTP/2 request
const h2Headers: Record<string, any> = {
':method': headers[':method'],
':path': headers[':path'],
':authority': `${destination.host}:${destination.port}`
};
for (const [k, v] of Object.entries(headers)) {
if (!k.startsWith(':') && typeof v === 'string') {
h2Headers[k] = v;
}
}
const h2Stream2 = session.request(h2Headers);
stream.pipe(h2Stream2);
h2Stream2.on('response', (hdrs: any) => {
// Map status and headers to client
const resp: Record<string, any> = { ':status': hdrs[':status'] as number };
for (const [hk, hv] of Object.entries(hdrs)) {
if (!hk.startsWith(':') && hv) resp[hk] = hv;
}
stream.respond(resp);
h2Stream2.pipe(stream);
});
h2Stream2.on('error', (err) => {
stream.respond({ ':status': 502 });
stream.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
return;
}
try {
// Determine host for routing
const authority = headers[':authority'] as string || '';
const host = authority.split(':')[0];
// Fake request object for routing
const fakeReq: any = { headers: { host }, method, url: path, socket: (stream.session as any).socket };
const proxyConfig = this.router.routeReq(fakeReq as any);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
// Select backend target
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
// Build headers for HTTP/1 proxy
const outboundHeaders: Record<string,string> = {};
for (const [key, value] of Object.entries(headers)) {
if (typeof key === 'string' && typeof value === 'string' && !key.startsWith(':')) {
outboundHeaders[key] = value;
}
}
if (outboundHeaders.host && (proxyConfig as any).rewriteHostHeader) {
outboundHeaders.host = `${destination.host}:${destination.port}`;
}
// Create HTTP/1 proxy request
const proxyReq = plugins.http.request(
{ hostname: destination.host, port: destination.port, path, method, headers: outboundHeaders },
(proxyRes) => {
// Map status and headers back to HTTP/2
const responseHeaders: Record<string, number|string|string[]> = {};
for (const [k, v] of Object.entries(proxyRes.headers)) {
if (v !== undefined) responseHeaders[k] = v;
}
stream.respond({ ':status': proxyRes.statusCode || 500, ...responseHeaders });
proxyRes.pipe(stream);
stream.on('close', () => proxyReq.destroy());
stream.on('error', () => proxyReq.destroy());
if (this.metricsTracker) stream.on('end', () => this.metricsTracker.incrementRequestsServed());
}
);
proxyReq.on('error', (err) => {
stream.respond({ ':status': 502 });
stream.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
// Pipe client stream to backend
stream.pipe(proxyReq);
} catch (err: any) {
stream.respond({ ':status': 500 });
stream.end('Internal Server Error');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
}
}
} }

View File

@ -20,6 +20,8 @@ export interface INetworkProxyOptions {
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
useExternalPort80Handler?: boolean; // Flag to indicate using external Port80Handler useExternalPort80Handler?: boolean; // Flag to indicate using external Port80Handler
// Protocol to use when proxying to backends: HTTP/1.x or HTTP/2
backendProtocol?: 'http1' | 'http2';
// ACME certificate management options // ACME certificate management options
acme?: { acme?: {
@ -58,6 +60,11 @@ export interface IReverseProxyConfig {
pass: string; pass: string;
}; };
rewriteHostHeader?: boolean; rewriteHostHeader?: boolean;
/**
* Protocol to use when proxying to this backend: 'http1' or 'http2'.
* Overrides the global backendProtocol option if set.
*/
backendProtocol?: 'http1' | 'http2';
} }
/** /**

View File

@ -5,9 +5,10 @@ import * as https from 'https';
import * as net from 'net'; import * as net from 'net';
import * as tls from 'tls'; import * as tls from 'tls';
import * as url from 'url'; import * as url from 'url';
import * as http2 from 'http2';
export { EventEmitter, http, https, net, tls, url }; export { EventEmitter, http, https, net, tls, url, http2 };
// tsclass scope // tsclass scope
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
@ -21,13 +22,15 @@ import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrequest from '@push.rocks/smartrequest';
import * as smartstring from '@push.rocks/smartstring'; import * as smartstring from '@push.rocks/smartstring';
export { lik, smartdelay, smartrequest, smartpromise, 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 };
// third party scope // third party scope
import * as acme from 'acme-client';
import prettyMs from 'pretty-ms'; import prettyMs from 'pretty-ms';
import * as ws from 'ws'; import * as ws from 'ws';
import wsDefault from 'ws'; import wsDefault from 'ws';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
export { acme, prettyMs, ws, wsDefault, minimatch }; export { prettyMs, ws, wsDefault, minimatch };

View File

@ -2,6 +2,21 @@ import * as plugins from '../plugins.js';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
class DisklessHttp01Handler {
private storage: Map<string, string>;
constructor(storage: Map<string, string>) { this.storage = storage; }
public getSupportedTypes(): string[] { return ['http-01']; }
public async prepare(ch: any): Promise<void> {
this.storage.set(ch.token, ch.keyAuthorization);
}
public async verify(ch: any): Promise<void> {
return;
}
public async cleanup(ch: any): Promise<void> {
this.storage.delete(ch.token);
}
}
/** /**
* Custom error classes for better error handling * Custom error classes for better error handling
@ -59,8 +74,6 @@ interface IDomainCertificate {
obtainingInProgress: boolean; obtainingInProgress: boolean;
certificate?: string; certificate?: string;
privateKey?: string; privateKey?: string;
challengeToken?: string;
challengeKeyAuthorization?: string;
expiryDate?: Date; expiryDate?: Date;
lastRenewalAttempt?: Date; lastRenewalAttempt?: Date;
} }
@ -128,9 +141,11 @@ export interface ICertificateExpiring {
*/ */
export class Port80Handler extends plugins.EventEmitter { export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>; private domainCertificates: Map<string, IDomainCertificate>;
// In-memory storage for ACME HTTP-01 challenge tokens
private acmeHttp01Storage: Map<string, string> = new Map();
// SmartAcme instance for certificate management
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private server: plugins.http.Server | null = null; private server: plugins.http.Server | null = null;
private acmeClient: plugins.acme.Client | null = null;
private accountKey: string | null = null;
private renewalTimer: NodeJS.Timeout | null = null; private renewalTimer: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
private options: Required<IPort80HandlerOptions>; private options: Required<IPort80HandlerOptions>;
@ -175,6 +190,17 @@ export class Port80Handler extends plugins.EventEmitter {
console.log('Port80Handler is disabled, skipping start'); console.log('Port80Handler is disabled, skipping start');
return; return;
} }
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
if (this.options.enabled) {
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.options.contactEmail,
certManager: new plugins.smartacme.MemoryCertManager(),
environment: this.options.useProduction ? 'production' : 'integration',
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
challengePriority: ['http-01'],
});
await this.smartAcme.start();
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
@ -579,38 +605,6 @@ export class Port80Handler extends plugins.EventEmitter {
} }
} }
/**
* Lazy initialization of the ACME client
* @returns An ACME client instance
*/
private async getAcmeClient(): Promise<plugins.acme.Client> {
if (this.acmeClient) {
return this.acmeClient;
}
try {
// Generate a new account key
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
this.acmeClient = new plugins.acme.Client({
directoryUrl: this.options.useProduction
? plugins.acme.directory.letsencrypt.production
: plugins.acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.contactEmail}`],
});
return this.acmeClient;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
}
}
/** /**
* Handles incoming HTTP requests * Handles incoming HTTP requests
@ -640,19 +634,26 @@ export class Port80Handler extends plugins.EventEmitter {
const { domainInfo, pattern } = domainMatch; const { domainInfo, pattern } = domainMatch;
const options = domainInfo.options; const options = domainInfo.options;
// If the request is for an ACME HTTP-01 challenge, handle it // Serve or forward ACME HTTP-01 challenge requests
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) { if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) {
// Check if we should forward ACME requests // Forward ACME requests if configured
if (options.acmeForward) { if (options.acmeForward) {
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
return; return;
} }
// Serve challenge response from in-memory storage
// Only handle ACME challenges for non-glob patterns const token = req.url.split('/').pop() || '';
if (!this.isGlobPattern(pattern)) { const keyAuth = this.acmeHttp01Storage.get(token);
this.handleAcmeChallenge(req, res, domain); if (keyAuth) {
return; res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(keyAuth);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
} }
return;
} }
// Check if we should forward non-ACME requests // Check if we should forward non-ACME requests
@ -762,209 +763,73 @@ export class Port80Handler extends plugins.EventEmitter {
} }
} }
/**
* Serves the ACME HTTP-01 challenge response
* @param req The HTTP request
* @param res The HTTP response
* @param domain The domain for the challenge
*/
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
// The token is the last part of the URL
const urlParts = req.url?.split('/');
const token = urlParts ? urlParts[urlParts.length - 1] : '';
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(domainInfo.challengeKeyAuthorization);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
}
}
/** /**
* Obtains a certificate for a domain using ACME HTTP-01 challenge * Obtains a certificate for a domain using ACME HTTP-01 challenge
* @param domain The domain to obtain a certificate for * @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt * @param isRenewal Whether this is a renewal attempt
*/ */
/**
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
*/
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> { private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
// Don't allow certificate issuance for glob patterns
if (this.isGlobPattern(domain)) { if (this.isGlobPattern(domain)) {
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
} }
const domainInfo = this.domainCertificates.get(domain)!;
// Get the domain info
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
throw new CertificateError('Domain not found', domain, isRenewal);
}
// Verify that acmeMaintenance is enabled
if (!domainInfo.options.acmeMaintenance) { if (!domainInfo.options.acmeMaintenance) {
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
return; return;
} }
// Prevent concurrent certificate issuance
if (domainInfo.obtainingInProgress) { if (domainInfo.obtainingInProgress) {
console.log(`Certificate issuance already in progress for ${domain}`); console.log(`Certificate issuance already in progress for ${domain}`);
return; return;
} }
if (!this.smartAcme) {
throw new Port80HandlerError('SmartAcme is not initialized');
}
domainInfo.obtainingInProgress = true; domainInfo.obtainingInProgress = true;
domainInfo.lastRenewalAttempt = new Date(); domainInfo.lastRenewalAttempt = new Date();
try { try {
const client = await this.getAcmeClient(); // Request certificate via SmartAcme
const certObj = await this.smartAcme.getCertificateForDomain(domain);
// Create a new order for the domain const certificate = certObj.publicKey;
const order = await client.createOrder({ const privateKey = certObj.privateKey;
identifiers: [{ type: 'dns', value: domain }], const expiryDate = new Date(certObj.validUntil);
});
// Get the authorizations for the order
const authorizations = await client.getAuthorizations(order);
// Process each authorization
await this.processAuthorizations(client, domain, authorizations);
// Generate a CSR and private key
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
commonName: domain,
});
const csr = csrBuffer.toString();
const privateKey = privateKeyBuffer.toString();
// Finalize the order with our CSR
await client.finalizeOrder(order, csr);
// Get the certificate with the full chain
const certificate = await client.getCertificate(order);
// Store the certificate and key
domainInfo.certificate = certificate; domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey; domainInfo.privateKey = privateKey;
domainInfo.certObtained = true; domainInfo.certObtained = true;
domainInfo.expiryDate = expiryDate;
// Clear challenge data
delete domainInfo.challengeToken;
delete domainInfo.challengeKeyAuthorization;
// Extract expiry date from certificate
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
// Save the certificate to the store if enabled
if (this.options.certificateStore) { if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey); this.saveCertificateToStore(domain, certificate, privateKey);
} }
const eventType = isRenewal
// Emit the appropriate event ? Port80HandlerEvents.CERTIFICATE_RENEWED
const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED
: Port80HandlerEvents.CERTIFICATE_ISSUED; : Port80HandlerEvents.CERTIFICATE_ISSUED;
this.emitCertificateEvent(eventType, { this.emitCertificateEvent(eventType, {
domain, domain,
certificate, certificate,
privateKey, privateKey,
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() expiryDate: expiryDate || this.getDefaultExpiryDate()
}); });
} catch (error: any) { } catch (error: any) {
// Check for rate limit errors const errorMsg = error?.message || 'Unknown error';
if (error.message && ( console.error(`Error during certificate issuance for ${domain}:`, error);
error.message.includes('rateLimited') ||
error.message.includes('too many certificates') ||
error.message.includes('rate limit')
)) {
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
} else {
console.error(`Error during certificate issuance for ${domain}:`, error);
}
// Emit failure event
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain, domain,
error: error.message || 'Unknown error', error: errorMsg,
isRenewal isRenewal
} as ICertificateFailure); } as ICertificateFailure);
throw new CertificateError(errorMsg, domain, isRenewal);
throw new CertificateError(
error.message || 'Certificate issuance failed',
domain,
isRenewal
);
} finally { } finally {
// Reset flag whether successful or not
domainInfo.obtainingInProgress = false; domainInfo.obtainingInProgress = false;
} }
} }
/**
* Process ACME authorizations by verifying and completing challenges
* @param client ACME client
* @param domain Domain name
* @param authorizations Authorizations to process
*/
private async processAuthorizations(
client: plugins.acme.Client,
domain: string,
authorizations: plugins.acme.Authorization[]
): Promise<void> {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
throw new CertificateError('Domain not found during authorization', domain);
}
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
if (!challenge) {
throw new CertificateError('HTTP-01 challenge not found', domain);
}
// Get the key authorization for the challenge
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
// Store the challenge data
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// ACME client type definition workaround - use compatible approach
// First check if challenge verification is needed
const authzUrl = authz.url;
try {
// Check if authzUrl exists and perform verification
if (authzUrl) {
await client.verifyChallenge(authz, challenge);
}
// Complete the challenge
await client.completeChallenge(challenge);
// Wait for validation
await client.waitForValidStatus(challenge);
console.log(`HTTP-01 challenge completed for ${domain}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
console.error(`Challenge error for ${domain}:`, error);
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
}
}
}
/** /**
* Starts the certificate renewal timer * Starts the certificate renewal timer
*/ */

View File

@ -1,5 +1,10 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
/**
* Provision object for static or HTTP-01 certificate
*/
export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
/** Domain configuration with per-domain allowed port ranges */ /** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig { export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s) domains: string[]; // Glob patterns for domain(s)
@ -115,6 +120,11 @@ export interface IPortProxySettings {
certificateStore?: string; certificateStore?: string;
skipConfiguredCerts?: boolean; skipConfiguredCerts?: boolean;
}; };
/**
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
* or a static certificate object for immediate provisioning.
*/
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
} }
/** /**

View File

@ -95,6 +95,17 @@ export class NetworkProxyBridge {
} }
} }
/**
* Apply an external (static) certificate into NetworkProxy
*/
public applyExternalCertificate(data: ICertificateData): void {
if (!this.networkProxy) {
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
return;
}
this.handleCertificateEvent(data);
}
/** /**
* Get the NetworkProxy instance * Get the NetworkProxy instance
*/ */

View File

@ -8,14 +8,14 @@ import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js'; import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js'; import { PortRangeManager } from './classes.pp.portrangemanager.js';
import { ConnectionHandler } from './classes.pp.connectionhandler.js'; import { ConnectionHandler } from './classes.pp.connectionhandler.js';
import { Port80Handler, Port80HandlerEvents } from '../port80handler/classes.port80handler.js'; import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
/** /**
* SmartProxy - Main class that coordinates all components * SmartProxy - Main class that coordinates all components
*/ */
export class SmartProxy { export class SmartProxy extends plugins.EventEmitter {
private netServers: plugins.net.Server[] = []; private netServers: plugins.net.Server[] = [];
private connectionLogger: NodeJS.Timeout | null = null; private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
@ -34,6 +34,7 @@ export class SmartProxy {
private port80Handler: Port80Handler | null = null; private port80Handler: Port80Handler | null = null;
constructor(settingsArg: IPortProxySettings) { constructor(settingsArg: IPortProxySettings) {
super();
// Set reasonable defaults for all settings // Set reasonable defaults for all settings
this.settings = { this.settings = {
...settingsArg, ...settingsArg,
@ -180,29 +181,67 @@ export class SmartProxy {
} }
} }
// Register all non-wildcard domains from domain configs // Provision certificates per domain via certProvider or HTTP-01
for (const domainConfig of this.settings.domainConfigs) { for (const domainConfig of this.settings.domainConfigs) {
for (const domain of domainConfig.domains) { for (const domain of domainConfig.domains) {
// Skip wildcards // Skip wildcard domains
if (domain.includes('*')) continue; if (domain.includes('*')) continue;
// Determine provisioning method
this.port80Handler.addDomain({ let provision = 'http01' as string | plugins.tsclass.network.ICert;
domainName: domain, if (this.settings.certProvider) {
sslRedirect: true, try {
acmeMaintenance: true provision = await this.settings.certProvider(domain);
}); } catch (err) {
console.log(`certProvider error for ${domain}: ${err}`);
console.log(`Registered domain ${domain} with Port80Handler`); }
}
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 // Set up event listeners
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => { this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => {
console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); 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) => { this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => {
console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); 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) => { this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (failureData) => {
@ -429,22 +468,40 @@ export class SmartProxy {
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
} }
// If Port80Handler is running, register non-wildcard domains // If Port80Handler is running, provision certificates per new domain
if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) { if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) {
for (const domainConfig of newDomainConfigs) { for (const domainConfig of newDomainConfigs) {
for (const domain of domainConfig.domains) { for (const domain of domainConfig.domains) {
// Skip wildcards
if (domain.includes('*')) continue; if (domain.includes('*')) continue;
let provision = 'http01' as string | plugins.tsclass.network.ICert;
this.port80Handler.addDomain({ if (this.settings.certProvider) {
domainName: domain, try {
sslRedirect: true, provision = await this.settings.certProvider(domain);
acmeMaintenance: true } 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 {
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`);
}
} }
} }
console.log('Provisioned certificates for new domains');
console.log('Registered non-wildcard domains with Port80Handler');
} }
} }