@serve.zone/dcrouter 5.5.0 → 6.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_serve/bundle.js +23 -12
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.cert-provision-scheduler.d.ts +49 -0
- package/dist_ts/classes.cert-provision-scheduler.js +104 -0
- package/dist_ts/classes.dcrouter.d.ts +8 -2
- package/dist_ts/classes.dcrouter.js +63 -27
- package/dist_ts/opsserver/handlers/certificate.handler.d.ts +12 -1
- package/dist_ts/opsserver/handlers/certificate.handler.js +119 -35
- package/dist_ts_interfaces/requests/certificate.d.ts +18 -2
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.js +4 -4
- package/dist_ts_web/elements/ops-view-certificates.d.ts +2 -1
- package/dist_ts_web/elements/ops-view-certificates.js +46 -19
- package/package.json +4 -4
- package/readme.md +87 -15
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.cert-provision-scheduler.ts +130 -0
- package/ts/classes.dcrouter.ts +66 -27
- package/ts/opsserver/handlers/certificate.handler.ts +137 -33
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +4 -4
- package/ts_web/elements/ops-view-certificates.ts +43 -18
- package/ts_web/readme.md +9 -0
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { logger } from './logger.js';
|
|
|
14
14
|
// Import storage manager
|
|
15
15
|
import { StorageManager, type IStorageConfig } from './storage/index.js';
|
|
16
16
|
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
|
17
|
+
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
|
|
17
18
|
// Import cache system
|
|
18
19
|
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
|
|
19
20
|
|
|
@@ -184,16 +185,19 @@ export class DcRouter {
|
|
|
184
185
|
public cacheDb?: CacheDb;
|
|
185
186
|
public cacheCleaner?: CacheCleaner;
|
|
186
187
|
|
|
187
|
-
// Certificate status tracking from SmartProxy events
|
|
188
|
+
// Certificate status tracking from SmartProxy events (keyed by domain)
|
|
188
189
|
public certificateStatusMap = new Map<string, {
|
|
189
190
|
status: 'valid' | 'failed';
|
|
190
|
-
|
|
191
|
+
routeNames: string[];
|
|
191
192
|
expiryDate?: string;
|
|
192
193
|
issuedAt?: string;
|
|
193
194
|
source?: string;
|
|
194
195
|
error?: string;
|
|
195
196
|
}>();
|
|
196
197
|
|
|
198
|
+
// Certificate provisioning scheduler with per-domain backoff
|
|
199
|
+
public certProvisionScheduler?: CertProvisionScheduler;
|
|
200
|
+
|
|
197
201
|
// TypedRouter for API endpoints
|
|
198
202
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
199
203
|
|
|
@@ -467,6 +471,9 @@ export class DcRouter {
|
|
|
467
471
|
},
|
|
468
472
|
};
|
|
469
473
|
|
|
474
|
+
// Initialize cert provision scheduler
|
|
475
|
+
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
|
|
476
|
+
|
|
470
477
|
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
|
|
471
478
|
if (challengeHandlers.length > 0) {
|
|
472
479
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
@@ -478,15 +485,25 @@ export class DcRouter {
|
|
|
478
485
|
});
|
|
479
486
|
await this.smartAcme.start();
|
|
480
487
|
|
|
488
|
+
const scheduler = this.certProvisionScheduler;
|
|
481
489
|
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
|
|
490
|
+
// Check backoff before attempting provision
|
|
491
|
+
if (await scheduler.isInBackoff(domain)) {
|
|
492
|
+
const info = await scheduler.getBackoffInfo(domain);
|
|
493
|
+
const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`;
|
|
494
|
+
eventComms.warn(msg);
|
|
495
|
+
throw new Error(msg);
|
|
496
|
+
}
|
|
497
|
+
|
|
482
498
|
try {
|
|
499
|
+
// smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally
|
|
483
500
|
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
|
|
484
501
|
eventComms.setSource('smartacme-dns-01');
|
|
485
502
|
const cert = await this.smartAcme.getCertificateForDomain(domain);
|
|
486
503
|
if (cert.validUntil) {
|
|
487
504
|
eventComms.setExpiryDate(new Date(cert.validUntil));
|
|
488
505
|
}
|
|
489
|
-
|
|
506
|
+
const result = {
|
|
490
507
|
id: cert.id,
|
|
491
508
|
domainName: cert.domainName,
|
|
492
509
|
created: cert.created,
|
|
@@ -495,7 +512,13 @@ export class DcRouter {
|
|
|
495
512
|
publicKey: cert.publicKey,
|
|
496
513
|
csr: cert.csr,
|
|
497
514
|
};
|
|
515
|
+
|
|
516
|
+
// Success — clear any backoff
|
|
517
|
+
await scheduler.clearBackoff(domain);
|
|
518
|
+
return result;
|
|
498
519
|
} catch (err) {
|
|
520
|
+
// Record failure for backoff tracking
|
|
521
|
+
await scheduler.recordFailure(domain, err.message);
|
|
499
522
|
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
|
|
500
523
|
return 'http01';
|
|
501
524
|
}
|
|
@@ -519,39 +542,34 @@ export class DcRouter {
|
|
|
519
542
|
});
|
|
520
543
|
|
|
521
544
|
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
|
545
|
+
// Events are keyed by domain for domain-centric certificate tracking
|
|
522
546
|
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
|
523
547
|
console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
});
|
|
531
|
-
}
|
|
548
|
+
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
549
|
+
this.certificateStatusMap.set(event.domain, {
|
|
550
|
+
status: 'valid', routeNames,
|
|
551
|
+
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
|
552
|
+
source: event.source,
|
|
553
|
+
});
|
|
532
554
|
});
|
|
533
555
|
|
|
534
556
|
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
|
535
557
|
console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
});
|
|
543
|
-
}
|
|
558
|
+
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
559
|
+
this.certificateStatusMap.set(event.domain, {
|
|
560
|
+
status: 'valid', routeNames,
|
|
561
|
+
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
|
562
|
+
source: event.source,
|
|
563
|
+
});
|
|
544
564
|
});
|
|
545
565
|
|
|
546
566
|
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
|
547
567
|
console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error);
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
});
|
|
554
|
-
}
|
|
568
|
+
const routeNames = this.findRouteNamesForDomain(event.domain);
|
|
569
|
+
this.certificateStatusMap.set(event.domain, {
|
|
570
|
+
status: 'failed', routeNames, error: event.error,
|
|
571
|
+
source: event.source,
|
|
572
|
+
});
|
|
555
573
|
});
|
|
556
574
|
|
|
557
575
|
// Start SmartProxy
|
|
@@ -724,7 +742,7 @@ export class DcRouter {
|
|
|
724
742
|
}
|
|
725
743
|
|
|
726
744
|
/**
|
|
727
|
-
* Find the route name that matches a given domain
|
|
745
|
+
* Find the first route name that matches a given domain
|
|
728
746
|
*/
|
|
729
747
|
private findRouteNameForDomain(domain: string): string | undefined {
|
|
730
748
|
if (!this.smartProxy) return undefined;
|
|
@@ -740,6 +758,27 @@ export class DcRouter {
|
|
|
740
758
|
return undefined;
|
|
741
759
|
}
|
|
742
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Find ALL route names that match a given domain
|
|
763
|
+
*/
|
|
764
|
+
public findRouteNamesForDomain(domain: string): string[] {
|
|
765
|
+
if (!this.smartProxy) return [];
|
|
766
|
+
const names: string[] = [];
|
|
767
|
+
for (const route of this.smartProxy.routeManager.getRoutes()) {
|
|
768
|
+
if (!route.match.domains || !route.name) continue;
|
|
769
|
+
const routeDomains = Array.isArray(route.match.domains)
|
|
770
|
+
? route.match.domains
|
|
771
|
+
: [route.match.domains];
|
|
772
|
+
for (const pattern of routeDomains) {
|
|
773
|
+
if (this.isDomainMatch(domain, pattern)) {
|
|
774
|
+
names.push(route.name);
|
|
775
|
+
break; // This route already matched, no need to check other patterns
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return names;
|
|
780
|
+
}
|
|
781
|
+
|
|
743
782
|
public async stop() {
|
|
744
783
|
console.log('Stopping DcRouter services...');
|
|
745
784
|
|
|
@@ -23,24 +23,45 @@ export class CertificateHandler {
|
|
|
23
23
|
)
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
//
|
|
26
|
+
// Legacy route-based reprovision (backward compat)
|
|
27
27
|
this.typedrouter.addTypedHandler(
|
|
28
28
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
|
29
29
|
'reprovisionCertificate',
|
|
30
30
|
async (dataArg) => {
|
|
31
|
-
return this.
|
|
31
|
+
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Domain-based reprovision (preferred)
|
|
37
|
+
this.typedrouter.addTypedHandler(
|
|
38
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
|
39
|
+
'reprovisionCertificateDomain',
|
|
40
|
+
async (dataArg) => {
|
|
41
|
+
return this.reprovisionCertificateDomain(dataArg.domain);
|
|
32
42
|
}
|
|
33
43
|
)
|
|
34
44
|
);
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Build domain-centric certificate overview.
|
|
49
|
+
* Instead of one row per route, we produce one row per unique domain.
|
|
50
|
+
*/
|
|
37
51
|
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
|
|
38
52
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
39
53
|
const smartProxy = dcRouter.smartProxy;
|
|
40
54
|
if (!smartProxy) return [];
|
|
41
55
|
|
|
42
56
|
const routes = smartProxy.routeManager.getRoutes();
|
|
43
|
-
|
|
57
|
+
|
|
58
|
+
// Phase 1: Collect unique domains with their associated route info
|
|
59
|
+
const domainMap = new Map<string, {
|
|
60
|
+
routeNames: string[];
|
|
61
|
+
source: interfaces.requests.TCertificateSource;
|
|
62
|
+
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
|
63
|
+
canReprovision: boolean;
|
|
64
|
+
}>();
|
|
44
65
|
|
|
45
66
|
for (const route of routes) {
|
|
46
67
|
if (!route.name) continue;
|
|
@@ -58,7 +79,6 @@ export class CertificateHandler {
|
|
|
58
79
|
// Determine source
|
|
59
80
|
let source: interfaces.requests.TCertificateSource = 'none';
|
|
60
81
|
if (tls.certificate === 'auto') {
|
|
61
|
-
// Check if a certProvisionFunction is configured
|
|
62
82
|
if ((smartProxy.settings as any).certProvisionFunction) {
|
|
63
83
|
source = 'provision-function';
|
|
64
84
|
} else {
|
|
@@ -68,15 +88,44 @@ export class CertificateHandler {
|
|
|
68
88
|
source = 'static';
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
const canReprovision = source === 'acme' || source === 'provision-function';
|
|
92
|
+
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
|
93
|
+
|
|
94
|
+
for (const domain of routeDomains) {
|
|
95
|
+
const existing = domainMap.get(domain);
|
|
96
|
+
if (existing) {
|
|
97
|
+
// Add this route name to the existing domain entry
|
|
98
|
+
if (!existing.routeNames.includes(route.name)) {
|
|
99
|
+
existing.routeNames.push(route.name);
|
|
100
|
+
}
|
|
101
|
+
// Upgrade source if more specific
|
|
102
|
+
if (existing.source === 'none' && source !== 'none') {
|
|
103
|
+
existing.source = source;
|
|
104
|
+
existing.canReprovision = canReprovision;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
domainMap.set(domain, {
|
|
108
|
+
routeNames: [route.name],
|
|
109
|
+
source,
|
|
110
|
+
tlsMode,
|
|
111
|
+
canReprovision,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Phase 2: Resolve status for each unique domain
|
|
118
|
+
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
|
119
|
+
|
|
120
|
+
for (const [domain, info] of domainMap) {
|
|
72
121
|
let status: interfaces.requests.TCertificateStatus = 'unknown';
|
|
73
122
|
let expiryDate: string | undefined;
|
|
74
123
|
let issuedAt: string | undefined;
|
|
75
124
|
let issuer: string | undefined;
|
|
76
125
|
let error: string | undefined;
|
|
77
126
|
|
|
78
|
-
// Check event-based status from
|
|
79
|
-
const eventStatus = dcRouter.certificateStatusMap.get(
|
|
127
|
+
// Check event-based status from certificateStatusMap (now keyed by domain)
|
|
128
|
+
const eventStatus = dcRouter.certificateStatusMap.get(domain);
|
|
80
129
|
if (eventStatus) {
|
|
81
130
|
status = eventStatus.status;
|
|
82
131
|
expiryDate = eventStatus.expiryDate;
|
|
@@ -87,10 +136,10 @@ export class CertificateHandler {
|
|
|
87
136
|
}
|
|
88
137
|
}
|
|
89
138
|
|
|
90
|
-
// Try
|
|
91
|
-
if (status === 'unknown') {
|
|
139
|
+
// Try SmartProxy certificate status if no event data
|
|
140
|
+
if (status === 'unknown' && info.routeNames.length > 0) {
|
|
92
141
|
try {
|
|
93
|
-
const rustStatus = await smartProxy.getCertificateStatus(
|
|
142
|
+
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
|
94
143
|
if (rustStatus) {
|
|
95
144
|
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
|
96
145
|
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
|
@@ -105,22 +154,19 @@ export class CertificateHandler {
|
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
// Check persisted cert data from StorageManager
|
|
108
|
-
if (status === 'unknown'
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (certData
|
|
114
|
-
|
|
115
|
-
if (certData.created) {
|
|
116
|
-
issuedAt = new Date(certData.created).toISOString();
|
|
117
|
-
}
|
|
118
|
-
issuer = 'smartacme-dns-01';
|
|
157
|
+
if (status === 'unknown') {
|
|
158
|
+
const cleanDomain = domain.replace(/^\*\.?/, '');
|
|
159
|
+
const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
|
160
|
+
if (certData?.validUntil) {
|
|
161
|
+
expiryDate = new Date(certData.validUntil).toISOString();
|
|
162
|
+
if (certData.created) {
|
|
163
|
+
issuedAt = new Date(certData.created).toISOString();
|
|
119
164
|
}
|
|
165
|
+
issuer = 'smartacme-dns-01';
|
|
120
166
|
}
|
|
121
167
|
}
|
|
122
168
|
|
|
123
|
-
// Compute status from expiry date
|
|
169
|
+
// Compute status from expiry date
|
|
124
170
|
if (expiryDate && (status === 'valid' || status === 'unknown')) {
|
|
125
171
|
const expiry = new Date(expiryDate);
|
|
126
172
|
const now = new Date();
|
|
@@ -136,28 +182,36 @@ export class CertificateHandler {
|
|
|
136
182
|
}
|
|
137
183
|
|
|
138
184
|
// Static certs with no other info default to 'valid'
|
|
139
|
-
if (source === 'static' && status === 'unknown') {
|
|
185
|
+
if (info.source === 'static' && status === 'unknown') {
|
|
140
186
|
status = 'valid';
|
|
141
187
|
}
|
|
142
188
|
|
|
143
189
|
// ACME/provision-function routes with no cert data are still provisioning
|
|
144
|
-
if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) {
|
|
190
|
+
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
|
|
145
191
|
status = 'provisioning';
|
|
146
192
|
}
|
|
147
193
|
|
|
148
|
-
|
|
194
|
+
// Phase 3: Attach backoff info
|
|
195
|
+
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
|
|
196
|
+
if (dcRouter.certProvisionScheduler) {
|
|
197
|
+
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
|
|
198
|
+
if (bi) {
|
|
199
|
+
backoffInfo = bi;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
149
202
|
|
|
150
203
|
certificates.push({
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
domain,
|
|
205
|
+
routeNames: info.routeNames,
|
|
153
206
|
status,
|
|
154
|
-
source,
|
|
155
|
-
tlsMode:
|
|
207
|
+
source: info.source,
|
|
208
|
+
tlsMode: info.tlsMode,
|
|
156
209
|
expiryDate,
|
|
157
210
|
issuer,
|
|
158
211
|
issuedAt,
|
|
159
212
|
error,
|
|
160
|
-
canReprovision,
|
|
213
|
+
canReprovision: info.canReprovision,
|
|
214
|
+
backoffInfo,
|
|
161
215
|
});
|
|
162
216
|
}
|
|
163
217
|
|
|
@@ -187,7 +241,10 @@ export class CertificateHandler {
|
|
|
187
241
|
return summary;
|
|
188
242
|
}
|
|
189
243
|
|
|
190
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Legacy route-based reprovisioning
|
|
246
|
+
*/
|
|
247
|
+
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
|
191
248
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
192
249
|
const smartProxy = dcRouter.smartProxy;
|
|
193
250
|
|
|
@@ -197,11 +254,58 @@ export class CertificateHandler {
|
|
|
197
254
|
|
|
198
255
|
try {
|
|
199
256
|
await smartProxy.provisionCertificate(routeName);
|
|
200
|
-
// Clear event-based status
|
|
201
|
-
dcRouter.certificateStatusMap
|
|
257
|
+
// Clear event-based status for domains in this route
|
|
258
|
+
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
|
259
|
+
if (entry.routeNames.includes(routeName)) {
|
|
260
|
+
dcRouter.certificateStatusMap.delete(domain);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
202
263
|
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
|
203
264
|
} catch (err) {
|
|
204
265
|
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
|
205
266
|
}
|
|
206
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
|
271
|
+
*/
|
|
272
|
+
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
|
273
|
+
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
274
|
+
const smartProxy = dcRouter.smartProxy;
|
|
275
|
+
|
|
276
|
+
if (!smartProxy) {
|
|
277
|
+
return { success: false, message: 'SmartProxy is not running' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Clear backoff for this domain (user override)
|
|
281
|
+
if (dcRouter.certProvisionScheduler) {
|
|
282
|
+
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Clear status map entry so it gets refreshed
|
|
286
|
+
dcRouter.certificateStatusMap.delete(domain);
|
|
287
|
+
|
|
288
|
+
// Try to provision via SmartAcme directly
|
|
289
|
+
if (dcRouter.smartAcme) {
|
|
290
|
+
try {
|
|
291
|
+
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
|
292
|
+
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
|
293
|
+
} catch (err) {
|
|
294
|
+
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fallback: try provisioning via the first matching route
|
|
299
|
+
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
|
300
|
+
if (routeNames.length > 0) {
|
|
301
|
+
try {
|
|
302
|
+
await smartProxy.provisionCertificate(routeNames[0]);
|
|
303
|
+
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { success: false, message: `No routes found for domain '${domain}'` };
|
|
310
|
+
}
|
|
207
311
|
}
|
package/ts_web/appstate.ts
CHANGED
|
@@ -719,18 +719,18 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
|
|
|
719
719
|
});
|
|
720
720
|
|
|
721
721
|
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
|
722
|
-
async (statePartArg,
|
|
722
|
+
async (statePartArg, domain) => {
|
|
723
723
|
const context = getActionContext();
|
|
724
724
|
const currentState = statePartArg.getState();
|
|
725
725
|
|
|
726
726
|
try {
|
|
727
727
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
728
|
-
interfaces.requests.
|
|
729
|
-
>('/typedrequest', '
|
|
728
|
+
interfaces.requests.IReq_ReprovisionCertificateDomain
|
|
729
|
+
>('/typedrequest', 'reprovisionCertificateDomain');
|
|
730
730
|
|
|
731
731
|
await request.fire({
|
|
732
732
|
identity: context.identity,
|
|
733
|
-
|
|
733
|
+
domain,
|
|
734
734
|
});
|
|
735
735
|
|
|
736
736
|
// Re-fetch overview after reprovisioning
|
|
@@ -94,13 +94,13 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
94
94
|
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
.
|
|
97
|
+
.routePills {
|
|
98
98
|
display: flex;
|
|
99
99
|
flex-wrap: wrap;
|
|
100
100
|
gap: 4px;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
.
|
|
103
|
+
.routePill {
|
|
104
104
|
display: inline-flex;
|
|
105
105
|
align-items: center;
|
|
106
106
|
padding: 2px 8px;
|
|
@@ -125,6 +125,17 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
125
125
|
white-space: nowrap;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
.backoffIndicator {
|
|
129
|
+
display: inline-flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 4px;
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
|
134
|
+
padding: 2px 6px;
|
|
135
|
+
border-radius: 4px;
|
|
136
|
+
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
.expiryInfo {
|
|
129
140
|
font-size: 12px;
|
|
130
141
|
}
|
|
@@ -218,14 +229,16 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
218
229
|
<dees-table
|
|
219
230
|
.data=${this.certState.certificates}
|
|
220
231
|
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
Domain: cert.domain,
|
|
233
|
+
Routes: this.renderRoutePills(cert.routeNames),
|
|
223
234
|
Status: this.renderStatusBadge(cert.status),
|
|
224
235
|
Source: this.renderSourceBadge(cert.source),
|
|
225
236
|
Expires: this.renderExpiry(cert.expiryDate),
|
|
226
|
-
Error: cert.
|
|
227
|
-
? html`<span class="
|
|
228
|
-
:
|
|
237
|
+
Error: cert.backoffInfo
|
|
238
|
+
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
|
|
239
|
+
: cert.error
|
|
240
|
+
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
|
241
|
+
: '',
|
|
229
242
|
})}
|
|
230
243
|
.dataActions=${[
|
|
231
244
|
{
|
|
@@ -245,11 +258,11 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
245
258
|
}
|
|
246
259
|
await appstate.certificateStatePart.dispatchAction(
|
|
247
260
|
appstate.reprovisionCertificateAction,
|
|
248
|
-
cert.
|
|
261
|
+
cert.domain,
|
|
249
262
|
);
|
|
250
263
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
|
251
264
|
DeesToast.show({
|
|
252
|
-
message: `Reprovisioning triggered for ${cert.
|
|
265
|
+
message: `Reprovisioning triggered for ${cert.domain}`,
|
|
253
266
|
type: 'success',
|
|
254
267
|
duration: 3000,
|
|
255
268
|
});
|
|
@@ -263,7 +276,7 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
263
276
|
const cert = actionData.item;
|
|
264
277
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
265
278
|
await DeesModal.createAndShow({
|
|
266
|
-
heading: `Certificate: ${cert.
|
|
279
|
+
heading: `Certificate: ${cert.domain}`,
|
|
267
280
|
content: html`
|
|
268
281
|
<div style="padding: 20px;">
|
|
269
282
|
<dees-dataview-codebox
|
|
@@ -275,10 +288,10 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
275
288
|
`,
|
|
276
289
|
menuOptions: [
|
|
277
290
|
{
|
|
278
|
-
name: 'Copy
|
|
291
|
+
name: 'Copy Domain',
|
|
279
292
|
iconName: 'copy',
|
|
280
293
|
action: async () => {
|
|
281
|
-
await navigator.clipboard.writeText(cert.
|
|
294
|
+
await navigator.clipboard.writeText(cert.domain);
|
|
282
295
|
},
|
|
283
296
|
},
|
|
284
297
|
],
|
|
@@ -287,7 +300,7 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
287
300
|
},
|
|
288
301
|
]}
|
|
289
302
|
heading1="Certificate Status"
|
|
290
|
-
heading2="TLS certificates
|
|
303
|
+
heading2="TLS certificates by domain"
|
|
291
304
|
searchable
|
|
292
305
|
.pagination=${true}
|
|
293
306
|
.paginationSize=${50}
|
|
@@ -296,14 +309,14 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
296
309
|
`;
|
|
297
310
|
}
|
|
298
311
|
|
|
299
|
-
private
|
|
312
|
+
private renderRoutePills(routeNames: string[]): TemplateResult {
|
|
300
313
|
const maxShow = 3;
|
|
301
|
-
const visible =
|
|
302
|
-
const remaining =
|
|
314
|
+
const visible = routeNames.slice(0, maxShow);
|
|
315
|
+
const remaining = routeNames.length - maxShow;
|
|
303
316
|
|
|
304
317
|
return html`
|
|
305
|
-
<span class="
|
|
306
|
-
${visible.map((
|
|
318
|
+
<span class="routePills">
|
|
319
|
+
${visible.map((r) => html`<span class="routePill">${r}</span>`)}
|
|
307
320
|
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
|
|
308
321
|
</span>
|
|
309
322
|
`;
|
|
@@ -352,4 +365,16 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
352
365
|
</span>
|
|
353
366
|
`;
|
|
354
367
|
}
|
|
368
|
+
|
|
369
|
+
private formatRetryTime(retryAfter?: string): string {
|
|
370
|
+
if (!retryAfter) return 'soon';
|
|
371
|
+
const retryDate = new Date(retryAfter);
|
|
372
|
+
const now = new Date();
|
|
373
|
+
const diffMs = retryDate.getTime() - now.getTime();
|
|
374
|
+
if (diffMs <= 0) return 'now';
|
|
375
|
+
const diffMin = Math.ceil(diffMs / 60000);
|
|
376
|
+
if (diffMin < 60) return `in ${diffMin}m`;
|
|
377
|
+
const diffHours = Math.ceil(diffMin / 60);
|
|
378
|
+
return `in ${diffHours}h`;
|
|
379
|
+
}
|
|
355
380
|
}
|
package/ts_web/readme.md
CHANGED
|
@@ -34,6 +34,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|
|
34
34
|
- **Security** — Security incidents from email processing
|
|
35
35
|
- Bounce record management and suppression list controls
|
|
36
36
|
|
|
37
|
+
### 🔐 Certificate Management
|
|
38
|
+
- Domain-centric certificate overview with status indicators
|
|
39
|
+
- Certificate source tracking (ACME, provision function, static)
|
|
40
|
+
- Expiry date monitoring and alerts
|
|
41
|
+
- Per-domain backoff status for failed provisions
|
|
42
|
+
- One-click reprovisioning per domain
|
|
43
|
+
|
|
37
44
|
### 📜 Log Viewer
|
|
38
45
|
- Real-time log streaming
|
|
39
46
|
- Filter by log level (error, warning, info, debug)
|
|
@@ -77,6 +84,7 @@ ts_web/
|
|
|
77
84
|
├── ops-view-overview.ts # Overview statistics
|
|
78
85
|
├── ops-view-network.ts # Network monitoring
|
|
79
86
|
├── ops-view-emails.ts # Email queue management
|
|
87
|
+
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
|
80
88
|
├── ops-view-logs.ts # Log viewer
|
|
81
89
|
├── ops-view-config.ts # Configuration display
|
|
82
90
|
├── ops-view-security.ts # Security dashboard
|
|
@@ -132,6 +140,7 @@ removeFromSuppressionAction(email) // Remove from suppression list
|
|
|
132
140
|
/emails/sent → Sent emails
|
|
133
141
|
/emails/failed → Failed emails
|
|
134
142
|
/emails/security → Security incidents
|
|
143
|
+
/certificates → Certificate management
|
|
135
144
|
/logs → Log viewer
|
|
136
145
|
/configuration → System configuration
|
|
137
146
|
/security → Security dashboard
|