@push.rocks/smartproxy 18.1.0 → 18.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/certificate/certificate-manager.d.ts +150 -0
- package/dist_ts/certificate/certificate-manager.js +505 -0
- package/dist_ts/certificate/events/simplified-events.d.ts +56 -0
- package/dist_ts/certificate/events/simplified-events.js +13 -0
- package/dist_ts/certificate/models/certificate-errors.d.ts +69 -0
- package/dist_ts/certificate/models/certificate-errors.js +141 -0
- package/dist_ts/certificate/models/certificate-strategy.d.ts +60 -0
- package/dist_ts/certificate/models/certificate-strategy.js +73 -0
- package/dist_ts/certificate/simplified-certificate-manager.d.ts +150 -0
- package/dist_ts/certificate/simplified-certificate-manager.js +501 -0
- package/dist_ts/http/index.d.ts +1 -9
- package/dist_ts/http/index.js +5 -11
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +4 -2
- package/dist_ts/proxies/network-proxy/network-proxy.js +3 -1
- package/dist_ts/proxies/network-proxy/simplified-certificate-bridge.d.ts +48 -0
- package/dist_ts/proxies/network-proxy/simplified-certificate-bridge.js +76 -0
- package/dist_ts/proxies/network-proxy/websocket-handler.js +21 -7
- package/dist_ts/proxies/smart-proxy/cert-store.d.ts +10 -0
- package/dist_ts/proxies/smart-proxy/cert-store.js +70 -0
- package/dist_ts/proxies/smart-proxy/certificate-manager.d.ts +116 -0
- package/dist_ts/proxies/smart-proxy/certificate-manager.js +401 -0
- package/dist_ts/proxies/smart-proxy/legacy-smart-proxy.d.ts +168 -0
- package/dist_ts/proxies/smart-proxy/legacy-smart-proxy.js +642 -0
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +26 -0
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/models/simplified-smartproxy-config.d.ts +65 -0
- package/dist_ts/proxies/smart-proxy/models/simplified-smartproxy-config.js +31 -0
- package/dist_ts/proxies/smart-proxy/models/smartproxy-options.d.ts +102 -0
- package/dist_ts/proxies/smart-proxy/models/smartproxy-options.js +73 -0
- package/dist_ts/proxies/smart-proxy/network-proxy-bridge.d.ts +10 -44
- package/dist_ts/proxies/smart-proxy/network-proxy-bridge.js +66 -202
- package/dist_ts/proxies/smart-proxy/route-connection-handler.d.ts +4 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.js +62 -2
- package/dist_ts/proxies/smart-proxy/simplified-smart-proxy.d.ts +41 -0
- package/dist_ts/proxies/smart-proxy/simplified-smart-proxy.js +132 -0
- package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +18 -13
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +79 -196
- package/package.json +5 -3
- package/readme.plan.md +1405 -617
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/http/index.ts +5 -12
- package/ts/plugins.ts +4 -1
- package/ts/proxies/network-proxy/network-proxy.ts +3 -0
- package/ts/proxies/network-proxy/websocket-handler.ts +18 -6
- package/ts/proxies/smart-proxy/cert-store.ts +86 -0
- package/ts/proxies/smart-proxy/certificate-manager.ts +506 -0
- package/ts/proxies/smart-proxy/models/route-types.ts +33 -3
- package/ts/proxies/smart-proxy/network-proxy-bridge.ts +86 -239
- package/ts/proxies/smart-proxy/route-connection-handler.ts +74 -1
- package/ts/proxies/smart-proxy/smart-proxy.ts +105 -222
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@push.rocks/smartproxy',
|
|
6
|
-
version: '18.
|
|
6
|
+
version: '18.2.0',
|
|
7
7
|
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
|
8
8
|
}
|
package/ts/http/index.ts
CHANGED
|
@@ -5,19 +5,12 @@
|
|
|
5
5
|
// Export types and models
|
|
6
6
|
export * from './models/http-types.js';
|
|
7
7
|
|
|
8
|
-
// Export submodules
|
|
9
|
-
export * from './port80/index.js';
|
|
8
|
+
// Export submodules (remove port80 export)
|
|
10
9
|
export * from './router/index.js';
|
|
11
10
|
export * from './redirects/index.js';
|
|
11
|
+
// REMOVED: export * from './port80/index.js';
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
import { Port80Handler } from './port80/port80-handler.js';
|
|
15
|
-
import { ChallengeResponder } from './port80/challenge-responder.js';
|
|
16
|
-
|
|
17
|
-
// Convenience namespace exports
|
|
13
|
+
// Convenience namespace exports (no more Port80)
|
|
18
14
|
export const Http = {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ChallengeResponder: ChallengeResponder
|
|
22
|
-
}
|
|
23
|
-
};
|
|
15
|
+
// Only router and redirect functionality remain
|
|
16
|
+
};
|
package/ts/plugins.ts
CHANGED
|
@@ -21,7 +21,8 @@ import * as smartdelay from '@push.rocks/smartdelay';
|
|
|
21
21
|
import * as smartpromise from '@push.rocks/smartpromise';
|
|
22
22
|
import * as smartrequest from '@push.rocks/smartrequest';
|
|
23
23
|
import * as smartstring from '@push.rocks/smartstring';
|
|
24
|
-
|
|
24
|
+
import * as smartfile from '@push.rocks/smartfile';
|
|
25
|
+
import * as smartcrypto from '@push.rocks/smartcrypto';
|
|
25
26
|
import * as smartacme from '@push.rocks/smartacme';
|
|
26
27
|
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
|
|
27
28
|
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js';
|
|
@@ -33,6 +34,8 @@ export {
|
|
|
33
34
|
smartrequest,
|
|
34
35
|
smartpromise,
|
|
35
36
|
smartstring,
|
|
37
|
+
smartfile,
|
|
38
|
+
smartcrypto,
|
|
36
39
|
smartacme,
|
|
37
40
|
smartacmePlugins,
|
|
38
41
|
smartacmeHandlers,
|
|
@@ -500,6 +500,9 @@ export class NetworkProxy implements IMetricsTracker {
|
|
|
500
500
|
this.logger.warn('Router has no recognized configuration method');
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
// Update WebSocket handler with new routes
|
|
504
|
+
this.webSocketHandler.setRoutes(routes);
|
|
505
|
+
|
|
503
506
|
this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`);
|
|
504
507
|
}
|
|
505
508
|
|
|
@@ -419,9 +419,15 @@ export class WebSocketHandler {
|
|
|
419
419
|
wsIncoming.on('close', (code, reason) => {
|
|
420
420
|
this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`);
|
|
421
421
|
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
422
|
+
// Ensure code is a valid WebSocket close code number
|
|
423
|
+
const validCode = typeof code === 'number' && code >= 1000 && code <= 4999 ? code : 1000;
|
|
424
|
+
try {
|
|
425
|
+
const reasonString = reason ? toBuffer(reason).toString() : '';
|
|
426
|
+
wsOutgoing.close(validCode, reasonString);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
this.logger.error('Error closing wsOutgoing:', err);
|
|
429
|
+
wsOutgoing.close(validCode);
|
|
430
|
+
}
|
|
425
431
|
}
|
|
426
432
|
|
|
427
433
|
// Clean up timers
|
|
@@ -432,9 +438,15 @@ export class WebSocketHandler {
|
|
|
432
438
|
wsOutgoing.on('close', (code, reason) => {
|
|
433
439
|
this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`);
|
|
434
440
|
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
441
|
+
// Ensure code is a valid WebSocket close code number
|
|
442
|
+
const validCode = typeof code === 'number' && code >= 1000 && code <= 4999 ? code : 1000;
|
|
443
|
+
try {
|
|
444
|
+
const reasonString = reason ? toBuffer(reason).toString() : '';
|
|
445
|
+
wsIncoming.close(validCode, reasonString);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
this.logger.error('Error closing wsIncoming:', err);
|
|
448
|
+
wsIncoming.close(validCode);
|
|
449
|
+
}
|
|
438
450
|
}
|
|
439
451
|
|
|
440
452
|
// Clean up timers
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { ICertificateData } from './certificate-manager.js';
|
|
3
|
+
|
|
4
|
+
export class CertStore {
|
|
5
|
+
constructor(private certDir: string) {}
|
|
6
|
+
|
|
7
|
+
public async initialize(): Promise<void> {
|
|
8
|
+
await plugins.smartfile.fs.ensureDirSync(this.certDir);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public async getCertificate(routeName: string): Promise<ICertificateData | null> {
|
|
12
|
+
const certPath = this.getCertPath(routeName);
|
|
13
|
+
const metaPath = `${certPath}/meta.json`;
|
|
14
|
+
|
|
15
|
+
if (!await plugins.smartfile.fs.fileExistsSync(metaPath)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const metaFile = await plugins.smartfile.SmartFile.fromFilePath(metaPath);
|
|
21
|
+
const meta = JSON.parse(metaFile.contents.toString());
|
|
22
|
+
|
|
23
|
+
const certFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/cert.pem`);
|
|
24
|
+
const cert = certFile.contents.toString();
|
|
25
|
+
|
|
26
|
+
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/key.pem`);
|
|
27
|
+
const key = keyFile.contents.toString();
|
|
28
|
+
|
|
29
|
+
let ca: string | undefined;
|
|
30
|
+
const caPath = `${certPath}/ca.pem`;
|
|
31
|
+
if (await plugins.smartfile.fs.fileExistsSync(caPath)) {
|
|
32
|
+
const caFile = await plugins.smartfile.SmartFile.fromFilePath(caPath);
|
|
33
|
+
ca = caFile.contents.toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
cert,
|
|
38
|
+
key,
|
|
39
|
+
ca,
|
|
40
|
+
expiryDate: new Date(meta.expiryDate),
|
|
41
|
+
issueDate: new Date(meta.issueDate)
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`Failed to load certificate for ${routeName}: ${error}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async saveCertificate(
|
|
50
|
+
routeName: string,
|
|
51
|
+
certData: ICertificateData
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const certPath = this.getCertPath(routeName);
|
|
54
|
+
await plugins.smartfile.fs.ensureDirSync(certPath);
|
|
55
|
+
|
|
56
|
+
// Save certificate files
|
|
57
|
+
await plugins.smartfile.memory.toFs(certData.cert, `${certPath}/cert.pem`);
|
|
58
|
+
await plugins.smartfile.memory.toFs(certData.key, `${certPath}/key.pem`);
|
|
59
|
+
|
|
60
|
+
if (certData.ca) {
|
|
61
|
+
await plugins.smartfile.memory.toFs(certData.ca, `${certPath}/ca.pem`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Save metadata
|
|
65
|
+
const meta = {
|
|
66
|
+
expiryDate: certData.expiryDate.toISOString(),
|
|
67
|
+
issueDate: certData.issueDate.toISOString(),
|
|
68
|
+
savedAt: new Date().toISOString()
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await plugins.smartfile.memory.toFs(JSON.stringify(meta, null, 2), `${certPath}/meta.json`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async deleteCertificate(routeName: string): Promise<void> {
|
|
75
|
+
const certPath = this.getCertPath(routeName);
|
|
76
|
+
if (await plugins.smartfile.fs.fileExistsSync(certPath)) {
|
|
77
|
+
await plugins.smartfile.fs.removeManySync([certPath]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private getCertPath(routeName: string): string {
|
|
82
|
+
// Sanitize route name for filesystem
|
|
83
|
+
const safeName = routeName.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
84
|
+
return `${this.certDir}/${safeName}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { NetworkProxy } from '../network-proxy/index.js';
|
|
3
|
+
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
|
|
4
|
+
import { CertStore } from './cert-store.js';
|
|
5
|
+
|
|
6
|
+
export interface ICertStatus {
|
|
7
|
+
domain: string;
|
|
8
|
+
status: 'valid' | 'pending' | 'expired' | 'error';
|
|
9
|
+
expiryDate?: Date;
|
|
10
|
+
issueDate?: Date;
|
|
11
|
+
source: 'static' | 'acme';
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ICertificateData {
|
|
16
|
+
cert: string;
|
|
17
|
+
key: string;
|
|
18
|
+
ca?: string;
|
|
19
|
+
expiryDate: Date;
|
|
20
|
+
issueDate: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SmartCertManager {
|
|
24
|
+
private certStore: CertStore;
|
|
25
|
+
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
|
26
|
+
private networkProxy: NetworkProxy | null = null;
|
|
27
|
+
private renewalTimer: NodeJS.Timeout | null = null;
|
|
28
|
+
private pendingChallenges: Map<string, string> = new Map();
|
|
29
|
+
private challengeRoute: IRouteConfig | null = null;
|
|
30
|
+
|
|
31
|
+
// Track certificate status by route name
|
|
32
|
+
private certStatus: Map<string, ICertStatus> = new Map();
|
|
33
|
+
|
|
34
|
+
// Callback to update SmartProxy routes for challenges
|
|
35
|
+
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private routes: IRouteConfig[],
|
|
39
|
+
private certDir: string = './certs',
|
|
40
|
+
private acmeOptions?: {
|
|
41
|
+
email?: string;
|
|
42
|
+
useProduction?: boolean;
|
|
43
|
+
port?: number;
|
|
44
|
+
}
|
|
45
|
+
) {
|
|
46
|
+
this.certStore = new CertStore(certDir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public setNetworkProxy(networkProxy: NetworkProxy): void {
|
|
50
|
+
this.networkProxy = networkProxy;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Set callback for updating routes (used for challenge routes)
|
|
55
|
+
*/
|
|
56
|
+
public setUpdateRoutesCallback(callback: (routes: IRouteConfig[]) => Promise<void>): void {
|
|
57
|
+
this.updateRoutesCallback = callback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Initialize certificate manager and provision certificates for all routes
|
|
62
|
+
*/
|
|
63
|
+
public async initialize(): Promise<void> {
|
|
64
|
+
// Create certificate directory if it doesn't exist
|
|
65
|
+
await this.certStore.initialize();
|
|
66
|
+
|
|
67
|
+
// Initialize SmartAcme if we have any ACME routes
|
|
68
|
+
const hasAcmeRoutes = this.routes.some(r =>
|
|
69
|
+
r.action.tls?.certificate === 'auto'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (hasAcmeRoutes && this.acmeOptions?.email) {
|
|
73
|
+
// Create HTTP-01 challenge handler
|
|
74
|
+
const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
|
75
|
+
|
|
76
|
+
// Set up challenge handler integration with our routing
|
|
77
|
+
this.setupChallengeHandler(http01Handler);
|
|
78
|
+
|
|
79
|
+
// Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler
|
|
80
|
+
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
81
|
+
accountEmail: this.acmeOptions.email,
|
|
82
|
+
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
|
83
|
+
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
|
84
|
+
challengeHandlers: [http01Handler]
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await this.smartAcme.start();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Provision certificates for all routes
|
|
91
|
+
await this.provisionAllCertificates();
|
|
92
|
+
|
|
93
|
+
// Start renewal timer
|
|
94
|
+
this.startRenewalTimer();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Provision certificates for all routes that need them
|
|
99
|
+
*/
|
|
100
|
+
private async provisionAllCertificates(): Promise<void> {
|
|
101
|
+
const certRoutes = this.routes.filter(r =>
|
|
102
|
+
r.action.tls?.mode === 'terminate' ||
|
|
103
|
+
r.action.tls?.mode === 'terminate-and-reencrypt'
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
for (const route of certRoutes) {
|
|
107
|
+
try {
|
|
108
|
+
await this.provisionCertificate(route);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Provision certificate for a single route
|
|
117
|
+
*/
|
|
118
|
+
public async provisionCertificate(route: IRouteConfig): Promise<void> {
|
|
119
|
+
const tls = route.action.tls;
|
|
120
|
+
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const domains = this.extractDomainsFromRoute(route);
|
|
125
|
+
if (domains.length === 0) {
|
|
126
|
+
console.warn(`Route ${route.name} has TLS termination but no domains`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const primaryDomain = domains[0];
|
|
131
|
+
|
|
132
|
+
if (tls.certificate === 'auto') {
|
|
133
|
+
// ACME certificate
|
|
134
|
+
await this.provisionAcmeCertificate(route, domains);
|
|
135
|
+
} else if (typeof tls.certificate === 'object') {
|
|
136
|
+
// Static certificate
|
|
137
|
+
await this.provisionStaticCertificate(route, primaryDomain, tls.certificate);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Provision ACME certificate
|
|
143
|
+
*/
|
|
144
|
+
private async provisionAcmeCertificate(
|
|
145
|
+
route: IRouteConfig,
|
|
146
|
+
domains: string[]
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
if (!this.smartAcme) {
|
|
149
|
+
throw new Error('SmartAcme not initialized');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const primaryDomain = domains[0];
|
|
153
|
+
const routeName = route.name || primaryDomain;
|
|
154
|
+
|
|
155
|
+
// Check if we already have a valid certificate
|
|
156
|
+
const existingCert = await this.certStore.getCertificate(routeName);
|
|
157
|
+
if (existingCert && this.isCertificateValid(existingCert)) {
|
|
158
|
+
console.log(`Using existing valid certificate for ${primaryDomain}`);
|
|
159
|
+
await this.applyCertificate(primaryDomain, existingCert);
|
|
160
|
+
this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(`Requesting ACME certificate for ${domains.join(', ')}`);
|
|
165
|
+
this.updateCertStatus(routeName, 'pending', 'acme');
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Add challenge route before requesting certificate
|
|
169
|
+
await this.addChallengeRoute();
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Use smartacme to get certificate
|
|
173
|
+
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
|
174
|
+
|
|
175
|
+
// SmartAcme's Cert object has these properties:
|
|
176
|
+
// - publicKey: The certificate PEM string
|
|
177
|
+
// - privateKey: The private key PEM string
|
|
178
|
+
// - csr: Certificate signing request
|
|
179
|
+
// - validUntil: Timestamp in milliseconds
|
|
180
|
+
// - domainName: The domain name
|
|
181
|
+
const certData: ICertificateData = {
|
|
182
|
+
cert: cert.publicKey,
|
|
183
|
+
key: cert.privateKey,
|
|
184
|
+
ca: cert.publicKey, // Use same as cert for now
|
|
185
|
+
expiryDate: new Date(cert.validUntil),
|
|
186
|
+
issueDate: new Date(cert.created)
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
await this.certStore.saveCertificate(routeName, certData);
|
|
190
|
+
await this.applyCertificate(primaryDomain, certData);
|
|
191
|
+
this.updateCertStatus(routeName, 'valid', 'acme', certData);
|
|
192
|
+
|
|
193
|
+
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
|
|
196
|
+
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
// Always remove challenge route after provisioning
|
|
200
|
+
await this.removeChallengeRoute();
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// Handle outer try-catch from adding challenge route
|
|
204
|
+
console.error(`Failed to setup ACME challenge for ${primaryDomain}: ${error}`);
|
|
205
|
+
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Provision static certificate
|
|
212
|
+
*/
|
|
213
|
+
private async provisionStaticCertificate(
|
|
214
|
+
route: IRouteConfig,
|
|
215
|
+
domain: string,
|
|
216
|
+
certConfig: { key: string; cert: string; keyFile?: string; certFile?: string }
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const routeName = route.name || domain;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
let key: string = certConfig.key;
|
|
222
|
+
let cert: string = certConfig.cert;
|
|
223
|
+
|
|
224
|
+
// Load from files if paths are provided
|
|
225
|
+
if (certConfig.keyFile) {
|
|
226
|
+
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.keyFile);
|
|
227
|
+
key = keyFile.contents.toString();
|
|
228
|
+
}
|
|
229
|
+
if (certConfig.certFile) {
|
|
230
|
+
const certFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.certFile);
|
|
231
|
+
cert = certFile.contents.toString();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Parse certificate to get dates
|
|
235
|
+
// Parse certificate to get dates - for now just use defaults
|
|
236
|
+
// TODO: Implement actual certificate parsing if needed
|
|
237
|
+
const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() };
|
|
238
|
+
|
|
239
|
+
const certData: ICertificateData = {
|
|
240
|
+
cert,
|
|
241
|
+
key,
|
|
242
|
+
expiryDate: certInfo.validTo,
|
|
243
|
+
issueDate: certInfo.validFrom
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Save to store for consistency
|
|
247
|
+
await this.certStore.saveCertificate(routeName, certData);
|
|
248
|
+
await this.applyCertificate(domain, certData);
|
|
249
|
+
this.updateCertStatus(routeName, 'valid', 'static', certData);
|
|
250
|
+
|
|
251
|
+
console.log(`Successfully loaded static certificate for ${domain}`);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error(`Failed to provision static certificate for ${domain}: ${error}`);
|
|
254
|
+
this.updateCertStatus(routeName, 'error', 'static', undefined, error.message);
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Apply certificate to NetworkProxy
|
|
261
|
+
*/
|
|
262
|
+
private async applyCertificate(domain: string, certData: ICertificateData): Promise<void> {
|
|
263
|
+
if (!this.networkProxy) {
|
|
264
|
+
console.warn('NetworkProxy not set, cannot apply certificate');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Apply certificate to NetworkProxy
|
|
269
|
+
this.networkProxy.updateCertificate(domain, certData.cert, certData.key);
|
|
270
|
+
|
|
271
|
+
// Also apply for wildcard if it's a subdomain
|
|
272
|
+
if (domain.includes('.') && !domain.startsWith('*.')) {
|
|
273
|
+
const parts = domain.split('.');
|
|
274
|
+
if (parts.length >= 2) {
|
|
275
|
+
const wildcardDomain = `*.${parts.slice(-2).join('.')}`;
|
|
276
|
+
this.networkProxy.updateCertificate(wildcardDomain, certData.cert, certData.key);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract domains from route configuration
|
|
283
|
+
*/
|
|
284
|
+
private extractDomainsFromRoute(route: IRouteConfig): string[] {
|
|
285
|
+
if (!route.match.domains) {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const domains = Array.isArray(route.match.domains)
|
|
290
|
+
? route.match.domains
|
|
291
|
+
: [route.match.domains];
|
|
292
|
+
|
|
293
|
+
// Filter out wildcards and patterns
|
|
294
|
+
return domains.filter(d =>
|
|
295
|
+
!d.includes('*') &&
|
|
296
|
+
!d.includes('{') &&
|
|
297
|
+
d.includes('.')
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Check if certificate is valid
|
|
303
|
+
*/
|
|
304
|
+
private isCertificateValid(cert: ICertificateData): boolean {
|
|
305
|
+
const now = new Date();
|
|
306
|
+
const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
307
|
+
|
|
308
|
+
return cert.expiryDate > expiryThreshold;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Add challenge route to SmartProxy
|
|
314
|
+
*/
|
|
315
|
+
private async addChallengeRoute(): Promise<void> {
|
|
316
|
+
if (!this.updateRoutesCallback) {
|
|
317
|
+
throw new Error('No route update callback set');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!this.challengeRoute) {
|
|
321
|
+
throw new Error('Challenge route not initialized');
|
|
322
|
+
}
|
|
323
|
+
const challengeRoute = this.challengeRoute;
|
|
324
|
+
|
|
325
|
+
const updatedRoutes = [...this.routes, challengeRoute];
|
|
326
|
+
await this.updateRoutesCallback(updatedRoutes);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Remove challenge route from SmartProxy
|
|
331
|
+
*/
|
|
332
|
+
private async removeChallengeRoute(): Promise<void> {
|
|
333
|
+
if (!this.updateRoutesCallback) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
|
|
338
|
+
await this.updateRoutesCallback(filteredRoutes);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Start renewal timer
|
|
343
|
+
*/
|
|
344
|
+
private startRenewalTimer(): void {
|
|
345
|
+
// Check for renewals every 12 hours
|
|
346
|
+
this.renewalTimer = setInterval(() => {
|
|
347
|
+
this.checkAndRenewCertificates();
|
|
348
|
+
}, 12 * 60 * 60 * 1000);
|
|
349
|
+
|
|
350
|
+
// Also do an immediate check
|
|
351
|
+
this.checkAndRenewCertificates();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Check and renew certificates that are expiring
|
|
356
|
+
*/
|
|
357
|
+
private async checkAndRenewCertificates(): Promise<void> {
|
|
358
|
+
for (const route of this.routes) {
|
|
359
|
+
if (route.action.tls?.certificate === 'auto') {
|
|
360
|
+
const routeName = route.name || this.extractDomainsFromRoute(route)[0];
|
|
361
|
+
const cert = await this.certStore.getCertificate(routeName);
|
|
362
|
+
|
|
363
|
+
if (cert && !this.isCertificateValid(cert)) {
|
|
364
|
+
console.log(`Certificate for ${routeName} needs renewal`);
|
|
365
|
+
try {
|
|
366
|
+
await this.provisionCertificate(route);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.error(`Failed to renew certificate for ${routeName}: ${error}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Update certificate status
|
|
377
|
+
*/
|
|
378
|
+
private updateCertStatus(
|
|
379
|
+
routeName: string,
|
|
380
|
+
status: ICertStatus['status'],
|
|
381
|
+
source: ICertStatus['source'],
|
|
382
|
+
certData?: ICertificateData,
|
|
383
|
+
error?: string
|
|
384
|
+
): void {
|
|
385
|
+
this.certStatus.set(routeName, {
|
|
386
|
+
domain: routeName,
|
|
387
|
+
status,
|
|
388
|
+
source,
|
|
389
|
+
expiryDate: certData?.expiryDate,
|
|
390
|
+
issueDate: certData?.issueDate,
|
|
391
|
+
error
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Get certificate status for a route
|
|
397
|
+
*/
|
|
398
|
+
public getCertificateStatus(routeName: string): ICertStatus | undefined {
|
|
399
|
+
return this.certStatus.get(routeName);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Force renewal of a certificate
|
|
404
|
+
*/
|
|
405
|
+
public async renewCertificate(routeName: string): Promise<void> {
|
|
406
|
+
const route = this.routes.find(r => r.name === routeName);
|
|
407
|
+
if (!route) {
|
|
408
|
+
throw new Error(`Route ${routeName} not found`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Remove existing certificate to force renewal
|
|
412
|
+
await this.certStore.deleteCertificate(routeName);
|
|
413
|
+
await this.provisionCertificate(route);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Setup challenge handler integration with SmartProxy routing
|
|
418
|
+
*/
|
|
419
|
+
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
|
|
420
|
+
// Create a challenge route that delegates to SmartAcme's HTTP-01 handler
|
|
421
|
+
const challengeRoute: IRouteConfig = {
|
|
422
|
+
name: 'acme-challenge',
|
|
423
|
+
priority: 1000, // High priority
|
|
424
|
+
match: {
|
|
425
|
+
ports: 80,
|
|
426
|
+
path: '/.well-known/acme-challenge/*'
|
|
427
|
+
},
|
|
428
|
+
action: {
|
|
429
|
+
type: 'static',
|
|
430
|
+
handler: async (context) => {
|
|
431
|
+
// Extract the token from the path
|
|
432
|
+
const token = context.path?.split('/').pop();
|
|
433
|
+
if (!token) {
|
|
434
|
+
return { status: 404, body: 'Not found' };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Create mock request/response objects for SmartAcme
|
|
438
|
+
const mockReq = {
|
|
439
|
+
url: context.path,
|
|
440
|
+
method: 'GET',
|
|
441
|
+
headers: context.headers || {}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
let responseData: any = null;
|
|
445
|
+
const mockRes = {
|
|
446
|
+
statusCode: 200,
|
|
447
|
+
setHeader: (name: string, value: string) => {},
|
|
448
|
+
end: (data: any) => {
|
|
449
|
+
responseData = data;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Use SmartAcme's handler
|
|
454
|
+
const handled = await new Promise<boolean>((resolve) => {
|
|
455
|
+
http01Handler.handleRequest(mockReq as any, mockRes as any, () => {
|
|
456
|
+
resolve(false);
|
|
457
|
+
});
|
|
458
|
+
// Give it a moment to process
|
|
459
|
+
setTimeout(() => resolve(true), 100);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (handled && responseData) {
|
|
463
|
+
return {
|
|
464
|
+
status: mockRes.statusCode,
|
|
465
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
466
|
+
body: responseData
|
|
467
|
+
};
|
|
468
|
+
} else {
|
|
469
|
+
return { status: 404, body: 'Not found' };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// Store the challenge route to add it when needed
|
|
476
|
+
this.challengeRoute = challengeRoute;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Stop certificate manager
|
|
481
|
+
*/
|
|
482
|
+
public async stop(): Promise<void> {
|
|
483
|
+
if (this.renewalTimer) {
|
|
484
|
+
clearInterval(this.renewalTimer);
|
|
485
|
+
this.renewalTimer = null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (this.smartAcme) {
|
|
489
|
+
await this.smartAcme.stop();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Remove any active challenge routes
|
|
493
|
+
if (this.pendingChallenges.size > 0) {
|
|
494
|
+
this.pendingChallenges.clear();
|
|
495
|
+
await this.removeChallengeRoute();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Get ACME options (for recreating after route updates)
|
|
501
|
+
*/
|
|
502
|
+
public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined {
|
|
503
|
+
return this.acmeOptions;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|