@push.rocks/smartproxy 4.2.4 → 4.3.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/classes.networkproxy.d.ts +6 -6
- package/dist_ts/classes.networkproxy.js +56 -39
- package/dist_ts/classes.port80handler.d.ts +109 -13
- package/dist_ts/classes.port80handler.js +399 -118
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.networkproxy.ts +59 -38
- package/ts/classes.port80handler.ts +497 -125
|
@@ -1,9 +1,58 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'http';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
+
* Custom error classes for better error handling
|
|
6
|
+
*/
|
|
7
|
+
export class Port80HandlerError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'Port80HandlerError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CertificateError extends Port80HandlerError {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
public readonly domain: string,
|
|
18
|
+
public readonly isRenewal: boolean = false
|
|
19
|
+
) {
|
|
20
|
+
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
|
21
|
+
this.name = 'CertificateError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ServerError extends Port80HandlerError {
|
|
26
|
+
constructor(message: string, public readonly code?: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ServerError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Domain forwarding configuration
|
|
34
|
+
*/
|
|
35
|
+
export interface IForwardConfig {
|
|
36
|
+
ip: string;
|
|
37
|
+
port: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Domain configuration options
|
|
42
|
+
*/
|
|
43
|
+
export interface IDomainOptions {
|
|
44
|
+
domainName: string;
|
|
45
|
+
sslRedirect: boolean; // if true redirects the request to port 443
|
|
46
|
+
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
|
47
|
+
forward?: IForwardConfig; // forwards all http requests to that target
|
|
48
|
+
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Represents a domain configuration with certificate status information
|
|
5
53
|
*/
|
|
6
54
|
interface IDomainCertificate {
|
|
55
|
+
options: IDomainOptions;
|
|
7
56
|
certObtained: boolean;
|
|
8
57
|
obtainingInProgress: boolean;
|
|
9
58
|
certificate?: string;
|
|
@@ -15,9 +64,9 @@ interface IDomainCertificate {
|
|
|
15
64
|
}
|
|
16
65
|
|
|
17
66
|
/**
|
|
18
|
-
* Configuration options for the
|
|
67
|
+
* Configuration options for the Port80Handler
|
|
19
68
|
*/
|
|
20
|
-
interface
|
|
69
|
+
interface IPort80HandlerOptions {
|
|
21
70
|
port?: number;
|
|
22
71
|
contactEmail?: string;
|
|
23
72
|
useProduction?: boolean;
|
|
@@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions {
|
|
|
29
78
|
/**
|
|
30
79
|
* Certificate data that can be emitted via events or set from outside
|
|
31
80
|
*/
|
|
32
|
-
interface ICertificateData {
|
|
81
|
+
export interface ICertificateData {
|
|
33
82
|
domain: string;
|
|
34
83
|
certificate: string;
|
|
35
84
|
privateKey: string;
|
|
@@ -37,34 +86,54 @@ interface ICertificateData {
|
|
|
37
86
|
}
|
|
38
87
|
|
|
39
88
|
/**
|
|
40
|
-
* Events emitted by the
|
|
89
|
+
* Events emitted by the Port80Handler
|
|
41
90
|
*/
|
|
42
|
-
export enum
|
|
91
|
+
export enum Port80HandlerEvents {
|
|
43
92
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
|
44
93
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
|
45
94
|
CERTIFICATE_FAILED = 'certificate-failed',
|
|
46
95
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
|
47
96
|
MANAGER_STARTED = 'manager-started',
|
|
48
97
|
MANAGER_STOPPED = 'manager-stopped',
|
|
98
|
+
REQUEST_FORWARDED = 'request-forwarded',
|
|
49
99
|
}
|
|
50
100
|
|
|
51
101
|
/**
|
|
52
|
-
*
|
|
102
|
+
* Certificate failure payload type
|
|
53
103
|
*/
|
|
54
|
-
export
|
|
104
|
+
export interface ICertificateFailure {
|
|
105
|
+
domain: string;
|
|
106
|
+
error: string;
|
|
107
|
+
isRenewal: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Certificate expiry payload type
|
|
112
|
+
*/
|
|
113
|
+
export interface ICertificateExpiring {
|
|
114
|
+
domain: string;
|
|
115
|
+
expiryDate: Date;
|
|
116
|
+
daysRemaining: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Port80Handler with ACME certificate management and request forwarding capabilities
|
|
121
|
+
* Now with glob pattern support for domain matching
|
|
122
|
+
*/
|
|
123
|
+
export class Port80Handler extends plugins.EventEmitter {
|
|
55
124
|
private domainCertificates: Map<string, IDomainCertificate>;
|
|
56
125
|
private server: plugins.http.Server | null = null;
|
|
57
126
|
private acmeClient: plugins.acme.Client | null = null;
|
|
58
127
|
private accountKey: string | null = null;
|
|
59
128
|
private renewalTimer: NodeJS.Timeout | null = null;
|
|
60
129
|
private isShuttingDown: boolean = false;
|
|
61
|
-
private options: Required<
|
|
130
|
+
private options: Required<IPort80HandlerOptions>;
|
|
62
131
|
|
|
63
132
|
/**
|
|
64
|
-
* Creates a new
|
|
133
|
+
* Creates a new Port80Handler
|
|
65
134
|
* @param options Configuration options
|
|
66
135
|
*/
|
|
67
|
-
constructor(options:
|
|
136
|
+
constructor(options: IPort80HandlerOptions = {}) {
|
|
68
137
|
super();
|
|
69
138
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
|
70
139
|
|
|
@@ -73,7 +142,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
73
142
|
port: options.port ?? 80,
|
|
74
143
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
|
75
144
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
|
76
|
-
renewThresholdDays: options.renewThresholdDays ??
|
|
145
|
+
renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
|
|
77
146
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
|
78
147
|
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
|
79
148
|
};
|
|
@@ -84,11 +153,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
84
153
|
*/
|
|
85
154
|
public async start(): Promise<void> {
|
|
86
155
|
if (this.server) {
|
|
87
|
-
throw new
|
|
156
|
+
throw new ServerError('Server is already running');
|
|
88
157
|
}
|
|
89
158
|
|
|
90
159
|
if (this.isShuttingDown) {
|
|
91
|
-
throw new
|
|
160
|
+
throw new ServerError('Server is shutting down');
|
|
92
161
|
}
|
|
93
162
|
|
|
94
163
|
return new Promise((resolve, reject) => {
|
|
@@ -97,22 +166,39 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
97
166
|
|
|
98
167
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
|
99
168
|
if (error.code === 'EACCES') {
|
|
100
|
-
reject(new
|
|
169
|
+
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
|
|
101
170
|
} else if (error.code === 'EADDRINUSE') {
|
|
102
|
-
reject(new
|
|
171
|
+
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
|
|
103
172
|
} else {
|
|
104
|
-
reject(error);
|
|
173
|
+
reject(new ServerError(error.message, error.code));
|
|
105
174
|
}
|
|
106
175
|
});
|
|
107
176
|
|
|
108
177
|
this.server.listen(this.options.port, () => {
|
|
109
|
-
console.log(`
|
|
178
|
+
console.log(`Port80Handler is listening on port ${this.options.port}`);
|
|
110
179
|
this.startRenewalTimer();
|
|
111
|
-
this.emit(
|
|
180
|
+
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
|
|
181
|
+
|
|
182
|
+
// Start certificate process for domains with acmeMaintenance enabled
|
|
183
|
+
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
|
184
|
+
// Skip glob patterns for certificate issuance
|
|
185
|
+
if (this.isGlobPattern(domain)) {
|
|
186
|
+
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
|
|
191
|
+
this.obtainCertificate(domain).catch(err => {
|
|
192
|
+
console.error(`Error obtaining initial certificate for ${domain}:`, err);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
112
197
|
resolve();
|
|
113
198
|
});
|
|
114
199
|
} catch (error) {
|
|
115
|
-
|
|
200
|
+
const message = error instanceof Error ? error.message : 'Unknown error starting server';
|
|
201
|
+
reject(new ServerError(message));
|
|
116
202
|
}
|
|
117
203
|
});
|
|
118
204
|
}
|
|
@@ -138,7 +224,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
138
224
|
this.server.close(() => {
|
|
139
225
|
this.server = null;
|
|
140
226
|
this.isShuttingDown = false;
|
|
141
|
-
this.emit(
|
|
227
|
+
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
|
|
142
228
|
resolve();
|
|
143
229
|
});
|
|
144
230
|
} else {
|
|
@@ -149,13 +235,41 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
149
235
|
}
|
|
150
236
|
|
|
151
237
|
/**
|
|
152
|
-
* Adds a domain
|
|
153
|
-
* @param
|
|
238
|
+
* Adds a domain with configuration options
|
|
239
|
+
* @param options Domain configuration options
|
|
154
240
|
*/
|
|
155
|
-
public addDomain(
|
|
156
|
-
if (!
|
|
157
|
-
|
|
158
|
-
|
|
241
|
+
public addDomain(options: IDomainOptions): void {
|
|
242
|
+
if (!options.domainName || typeof options.domainName !== 'string') {
|
|
243
|
+
throw new Port80HandlerError('Invalid domain name');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const domainName = options.domainName;
|
|
247
|
+
|
|
248
|
+
if (!this.domainCertificates.has(domainName)) {
|
|
249
|
+
this.domainCertificates.set(domainName, {
|
|
250
|
+
options,
|
|
251
|
+
certObtained: false,
|
|
252
|
+
obtainingInProgress: false
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
console.log(`Domain added: ${domainName} with configuration:`, {
|
|
256
|
+
sslRedirect: options.sslRedirect,
|
|
257
|
+
acmeMaintenance: options.acmeMaintenance,
|
|
258
|
+
hasForward: !!options.forward,
|
|
259
|
+
hasAcmeForward: !!options.acmeForward
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
|
|
263
|
+
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
|
|
264
|
+
this.obtainCertificate(domainName).catch(err => {
|
|
265
|
+
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Update existing domain with new options
|
|
270
|
+
const existing = this.domainCertificates.get(domainName)!;
|
|
271
|
+
existing.options = options;
|
|
272
|
+
console.log(`Domain ${domainName} configuration updated`);
|
|
159
273
|
}
|
|
160
274
|
}
|
|
161
275
|
|
|
@@ -177,10 +291,30 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
177
291
|
* @param expiryDate Optional expiry date
|
|
178
292
|
*/
|
|
179
293
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
|
294
|
+
if (!domain || !certificate || !privateKey) {
|
|
295
|
+
throw new Port80HandlerError('Domain, certificate and privateKey are required');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Don't allow setting certificates for glob patterns
|
|
299
|
+
if (this.isGlobPattern(domain)) {
|
|
300
|
+
throw new Port80HandlerError('Cannot set certificate for glob pattern domains');
|
|
301
|
+
}
|
|
302
|
+
|
|
180
303
|
let domainInfo = this.domainCertificates.get(domain);
|
|
181
304
|
|
|
182
305
|
if (!domainInfo) {
|
|
183
|
-
|
|
306
|
+
// Create default domain options if not already configured
|
|
307
|
+
const defaultOptions: IDomainOptions = {
|
|
308
|
+
domainName: domain,
|
|
309
|
+
sslRedirect: true,
|
|
310
|
+
acmeMaintenance: true
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
domainInfo = {
|
|
314
|
+
options: defaultOptions,
|
|
315
|
+
certObtained: false,
|
|
316
|
+
obtainingInProgress: false
|
|
317
|
+
};
|
|
184
318
|
this.domainCertificates.set(domain, domainInfo);
|
|
185
319
|
}
|
|
186
320
|
|
|
@@ -192,27 +326,18 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
192
326
|
if (expiryDate) {
|
|
193
327
|
domainInfo.expiryDate = expiryDate;
|
|
194
328
|
} else {
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
// This is a simplistic approach - in a real implementation, use a proper
|
|
198
|
-
// certificate parsing library like node-forge or x509
|
|
199
|
-
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
200
|
-
if (matches && matches[1]) {
|
|
201
|
-
domainInfo.expiryDate = new Date(matches[1]);
|
|
202
|
-
}
|
|
203
|
-
} catch (error) {
|
|
204
|
-
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
|
205
|
-
}
|
|
329
|
+
// Extract expiry date from certificate
|
|
330
|
+
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
|
206
331
|
}
|
|
207
332
|
|
|
208
333
|
console.log(`Certificate set for ${domain}`);
|
|
209
334
|
|
|
210
335
|
// Emit certificate event
|
|
211
|
-
this.emitCertificateEvent(
|
|
336
|
+
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
|
|
212
337
|
domain,
|
|
213
338
|
certificate,
|
|
214
339
|
privateKey,
|
|
215
|
-
expiryDate: domainInfo.expiryDate ||
|
|
340
|
+
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
|
216
341
|
});
|
|
217
342
|
}
|
|
218
343
|
|
|
@@ -221,6 +346,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
221
346
|
* @param domain The domain to get the certificate for
|
|
222
347
|
*/
|
|
223
348
|
public getCertificate(domain: string): ICertificateData | null {
|
|
349
|
+
// Can't get certificates for glob patterns
|
|
350
|
+
if (this.isGlobPattern(domain)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
224
354
|
const domainInfo = this.domainCertificates.get(domain);
|
|
225
355
|
|
|
226
356
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
|
@@ -231,10 +361,69 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
231
361
|
domain,
|
|
232
362
|
certificate: domainInfo.certificate,
|
|
233
363
|
privateKey: domainInfo.privateKey,
|
|
234
|
-
expiryDate: domainInfo.expiryDate ||
|
|
364
|
+
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
|
235
365
|
};
|
|
236
366
|
}
|
|
237
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Check if a domain is a glob pattern
|
|
370
|
+
* @param domain Domain to check
|
|
371
|
+
* @returns True if the domain is a glob pattern
|
|
372
|
+
*/
|
|
373
|
+
private isGlobPattern(domain: string): boolean {
|
|
374
|
+
return domain.includes('*');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get domain info for a specific domain, using glob pattern matching if needed
|
|
379
|
+
* @param requestDomain The actual domain from the request
|
|
380
|
+
* @returns The domain info or null if not found
|
|
381
|
+
*/
|
|
382
|
+
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
|
|
383
|
+
// Try direct match first
|
|
384
|
+
if (this.domainCertificates.has(requestDomain)) {
|
|
385
|
+
return {
|
|
386
|
+
domainInfo: this.domainCertificates.get(requestDomain)!,
|
|
387
|
+
pattern: requestDomain
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Then try glob patterns
|
|
392
|
+
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
|
|
393
|
+
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
|
|
394
|
+
return { domainInfo, pattern };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Check if a domain matches a glob pattern
|
|
403
|
+
* @param domain The domain to check
|
|
404
|
+
* @param pattern The pattern to match against
|
|
405
|
+
* @returns True if the domain matches the pattern
|
|
406
|
+
*/
|
|
407
|
+
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
408
|
+
// Handle different glob pattern styles
|
|
409
|
+
if (pattern.startsWith('*.')) {
|
|
410
|
+
// *.example.com matches any subdomain
|
|
411
|
+
const suffix = pattern.substring(2);
|
|
412
|
+
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
|
|
413
|
+
} else if (pattern.endsWith('.*')) {
|
|
414
|
+
// example.* matches any TLD
|
|
415
|
+
const prefix = pattern.substring(0, pattern.length - 2);
|
|
416
|
+
const domainParts = domain.split('.');
|
|
417
|
+
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
|
|
418
|
+
} else if (pattern === '*') {
|
|
419
|
+
// Wildcard matches everything
|
|
420
|
+
return true;
|
|
421
|
+
} else {
|
|
422
|
+
// Exact match (shouldn't reach here as we check exact matches first)
|
|
423
|
+
return domain === pattern;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
238
427
|
/**
|
|
239
428
|
* Lazy initialization of the ACME client
|
|
240
429
|
* @returns An ACME client instance
|
|
@@ -244,23 +433,28 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
244
433
|
return this.acmeClient;
|
|
245
434
|
}
|
|
246
435
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
436
|
+
try {
|
|
437
|
+
// Generate a new account key
|
|
438
|
+
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
|
439
|
+
|
|
440
|
+
this.acmeClient = new plugins.acme.Client({
|
|
441
|
+
directoryUrl: this.options.useProduction
|
|
442
|
+
? plugins.acme.directory.letsencrypt.production
|
|
443
|
+
: plugins.acme.directory.letsencrypt.staging,
|
|
444
|
+
accountKey: this.accountKey,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// Create a new account
|
|
448
|
+
await this.acmeClient.createAccount({
|
|
449
|
+
termsOfServiceAgreed: true,
|
|
450
|
+
contact: [`mailto:${this.options.contactEmail}`],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return this.acmeClient;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
|
|
456
|
+
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
|
|
457
|
+
}
|
|
264
458
|
}
|
|
265
459
|
|
|
266
460
|
/**
|
|
@@ -279,22 +473,42 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
279
473
|
// Extract domain (ignoring any port in the Host header)
|
|
280
474
|
const domain = hostHeader.split(':')[0];
|
|
281
475
|
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (!this.domainCertificates.has(domain)) {
|
|
476
|
+
// Get domain config, using glob pattern matching if needed
|
|
477
|
+
const domainMatch = this.getDomainInfoForRequest(domain);
|
|
478
|
+
|
|
479
|
+
if (!domainMatch) {
|
|
289
480
|
res.statusCode = 404;
|
|
290
481
|
res.end('Domain not configured');
|
|
291
482
|
return;
|
|
292
483
|
}
|
|
293
484
|
|
|
294
|
-
const domainInfo =
|
|
485
|
+
const { domainInfo, pattern } = domainMatch;
|
|
486
|
+
const options = domainInfo.options;
|
|
295
487
|
|
|
296
|
-
// If
|
|
297
|
-
if (
|
|
488
|
+
// If the request is for an ACME HTTP-01 challenge, handle it
|
|
489
|
+
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) {
|
|
490
|
+
// Check if we should forward ACME requests
|
|
491
|
+
if (options.acmeForward) {
|
|
492
|
+
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Only handle ACME challenges for non-glob patterns
|
|
497
|
+
if (!this.isGlobPattern(pattern)) {
|
|
498
|
+
this.handleAcmeChallenge(req, res, domain);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Check if we should forward non-ACME requests
|
|
504
|
+
if (options.forward) {
|
|
505
|
+
this.forwardRequest(req, res, options.forward, 'HTTP');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
|
|
510
|
+
// (Skip for glob patterns as they won't have certificates)
|
|
511
|
+
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
|
|
298
512
|
const httpsPort = this.options.httpsRedirectPort;
|
|
299
513
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
|
300
514
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
|
@@ -302,17 +516,94 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
302
516
|
res.statusCode = 301;
|
|
303
517
|
res.setHeader('Location', redirectUrl);
|
|
304
518
|
res.end(`Redirecting to ${redirectUrl}`);
|
|
305
|
-
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Handle case where certificate maintenance is enabled but not yet obtained
|
|
523
|
+
// (Skip for glob patterns as they can't have certificates)
|
|
524
|
+
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
|
|
306
525
|
// Trigger certificate issuance if not already running
|
|
307
526
|
if (!domainInfo.obtainingInProgress) {
|
|
308
527
|
this.obtainCertificate(domain).catch(err => {
|
|
309
|
-
|
|
528
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
529
|
+
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
|
530
|
+
domain,
|
|
531
|
+
error: errorMessage,
|
|
532
|
+
isRenewal: false
|
|
533
|
+
});
|
|
310
534
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
|
311
535
|
});
|
|
312
536
|
}
|
|
313
537
|
|
|
314
538
|
res.statusCode = 503;
|
|
315
539
|
res.end('Certificate issuance in progress, please try again later.');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Default response for unhandled request
|
|
544
|
+
res.statusCode = 404;
|
|
545
|
+
res.end('No handlers configured for this request');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Forwards an HTTP request to the specified target
|
|
550
|
+
* @param req The original request
|
|
551
|
+
* @param res The response object
|
|
552
|
+
* @param target The forwarding target (IP and port)
|
|
553
|
+
* @param requestType Type of request for logging
|
|
554
|
+
*/
|
|
555
|
+
private forwardRequest(
|
|
556
|
+
req: plugins.http.IncomingMessage,
|
|
557
|
+
res: plugins.http.ServerResponse,
|
|
558
|
+
target: IForwardConfig,
|
|
559
|
+
requestType: string
|
|
560
|
+
): void {
|
|
561
|
+
const options = {
|
|
562
|
+
hostname: target.ip,
|
|
563
|
+
port: target.port,
|
|
564
|
+
path: req.url,
|
|
565
|
+
method: req.method,
|
|
566
|
+
headers: { ...req.headers }
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
|
570
|
+
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
|
|
571
|
+
|
|
572
|
+
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
|
573
|
+
// Copy status code
|
|
574
|
+
res.statusCode = proxyRes.statusCode || 500;
|
|
575
|
+
|
|
576
|
+
// Copy headers
|
|
577
|
+
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
578
|
+
if (value) res.setHeader(key, value);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Pipe response data
|
|
582
|
+
proxyRes.pipe(res);
|
|
583
|
+
|
|
584
|
+
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
|
|
585
|
+
domain,
|
|
586
|
+
requestType,
|
|
587
|
+
target: `${target.ip}:${target.port}`,
|
|
588
|
+
statusCode: proxyRes.statusCode
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
proxyReq.on('error', (error) => {
|
|
593
|
+
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
|
|
594
|
+
if (!res.headersSent) {
|
|
595
|
+
res.statusCode = 502;
|
|
596
|
+
res.end(`Proxy error: ${error.message}`);
|
|
597
|
+
} else {
|
|
598
|
+
res.end();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Pipe original request to proxy request
|
|
603
|
+
if (req.readable) {
|
|
604
|
+
req.pipe(proxyReq);
|
|
605
|
+
} else {
|
|
606
|
+
proxyReq.end();
|
|
316
607
|
}
|
|
317
608
|
}
|
|
318
609
|
|
|
@@ -351,10 +642,21 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
351
642
|
* @param isRenewal Whether this is a renewal attempt
|
|
352
643
|
*/
|
|
353
644
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
|
645
|
+
// Don't allow certificate issuance for glob patterns
|
|
646
|
+
if (this.isGlobPattern(domain)) {
|
|
647
|
+
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
|
648
|
+
}
|
|
649
|
+
|
|
354
650
|
// Get the domain info
|
|
355
651
|
const domainInfo = this.domainCertificates.get(domain);
|
|
356
652
|
if (!domainInfo) {
|
|
357
|
-
throw new
|
|
653
|
+
throw new CertificateError('Domain not found', domain, isRenewal);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Verify that acmeMaintenance is enabled
|
|
657
|
+
if (!domainInfo.options.acmeMaintenance) {
|
|
658
|
+
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
|
659
|
+
return;
|
|
358
660
|
}
|
|
359
661
|
|
|
360
662
|
// Prevent concurrent certificate issuance
|
|
@@ -377,40 +679,8 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
377
679
|
// Get the authorizations for the order
|
|
378
680
|
const authorizations = await client.getAuthorizations(order);
|
|
379
681
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (!challenge) {
|
|
383
|
-
throw new Error('HTTP-01 challenge not found');
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Get the key authorization for the challenge
|
|
387
|
-
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
388
|
-
|
|
389
|
-
// Store the challenge data
|
|
390
|
-
domainInfo.challengeToken = challenge.token;
|
|
391
|
-
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
|
392
|
-
|
|
393
|
-
// ACME client type definition workaround - use compatible approach
|
|
394
|
-
// First check if challenge verification is needed
|
|
395
|
-
const authzUrl = authz.url;
|
|
396
|
-
|
|
397
|
-
try {
|
|
398
|
-
// Check if authzUrl exists and perform verification
|
|
399
|
-
if (authzUrl) {
|
|
400
|
-
await client.verifyChallenge(authz, challenge);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Complete the challenge
|
|
404
|
-
await client.completeChallenge(challenge);
|
|
405
|
-
|
|
406
|
-
// Wait for validation
|
|
407
|
-
await client.waitForValidStatus(challenge);
|
|
408
|
-
console.log(`HTTP-01 challenge completed for ${domain}`);
|
|
409
|
-
} catch (error) {
|
|
410
|
-
console.error(`Challenge error for ${domain}:`, error);
|
|
411
|
-
throw error;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
682
|
+
// Process each authorization
|
|
683
|
+
await this.processAuthorizations(client, domain, authorizations);
|
|
414
684
|
|
|
415
685
|
// Generate a CSR and private key
|
|
416
686
|
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
|
@@ -436,28 +706,20 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
436
706
|
delete domainInfo.challengeKeyAuthorization;
|
|
437
707
|
|
|
438
708
|
// Extract expiry date from certificate
|
|
439
|
-
|
|
440
|
-
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
441
|
-
if (matches && matches[1]) {
|
|
442
|
-
domainInfo.expiryDate = new Date(matches[1]);
|
|
443
|
-
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
|
|
444
|
-
}
|
|
445
|
-
} catch (error) {
|
|
446
|
-
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
|
447
|
-
}
|
|
709
|
+
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
|
448
710
|
|
|
449
711
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
|
450
712
|
|
|
451
713
|
// Emit the appropriate event
|
|
452
714
|
const eventType = isRenewal
|
|
453
|
-
?
|
|
454
|
-
:
|
|
715
|
+
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
|
716
|
+
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
|
455
717
|
|
|
456
718
|
this.emitCertificateEvent(eventType, {
|
|
457
719
|
domain,
|
|
458
720
|
certificate,
|
|
459
721
|
privateKey,
|
|
460
|
-
expiryDate: domainInfo.expiryDate ||
|
|
722
|
+
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
|
461
723
|
});
|
|
462
724
|
|
|
463
725
|
} catch (error: any) {
|
|
@@ -473,17 +735,76 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
473
735
|
}
|
|
474
736
|
|
|
475
737
|
// Emit failure event
|
|
476
|
-
this.emit(
|
|
738
|
+
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
|
477
739
|
domain,
|
|
478
740
|
error: error.message || 'Unknown error',
|
|
479
741
|
isRenewal
|
|
480
|
-
});
|
|
742
|
+
} as ICertificateFailure);
|
|
743
|
+
|
|
744
|
+
throw new CertificateError(
|
|
745
|
+
error.message || 'Certificate issuance failed',
|
|
746
|
+
domain,
|
|
747
|
+
isRenewal
|
|
748
|
+
);
|
|
481
749
|
} finally {
|
|
482
750
|
// Reset flag whether successful or not
|
|
483
751
|
domainInfo.obtainingInProgress = false;
|
|
484
752
|
}
|
|
485
753
|
}
|
|
486
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Process ACME authorizations by verifying and completing challenges
|
|
757
|
+
* @param client ACME client
|
|
758
|
+
* @param domain Domain name
|
|
759
|
+
* @param authorizations Authorizations to process
|
|
760
|
+
*/
|
|
761
|
+
private async processAuthorizations(
|
|
762
|
+
client: plugins.acme.Client,
|
|
763
|
+
domain: string,
|
|
764
|
+
authorizations: plugins.acme.Authorization[]
|
|
765
|
+
): Promise<void> {
|
|
766
|
+
const domainInfo = this.domainCertificates.get(domain);
|
|
767
|
+
if (!domainInfo) {
|
|
768
|
+
throw new CertificateError('Domain not found during authorization', domain);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
for (const authz of authorizations) {
|
|
772
|
+
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
|
773
|
+
if (!challenge) {
|
|
774
|
+
throw new CertificateError('HTTP-01 challenge not found', domain);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Get the key authorization for the challenge
|
|
778
|
+
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
779
|
+
|
|
780
|
+
// Store the challenge data
|
|
781
|
+
domainInfo.challengeToken = challenge.token;
|
|
782
|
+
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
|
783
|
+
|
|
784
|
+
// ACME client type definition workaround - use compatible approach
|
|
785
|
+
// First check if challenge verification is needed
|
|
786
|
+
const authzUrl = authz.url;
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
// Check if authzUrl exists and perform verification
|
|
790
|
+
if (authzUrl) {
|
|
791
|
+
await client.verifyChallenge(authz, challenge);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Complete the challenge
|
|
795
|
+
await client.completeChallenge(challenge);
|
|
796
|
+
|
|
797
|
+
// Wait for validation
|
|
798
|
+
await client.waitForValidStatus(challenge);
|
|
799
|
+
console.log(`HTTP-01 challenge completed for ${domain}`);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
|
|
802
|
+
console.error(`Challenge error for ${domain}:`, error);
|
|
803
|
+
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
487
808
|
/**
|
|
488
809
|
* Starts the certificate renewal timer
|
|
489
810
|
*/
|
|
@@ -519,6 +840,16 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
519
840
|
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
|
|
520
841
|
|
|
521
842
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
|
843
|
+
// Skip glob patterns
|
|
844
|
+
if (this.isGlobPattern(domain)) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Skip domains with acmeMaintenance disabled
|
|
849
|
+
if (!domainInfo.options.acmeMaintenance) {
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
522
853
|
// Skip domains without certificates or already in renewal
|
|
523
854
|
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
|
|
524
855
|
continue;
|
|
@@ -534,26 +865,67 @@ export class AcmeCertManager extends plugins.EventEmitter {
|
|
|
534
865
|
// Check if certificate is near expiry
|
|
535
866
|
if (timeUntilExpiry <= renewThresholdMs) {
|
|
536
867
|
console.log(`Certificate for ${domain} expires soon, renewing...`);
|
|
537
|
-
|
|
868
|
+
|
|
869
|
+
const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000));
|
|
870
|
+
|
|
871
|
+
this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, {
|
|
538
872
|
domain,
|
|
539
873
|
expiryDate: domainInfo.expiryDate,
|
|
540
|
-
daysRemaining
|
|
541
|
-
});
|
|
874
|
+
daysRemaining
|
|
875
|
+
} as ICertificateExpiring);
|
|
542
876
|
|
|
543
877
|
// Start renewal process
|
|
544
878
|
this.obtainCertificate(domain, true).catch(err => {
|
|
545
|
-
|
|
879
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
880
|
+
console.error(`Error renewing certificate for ${domain}:`, errorMessage);
|
|
546
881
|
});
|
|
547
882
|
}
|
|
548
883
|
}
|
|
549
884
|
}
|
|
550
885
|
|
|
886
|
+
/**
|
|
887
|
+
* Extract expiry date from certificate using a more robust approach
|
|
888
|
+
* @param certificate Certificate PEM string
|
|
889
|
+
* @param domain Domain for logging
|
|
890
|
+
* @returns Extracted expiry date or default
|
|
891
|
+
*/
|
|
892
|
+
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
|
|
893
|
+
try {
|
|
894
|
+
// This is still using regex, but in a real implementation you would use
|
|
895
|
+
// a library like node-forge or x509 to properly parse the certificate
|
|
896
|
+
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
897
|
+
if (matches && matches[1]) {
|
|
898
|
+
const expiryDate = new Date(matches[1]);
|
|
899
|
+
|
|
900
|
+
// Validate that we got a valid date
|
|
901
|
+
if (!isNaN(expiryDate.getTime())) {
|
|
902
|
+
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
|
|
903
|
+
return expiryDate;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
|
|
908
|
+
return this.getDefaultExpiryDate();
|
|
909
|
+
} catch (error) {
|
|
910
|
+
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
|
|
911
|
+
return this.getDefaultExpiryDate();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Get a default expiry date (90 days from now)
|
|
917
|
+
* @returns Default expiry date
|
|
918
|
+
*/
|
|
919
|
+
private getDefaultExpiryDate(): Date {
|
|
920
|
+
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
|
|
921
|
+
}
|
|
922
|
+
|
|
551
923
|
/**
|
|
552
924
|
* Emits a certificate event with the certificate data
|
|
553
925
|
* @param eventType The event type to emit
|
|
554
926
|
* @param data The certificate data
|
|
555
927
|
*/
|
|
556
|
-
private emitCertificateEvent(eventType:
|
|
928
|
+
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
|
|
557
929
|
this.emit(eventType, data);
|
|
558
930
|
}
|
|
559
931
|
}
|