Compare commits

...

20 Commits

Author SHA1 Message Date
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
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
23 changed files with 2829 additions and 1139 deletions

View File

@ -1,5 +1,81 @@
# Changelog # Changelog
## 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
- 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": "9.0.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,25 @@
"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", "@push.rocks/taskbuffer": "^3.1.7",
"@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 +85,6 @@
"mongodb-memory-server", "mongodb-memory-server",
"puppeteer" "puppeteer"
] ]
} },
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
} }

1856
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

29
readme.plan.md Normal file
View File

@ -0,0 +1,29 @@
# Project Simplification Plan
This document outlines a roadmap to simplify and refactor the SmartProxy & NetworkProxy codebase for better maintainability, reduced duplication, and clearer configuration.
## 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
- [ ] Unify configuration options:
- [x] Merge `INetworkProxyOptions.acme`, `IPort80HandlerOptions`, and `port80HandlerConfig` into one schema
- [ ] Deprecate old option names and provide clear upgrade path
- [ ] Centralize plugin imports in `ts/plugins.ts` and update all modules to use it
- [ ] 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

@ -0,0 +1,45 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartProxy } from '../ts/smartproxy/classes.smartproxy.js';
tap.test('performRenewals only renews domains below threshold', async () => {
// Set up SmartProxy instance without real servers
const proxy = new SmartProxy({
fromPort: 0,
toPort: 0,
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: [],
globalPortRanges: []
});
// Stub port80Handler status and renewal
const statuses = new Map<string, any>();
const now = new Date();
statuses.set('expiring.com', {
certObtained: true,
expiryDate: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000),
obtainingInProgress: false
});
statuses.set('ok.com', {
certObtained: true,
expiryDate: new Date(now.getTime() + 100 * 24 * 60 * 60 * 1000),
obtainingInProgress: false
});
const renewed: string[] = [];
// Inject fake handler
(proxy as any).port80Handler = {
getDomainCertificateStatus: () => statuses,
renewCertificate: async (domain: string) => { renewed.push(domain); }
};
// Configure threshold
proxy.settings.port80HandlerConfig.enabled = true;
proxy.settings.port80HandlerConfig.autoRenew = true;
proxy.settings.port80HandlerConfig.renewThresholdDays = 10;
// Execute renewals
await (proxy as any).performRenewals();
// Only the expiring.com domain should be renewed
expect(renewed).toEqual(['expiring.com']);
});
export default tap.start();

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: '9.0.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.'
} }

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);
}
}

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

