Compare commits

...

18 Commits

Author SHA1 Message Date
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
20 changed files with 2617 additions and 920 deletions

View File

@ -1,5 +1,72 @@
# Changelog # Changelog
## 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": "8.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

47
readme.plan.md Normal file
View File

@ -0,0 +1,47 @@
## Refactor: Introduce a Unified CertProvisioner for Certificate Lifecycle
- [x] Ensure Port80Handler is challenge-only:
- Remove any internal scheduling and deprecated ACME flows (`getAcmeClient`, `processAuthorizations`, `handleAcmeChallenge`) from Port80Handler.
- Remove legacy ACME options (`renewThresholdDays`, `renewCheckIntervalHours`, `mongoDescriptor`, etc.) from `IPort80HandlerOptions`.
- Retain only methods for HTTP-01 challenge and direct renewals (`obtainCertificate`, `renewCertificate`, `getDomainCertificateStatus`).
- [x] Clean up deprecated `acme` configuration:
- Remove the `acme` property from `IPortProxySettings` and all legacy references in code.
- [x] Implement `CertProvisioner` component:
- [x] Create class `ts/smartproxy/classes.pp.certprovisioner.ts`.
- [x] Constructor accepts:
* `domainConfigs: IDomainConfig[]`
* `port80Handler: Port80Handler`
* `networkProxyBridge: NetworkProxyBridge`
* optional `certProvider: (domain) => Promise<ICert | 'http01'>`
* `renewThresholdDays`, `renewCheckIntervalHours`, `autoRenew` settings.
- Responsibilities:
* Initial provisioning: static vs HTTP-01.
* Subscribe to Port80Handler events (CERTIFICATE_ISSUED/RENEWED) and to static cert updates.
* Re-emit unified `'certificate'` events to SmartProxy.
* Central scheduling of renewals via `@push.rocks/taskbuffer`.
- [x] Refactor SmartProxy:
- [x] Remove existing scheduling / renewal logic.
- [x] Instantiate `CertProvisioner` in `start()`, delegate cert workflows entirely.
- [x] Forward CertProvisioner events to SmartProxys `'certificate'` listener.
- [x] CertProvisioner lifecycle methods:
- [x] `start()`: provision all domains, start scheduler.
- [x] `stop()`: stop scheduler.
- [x] `requestCertificate(domain)`: on-demand provisioning.
- [x] Handle static certificate auto-refresh:
- [x] In the renewal scheduler, for domains with static certs, re-call `certProvider(domain)` near expiry.
- [x] Apply returned cert via `networkProxyBridge.applyExternalCertificate()`.
- [ ] Tests:
- Unit tests for `CertProvisioner`, mocking Port80Handler and `certProvider`:
* Validate initial provisioning and dynamic/static flows.
* Validate scheduling triggers correct renewals.
- Integration tests:
* Use actual in-memory Port80Handler with short intervals to verify renewals and event emission.
- [ ] Documentation:
- Add code-level TS doc for `CertProvisioner` API (options, methods, events).
- Update root `README.md` and architecture diagrams to show `CertProvisioner` role.

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

View File

@ -353,11 +353,8 @@ export class CertificateManager {
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
}); });

View File

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

View File

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

View File

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

View File

