@serve.zone/dcrouter 5.4.6 → 6.0.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.
@@ -0,0 +1,46 @@
1
+ import * as plugins from './plugins.js';
2
+ import { StorageManager } from './storage/index.js';
3
+
4
+ /**
5
+ * ICertManager implementation backed by StorageManager.
6
+ * Persists SmartAcme certificates under a /certs/ key prefix so they
7
+ * survive process restarts without re-hitting ACME.
8
+ */
9
+ export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
10
+ private keyPrefix = '/certs/';
11
+
12
+ constructor(private storageManager: StorageManager) {}
13
+
14
+ async init(): Promise<void> {}
15
+
16
+ async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
17
+ const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
18
+ if (!data) return null;
19
+ return new plugins.smartacme.Cert(data);
20
+ }
21
+
22
+ async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
23
+ await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
24
+ id: cert.id,
25
+ domainName: cert.domainName,
26
+ created: cert.created,
27
+ privateKey: cert.privateKey,
28
+ publicKey: cert.publicKey,
29
+ csr: cert.csr,
30
+ validUntil: cert.validUntil,
31
+ });
32
+ }
33
+
34
+ async deleteCertificate(domainName: string): Promise<void> {
35
+ await this.storageManager.delete(this.keyPrefix + domainName);
36
+ }
37
+
38
+ async close(): Promise<void> {}
39
+
40
+ async wipe(): Promise<void> {
41
+ const keys = await this.storageManager.list(this.keyPrefix);
42
+ for (const key of keys) {
43
+ await this.storageManager.delete(key);
44
+ }
45
+ }
46
+ }
@@ -23,24 +23,45 @@ export class CertificateHandler {
23
23
  )
24
24
  );
25
25
 
26
- // Reprovision Certificate
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.reprovisionCertificate(dataArg.routeName);
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
- const certificates: interfaces.requests.ICertificateInfo[] = [];
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
- // Start with unknown status
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 DcRouter's certificateStatusMap
79
- const eventStatus = dcRouter.certificateStatusMap.get(route.name);
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 Rust-side certificate status if no event data
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(route.name);
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;
@@ -104,7 +153,20 @@ export class CertificateHandler {
104
153
  }
105
154
  }
106
155
 
107
- // Compute status from expiry date if we have one and status is still valid/unknown
156
+ // Check persisted cert data from StorageManager
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();
164
+ }
165
+ issuer = 'smartacme-dns-01';
166
+ }
167
+ }
168
+
169
+ // Compute status from expiry date
108
170
  if (expiryDate && (status === 'valid' || status === 'unknown')) {
109
171
  const expiry = new Date(expiryDate);
110
172
  const now = new Date();
@@ -120,23 +182,36 @@ export class CertificateHandler {
120
182
  }
121
183
 
122
184
  // Static certs with no other info default to 'valid'
123
- if (source === 'static' && status === 'unknown') {
185
+ if (info.source === 'static' && status === 'unknown') {
124
186
  status = 'valid';
125
187
  }
126
188
 
127
- const canReprovision = source === 'acme' || source === 'provision-function';
189
+ // ACME/provision-function routes with no cert data are still provisioning
190
+ if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
191
+ status = 'provisioning';
192
+ }
193
+
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
+ }
128
202
 
129
203
  certificates.push({
130
- routeName: route.name,
131
- domains: routeDomains,
204
+ domain,
205
+ routeNames: info.routeNames,
132
206
  status,
133
- source,
134
- tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough',
207
+ source: info.source,
208
+ tlsMode: info.tlsMode,
135
209
  expiryDate,
136
210
  issuer,
137
211
  issuedAt,
138
212
  error,
139
- canReprovision,
213
+ canReprovision: info.canReprovision,
214
+ backoffInfo,
140
215
  });
141
216
  }
142
217
 
@@ -166,7 +241,10 @@ export class CertificateHandler {
166
241
  return summary;
167
242
  }
168
243
 
169
- private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> {
244
+ /**
245
+ * Legacy route-based reprovisioning
246
+ */
247
+ private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
170
248
  const dcRouter = this.opsServerRef.dcRouterRef;
171
249
  const smartProxy = dcRouter.smartProxy;
172
250
 
@@ -176,11 +254,58 @@ export class CertificateHandler {
176
254
 
177
255
  try {
178
256
  await smartProxy.provisionCertificate(routeName);
179
- // Clear event-based status so it gets refreshed
180
- dcRouter.certificateStatusMap.delete(routeName);
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
+ }
181
263
  return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
182
264
  } catch (err) {
183
265
  return { success: false, message: err.message || 'Failed to reprovision certificate' };
184
266
  }
185
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
+ }
186
311
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '5.4.6',
6
+ version: '6.0.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -719,18 +719,18 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
719
719
  });
720
720
 
721
721
  export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
722
- async (statePartArg, routeName) => {
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.IReq_ReprovisionCertificate
729
- >('/typedrequest', 'reprovisionCertificate');
728
+ interfaces.requests.IReq_ReprovisionCertificateDomain
729
+ >('/typedrequest', 'reprovisionCertificateDomain');
730
730
 
731
731
  await request.fire({
732
732
  identity: context.identity,
733
- routeName,
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
- .domainPills {
97
+ .routePills {
98
98
  display: flex;
99
99
  flex-wrap: wrap;
100
100
  gap: 4px;
101
101
  }
102
102
 
103
- .domainPill {
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
- Route: cert.routeName,
222
- Domains: this.renderDomainPills(cert.domains),
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.error
227
- ? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
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.routeName,
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.routeName}`,
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.routeName}`,
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 Route Name',
291
+ name: 'Copy Domain',
279
292
  iconName: 'copy',
280
293
  action: async () => {
281
- await navigator.clipboard.writeText(cert.routeName);
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 across all routes"
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 renderDomainPills(domains: string[]): TemplateResult {
312
+ private renderRoutePills(routeNames: string[]): TemplateResult {
300
313
  const maxShow = 3;
301
- const visible = domains.slice(0, maxShow);
302
- const remaining = domains.length - maxShow;
314
+ const visible = routeNames.slice(0, maxShow);
315
+ const remaining = routeNames.length - maxShow;
303
316
 
304
317
  return html`
305
- <span class="domainPills">
306
- ${visible.map((d) => html`<span class="domainPill">${d}</span>`)}
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
  }