@ -0,0 +1,89 @@
/**
* 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
}

View File

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

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

@ -1,5 +1,10 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
/**
* Configuration options for NetworkProxy
*/
import type { IAcmeOptions } from '../common/types.js';
/** /**
* Configuration options for NetworkProxy * Configuration options for NetworkProxy
*/ */
@ -20,18 +25,11 @@ 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?: IAcmeOptions;
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
};
} }
/** /**
@ -58,6 +56,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,9 @@ 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, http2 };
export { EventEmitter, http, https, net, tls, url };
// tsclass scope // tsclass scope
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
@ -21,13 +21,27 @@ 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';
import * as taskbuffer from '@push.rocks/taskbuffer';
export {
lik,
smartdelay,
smartrequest,
smartpromise,
smartstring,
smartacme,
smartacmePlugins,
smartacmeHandlers,
taskbuffer,
};
// 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

@ -1,7 +1,30 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import * as fs from 'fs'; import { Port80HandlerEvents } from '../common/types.js';
import * as path from 'path'; 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>;
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
@ -31,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 * Represents a domain configuration with certificate status information
@ -59,8 +64,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;
} }
@ -68,59 +71,8 @@ interface IDomainCertificate {
/** /**
* Configuration options for the Port80Handler * Configuration options for the Port80Handler
*/ */
interface IPort80HandlerOptions { // Port80Handler options moved to common types
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
}
/**
* 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 * Port80Handler with ACME certificate management and request forwarding capabilities
@ -128,18 +80,21 @@ 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; // Renewal scheduling is handled externally by SmartProxy
private accountKey: string | null = null; // (Removed internal renewal timer)
private renewalTimer: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
private options: Required<IPort80HandlerOptions>; private options: Required<IAcmeOptions>;
/** /**
* Creates a new Port80Handler * Creates a new Port80Handler
* @param options Configuration options * @param options Configuration options
*/ */
constructor(options: IPort80HandlerOptions = {}) { constructor(options: IAcmeOptions = {}) {
super(); super();
this.domainCertificates = new Map<string, IDomainCertificate>(); this.domainCertificates = new Map<string, IDomainCertificate>();
@ -148,13 +103,14 @@ export class Port80Handler extends plugins.EventEmitter {
port: options.port ?? 80, port: options.port ?? 80,
contactEmail: options.contactEmail ?? 'admin@example.com', contactEmail: options.contactEmail ?? 'admin@example.com',
useProduction: options.useProduction ?? false, // Safer default: staging useProduction: options.useProduction ?? false, // Safer default: staging
renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
httpsRedirectPort: options.httpsRedirectPort ?? 443, httpsRedirectPort: options.httpsRedirectPort ?? 443,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
enabled: options.enabled ?? true, // Enable by default enabled: options.enabled ?? true, // Enable by default
autoRenew: options.autoRenew ?? true, // Auto-renew by default certificateStore: options.certificateStore ?? './certs',
certificateStore: options.certificateStore ?? './certs', // Default store location skipConfiguredCerts: options.skipConfiguredCerts ?? false,
skipConfiguredCerts: options.skipConfiguredCerts ?? false renewThresholdDays: options.renewThresholdDays ?? 30,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
autoRenew: options.autoRenew ?? true,
domainForwards: options.domainForwards ?? []
}; };
} }
@ -175,13 +131,20 @@ 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 {
// Load certificates from store if enabled
if (this.options.certificateStore) {
this.loadCertificatesFromStore();
}
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
@ -197,7 +160,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.server.listen(this.options.port, () => { this.server.listen(this.options.port, () => {
console.log(`Port80Handler is listening on port ${this.options.port}`); console.log(`Port80Handler is listening on port ${this.options.port}`);
this.startRenewalTimer();
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
// Start certificate process for domains with acmeMaintenance enabled // Start certificate process for domains with acmeMaintenance enabled
@ -234,11 +196,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.isShuttingDown = true; this.isShuttingDown = true;
// Stop the renewal timer
if (this.renewalTimer) {
clearInterval(this.renewalTimer);
this.renewalTimer = null;
}
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (this.server) { if (this.server) {
@ -353,10 +310,7 @@ export class Port80Handler extends plugins.EventEmitter {
console.log(`Certificate set for ${domain}`); console.log(`Certificate set for ${domain}`);
// Save certificate to store if enabled // (Persistence of certificates moved to CertProvisioner)
if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
// Emit certificate event // Emit certificate event
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
@ -391,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 * Check if a domain is a glob pattern
@ -579,38 +406,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 +435,32 @@ 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 // Handle ACME HTTP-01 challenge requests or forwarding
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) { if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
// 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;
} }
// If not managing ACME for this domain, return 404
// Only handle ACME challenges for non-glob patterns if (!options.acmeMaintenance) {
if (!this.isGlobPattern(pattern)) { res.statusCode = 404;
this.handleAcmeChallenge(req, res, domain); res.end('Not found');
return; return;
} }
// Serve challenge response from in-memory storage
const token = req.url.split('/').pop() || '';
const keyAuth = this.acmeHttp01Storage.get(token);
if (keyAuth) {
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,292 +570,71 @@ 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}`);
// Persistence moved to CertProvisioner
// Save the certificate to the store if enabled const eventType = isRenewal
if (this.options.certificateStore) { ? Port80HandlerEvents.CERTIFICATE_RENEWED
this.saveCertificateToStore(domain, certificate, privateKey);
}
// Emit the appropriate event
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
*/
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 * Extract expiry date from certificate using a more robust approach
@ -1173,7 +760,19 @@ export class Port80Handler extends plugins.EventEmitter {
* Gets configuration details * Gets configuration details
* @returns Current configuration * @returns Current configuration
*/ */
public getConfig(): Required<IPort80HandlerOptions> { public getConfig(): Required<IAcmeOptions> {
return { ...this.options }; 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

@ -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)
@ -16,6 +21,7 @@ export interface IDomainConfig {
} }
/** Port proxy settings including global allowed port ranges */ /** Port proxy settings including global allowed port ranges */
import type { IAcmeOptions } from '../common/types.js';
export interface IPortProxySettings { export interface IPortProxySettings {
fromPort: number; fromPort: number;
toPort: number; toPort: number;
@ -78,43 +84,14 @@ export interface IPortProxySettings {
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
// Port80Handler configuration (replaces ACME configuration) // ACME configuration options for SmartProxy
port80HandlerConfig?: { acme?: IAcmeOptions;
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;
};
}>;
};
// Legacy ACME configuration (deprecated, use port80HandlerConfig instead) /**
acme?: { * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
enabled?: boolean; * or a static certificate object for immediate provisioning.
port?: number; */
contactEmail?: string; certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
useProduction?: boolean;
renewThresholdDays?: number;
autoRenew?: boolean;
certificateStore?: string;
skipConfiguredCerts?: boolean;
};
} }
/** /**

View File

@ -1,6 +1,9 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.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'; import type { IConnectionRecord, IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
/** /**
@ -18,9 +21,11 @@ export class NetworkProxyBridge {
public setPort80Handler(handler: Port80Handler): void { public setPort80Handler(handler: Port80Handler): void {
this.port80Handler = handler; this.port80Handler = handler;
// Register for certificate events // Subscribe to certificate events
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateEvent.bind(this)); subscribeToPort80Handler(handler, {
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateEvent.bind(this)); onCertificateIssued: this.handleCertificateEvent.bind(this),
onCertificateRenewed: this.handleCertificateEvent.bind(this)
});
// If NetworkProxy is already initialized, connect it with Port80Handler // If NetworkProxy is already initialized, connect it with Port80Handler
if (this.networkProxy) { if (this.networkProxy) {
@ -43,10 +48,6 @@ export class NetworkProxyBridge {
useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available 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); this.networkProxy = new NetworkProxy(networkProxyOptions);
@ -95,6 +96,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
*/ */
@ -277,7 +289,7 @@ export class NetworkProxyBridge {
); );
// Log ACME-eligible domains // Log ACME-eligible domains
const acmeEnabled = this.settings.port80HandlerConfig?.enabled || this.settings.acme?.enabled; const acmeEnabled = !!this.settings.acme?.enabled;
if (acmeEnabled) { if (acmeEnabled) {
const acmeEligibleDomains = proxyConfigs const acmeEligibleDomains = proxyConfigs
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards .filter((config) => !config.hostName.includes('*')) // Exclude wildcards
@ -338,7 +350,7 @@ export class NetworkProxyBridge {
return false; 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'); console.log('Cannot request certificate - ACME is not enabled');
return false; 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 // Add global port ranges
if (this.settings.globalPortRanges) { if (this.settings.globalPortRanges) {
@ -202,12 +198,6 @@ export class PortRangeManager {
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`); 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; return warnings;
} }

View File

@ -8,14 +8,15 @@ 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 } from '../port80handler/classes.port80handler.js';
import * as path from 'path'; import { CertProvisioner } from './classes.pp.certprovisioner.js';
import * as fs from 'fs'; import type { ICertificateData } from '../common/types.js';
import { buildPort80Handler } from '../common/acmeFactory.js';
/** /**
* 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;
@ -32,8 +33,11 @@ export class SmartProxy {
// Port80Handler for ACME certificate management // Port80Handler for ACME certificate management
private port80Handler: Port80Handler | null = null; private port80Handler: Port80Handler | null = null;
// CertProvisioner for unified certificate workflows
private certProvisioner?: CertProvisioner;
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,
@ -62,41 +66,25 @@ export class SmartProxy {
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
networkProxyPort: settingsArg.networkProxyPort || 8443, networkProxyPort: settingsArg.networkProxyPort || 8443,
port80HandlerConfig: settingsArg.port80HandlerConfig || {}, acme: settingsArg.acme || {},
globalPortRanges: settingsArg.globalPortRanges || [], globalPortRanges: settingsArg.globalPortRanges || [],
}; };
// Set port80HandlerConfig defaults, using legacy acme config if available // Set default ACME options if not provided
if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) { if (!this.settings.acme || Object.keys(this.settings.acme).length === 0) {
if (this.settings.acme) { this.settings.acme = {
// Migrate from legacy acme config enabled: false,
this.settings.port80HandlerConfig = { port: 80,
enabled: this.settings.acme.enabled, contactEmail: 'admin@example.com',
port: this.settings.acme.port || 80, useProduction: false,
contactEmail: this.settings.acme.contactEmail || 'admin@example.com', renewThresholdDays: 30,
useProduction: this.settings.acme.useProduction || false, autoRenew: true,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30, certificateStore: './certs',
autoRenew: this.settings.acme.autoRenew !== false, // Default to true skipConfiguredCerts: false,
certificateStore: this.settings.acme.certificateStore || './certs', httpsRedirectPort: this.settings.fromPort,
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, renewCheckIntervalHours: 24,
httpsRedirectPort: this.settings.fromPort, domainForwards: []
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
};
}
} }
// Initialize component managers // Initialize component managers
@ -134,89 +122,20 @@ export class SmartProxy {
* Initialize the Port80Handler for ACME certificate management * Initialize the Port80Handler for ACME certificate management
*/ */
private async initializePort80Handler(): Promise<void> { private async initializePort80Handler(): Promise<void> {
const config = this.settings.port80HandlerConfig; const config = this.settings.acme!;
if (!config.enabled) {
if (!config || !config.enabled) { console.log('ACME is disabled in configuration');
console.log('Port80Handler is disabled in configuration');
return; return;
} }
try { try {
// Ensure the certificate store directory exists // Build and start the Port80Handler
if (config.certificateStore) { this.port80Handler = buildPort80Handler({
const certStorePath = path.resolve(config.certificateStore); ...config,
if (!fs.existsSync(certStorePath)) { httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort
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
}); });
// Share Port80Handler with NetworkProxyBridge before start
// 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}`);
}
}
// Register all non-wildcard domains from domain configs
for (const domainConfig of this.settings.domainConfigs) {
for (const domain of domainConfig.domains) {
// Skip wildcards
if (domain.includes('*')) continue;
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Registered domain ${domain} with Port80Handler`);
}
}
// Set up event listeners
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => {
console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => {
console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
});
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
this.networkProxyBridge.setPort80Handler(this.port80Handler); this.networkProxyBridge.setPort80Handler(this.port80Handler);
// Start Port80Handler
await this.port80Handler.start(); await this.port80Handler.start();
console.log(`Port80Handler started on port ${config.port}`); console.log(`Port80Handler started on port ${config.port}`);
} catch (err) { } catch (err) {
@ -236,6 +155,37 @@ export class SmartProxy {
// Initialize Port80Handler if enabled // Initialize Port80Handler if enabled
await this.initializePort80Handler(); 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 // Initialize and start NetworkProxy if needed
if ( if (
@ -364,6 +314,11 @@ export class SmartProxy {
public async stop() { public async stop() {
console.log('PortProxy shutting down...'); console.log('PortProxy shutting down...');
this.isShuttingDown = true; this.isShuttingDown = true;
// Stop CertProvisioner if active
if (this.certProvisioner) {
await this.certProvisioner.stop();
console.log('CertProvisioner stopped');
}
// Stop the Port80Handler if running // Stop the Port80Handler if running
if (this.port80Handler) { if (this.port80Handler) {
@ -429,92 +384,65 @@ 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.acme?.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');
} }
} }
/** /**
* Updates the Port80Handler configuration * Perform scheduled renewals for managed domains
*/ */
public async updatePort80HandlerConfig(config: IPortProxySettings['port80HandlerConfig']): Promise<void> { private async performRenewals(): Promise<void> {
if (!config) return; if (!this.port80Handler) return;
const statuses = this.port80Handler.getDomainCertificateStatus();
console.log('Updating Port80Handler configuration'); const threshold = this.settings.acme?.renewThresholdDays ?? 30;
const now = new Date();
// Update the settings for (const [domain, status] of statuses.entries()) {
this.settings.port80HandlerConfig = { if (!status.certObtained || status.obtainingInProgress || !status.expiryDate) continue;
...this.settings.port80HandlerConfig, const msRemaining = status.expiryDate.getTime() - now.getTime();
...config const daysRemaining = Math.ceil(msRemaining / (24 * 60 * 60 * 1000));
}; if (daysRemaining <= threshold) {
// 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 { try {
await this.port80Handler.stop(); await this.port80Handler.renewCertificate(domain);
this.port80Handler = null;
console.log('Stopped Port80Handler for configuration update');
} catch (err) { } catch (err) {
console.log(`Error stopping Port80Handler: ${err}`); console.error(`Error renewing certificate for ${domain}:`, 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 * Request a certificate for a specific domain
*/ */
@ -607,7 +535,7 @@ export class SmartProxy {
networkProxyConnections, networkProxyConnections,
terminationStats, terminationStats,
acmeEnabled: !!this.port80Handler, acmeEnabled: !!this.port80Handler,
port80HandlerPort: this.port80Handler ? this.settings.port80HandlerConfig?.port : null port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null
}; };
} }
@ -658,7 +586,7 @@ export class SmartProxy {
status: 'valid', status: 'valid',
expiryDate: expiryDate.toISOString(), expiryDate: expiryDate.toISOString(),
daysRemaining, daysRemaining,
renewalNeeded: daysRemaining <= this.settings.port80HandlerConfig.renewThresholdDays renewalNeeded: daysRemaining <= (this.settings.acme?.renewThresholdDays ?? 0)
}; };
} else { } else {
certificateStatus[domain] = { certificateStatus[domain] = {
@ -668,11 +596,12 @@ export class SmartProxy {
} }
} }
const acme = this.settings.acme!;
return { return {
enabled: true, enabled: true,
port: this.settings.port80HandlerConfig.port, port: acme.port!,
useProduction: this.settings.port80HandlerConfig.useProduction, useProduction: acme.useProduction!,
autoRenew: this.settings.port80HandlerConfig.autoRenew, autoRenew: acme.autoRenew!,
certificates: certificateStatus certificates: certificateStatus
}; };
} }