@ -5,9 +5,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,21 @@
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'; // (fs and path I/O moved to CertProvisioner)
import * as path from 'path'; // ACME HTTP-01 challenge handler storing tokens in memory (diskless)
class DisklessHttp01Handler {
private storage: Map<string, string>;
constructor(storage: Map<string, string>) { this.storage = storage; }
public getSupportedTypes(): string[] { return ['http-01']; }
public async prepare(ch: any): Promise<void> {
this.storage.set(ch.token, ch.keyAuthorization);
}
public async verify(ch: any): Promise<void> {
return;
}
public async cleanup(ch: any): Promise<void> {
this.storage.delete(ch.token);
}
}
/** /**
* Custom error classes for better error handling * Custom error classes for better error handling
@ -59,8 +73,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;
} }
@ -72,13 +84,9 @@ interface IPort80HandlerOptions {
port?: number; port?: number;
contactEmail?: string; contactEmail?: string;
useProduction?: boolean; useProduction?: boolean;
renewThresholdDays?: number;
httpsRedirectPort?: number; httpsRedirectPort?: number;
renewCheckIntervalHours?: number;
enabled?: boolean; // Whether ACME is enabled at all enabled?: boolean; // Whether ACME is enabled at all
autoRenew?: boolean; // Whether to automatically renew certificates // (Persistence moved to CertProvisioner)
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
} }
/** /**
@ -128,10 +136,13 @@ 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<IPort80HandlerOptions>;
@ -148,13 +159,8 @@ 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', // Default store location
skipConfiguredCerts: options.skipConfiguredCerts ?? false
}; };
} }
@ -175,13 +181,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 +210,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 +246,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 +360,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 +395,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 +456,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 +485,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 +620,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
if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
// Emit the appropriate event
const eventType = isRenewal const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED ? 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 && (
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); 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
@ -1176,4 +813,16 @@ export class Port80Handler extends plugins.EventEmitter {
public getConfig(): Required<IPort80HandlerOptions> { public getConfig(): Required<IPort80HandlerOptions> {
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,183 @@
import * as plugins from '../plugins.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js';
import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.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
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: false });
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (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)
@ -104,17 +109,11 @@ export interface IPortProxySettings {
}>; }>;
}; };
// 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

@ -43,10 +43,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 +91,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 +284,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.port80HandlerConfig?.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 +345,7 @@ export class NetworkProxyBridge {
return false; return false;
} }
if (!this.settings.port80HandlerConfig?.enabled && !this.settings.acme?.enabled) { if (!this.settings.port80HandlerConfig?.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,16 @@ 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 { CertProvisioner } from './classes.pp.certprovisioner.js';
import type { ICertificateData } from '../port80handler/classes.port80handler.js';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
/** /**
* SmartProxy - Main class that coordinates all components * SmartProxy - Main class that coordinates all components
*/ */
export class SmartProxy { export class SmartProxy extends plugins.EventEmitter {
private netServers: plugins.net.Server[] = []; private netServers: plugins.net.Server[] = [];
private connectionLogger: NodeJS.Timeout | null = null; private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
@ -32,8 +34,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,
@ -66,24 +71,8 @@ export class SmartProxy {
globalPortRanges: settingsArg.globalPortRanges || [], globalPortRanges: settingsArg.globalPortRanges || [],
}; };
// Set port80HandlerConfig defaults, using legacy acme config if available // Set default port80HandlerConfig if not provided
if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) { if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) {
if (this.settings.acme) {
// Migrate from legacy acme config
this.settings.port80HandlerConfig = {
enabled: this.settings.acme.enabled,
port: this.settings.acme.port || 80,
contactEmail: this.settings.acme.contactEmail || 'admin@example.com',
useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false, // Default to true
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
httpsRedirectPort: this.settings.fromPort,
renewCheckIntervalHours: 24
};
} else {
// Set defaults if no config provided
this.settings.port80HandlerConfig = { this.settings.port80HandlerConfig = {
enabled: false, enabled: false,
port: 80, port: 80,
@ -97,7 +86,6 @@ export class SmartProxy {
renewCheckIntervalHours: 24 renewCheckIntervalHours: 24
}; };
} }
}
// Initialize component managers // Initialize component managers
this.timeoutManager = new TimeoutManager(this.settings); this.timeoutManager = new TimeoutManager(this.settings);
@ -156,62 +144,13 @@ export class SmartProxy {
port: config.port, port: config.port,
contactEmail: config.contactEmail, contactEmail: config.contactEmail,
useProduction: config.useProduction, useProduction: config.useProduction,
renewThresholdDays: config.renewThresholdDays,
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort, httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort,
renewCheckIntervalHours: config.renewCheckIntervalHours,
enabled: config.enabled, enabled: config.enabled,
autoRenew: config.autoRenew,
certificateStore: config.certificateStore, certificateStore: config.certificateStore,
skipConfiguredCerts: config.skipConfiguredCerts skipConfiguredCerts: config.skipConfiguredCerts
}); });
// 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 // Share Port80Handler with NetworkProxyBridge
this.networkProxyBridge.setPort80Handler(this.port80Handler); this.networkProxyBridge.setPort80Handler(this.port80Handler);
@ -236,6 +175,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) {
this.certProvisioner = new CertProvisioner(
this.settings.domainConfigs,
this.port80Handler,
this.networkProxyBridge,
this.settings.certProvider,
this.settings.port80HandlerConfig?.renewThresholdDays || 30,
this.settings.port80HandlerConfig?.renewCheckIntervalHours || 24,
this.settings.port80HandlerConfig?.autoRenew !== false,
// External ACME forwarding for specific domains
this.settings.port80HandlerConfig?.domainForwards?.map(f => ({
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
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 +334,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,22 +404,40 @@ export class SmartProxy {
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
} }
// If Port80Handler is running, register non-wildcard domains // If Port80Handler is running, provision certificates per new domain
if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) { if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) {
for (const domainConfig of newDomainConfigs) { for (const domainConfig of newDomainConfigs) {
for (const domain of domainConfig.domains) { for (const domain of domainConfig.domains) {
// Skip wildcards
if (domain.includes('*')) continue; if (domain.includes('*')) continue;
let provision = 'http01' as string | plugins.tsclass.network.ICert;
if (this.settings.certProvider) {
try {
provision = await this.settings.certProvider(domain);
} catch (err) {
console.log(`certProvider error for ${domain}: ${err}`);
}
}
if (provision === 'http01') {
this.port80Handler.addDomain({ this.port80Handler.addDomain({
domainName: domain, domainName: domain,
sslRedirect: true, sslRedirect: true,
acmeMaintenance: 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('Registered non-wildcard domains with Port80Handler'); console.log('Provisioned certificates for new domains');
} }
} }
@ -515,6 +508,27 @@ export class SmartProxy {
} }
} }
/**
* Perform scheduled renewals for managed domains
*/
private async performRenewals(): Promise<void> {
if (!this.port80Handler) return;
const statuses = this.port80Handler.getDomainCertificateStatus();
const threshold = this.settings.port80HandlerConfig.renewThresholdDays ?? 30;
const now = new Date();
for (const [domain, status] of statuses.entries()) {
if (!status.certObtained || status.obtainingInProgress || !status.expiryDate) continue;
const msRemaining = status.expiryDate.getTime() - now.getTime();
const daysRemaining = Math.ceil(msRemaining / (24 * 60 * 60 * 1000));
if (daysRemaining <= threshold) {
try {
await this.port80Handler.renewCertificate(domain);
} catch (err) {
console.error(`Error renewing certificate for ${domain}:`, err);
}
}
}
}
/** /**
* Request a certificate for a specific domain * Request a certificate for a specific domain
*/ */