@serve.zone/dcrouter 17.10.2 → 18.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.
Files changed (84) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +360 -360
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/acme/acme-failure-classification.d.ts +64 -0
  5. package/dist_ts/acme/acme-failure-classification.js +114 -0
  6. package/dist_ts/acme/classes.smartacme-lifecycle.d.ts +42 -0
  7. package/dist_ts/acme/classes.smartacme-lifecycle.js +75 -4
  8. package/dist_ts/acme/index.d.ts +1 -0
  9. package/dist_ts/acme/index.js +2 -1
  10. package/dist_ts/classes.dcrouter.d.ts +33 -9
  11. package/dist_ts/classes.dcrouter.js +145 -11
  12. package/dist_ts/config/classes.route-config-manager.d.ts +56 -0
  13. package/dist_ts/config/classes.route-config-manager.js +161 -1
  14. package/dist_ts/db/documents/classes.dns-authority.doc.d.ts +30 -0
  15. package/dist_ts/db/documents/classes.dns-authority.doc.js +108 -0
  16. package/dist_ts/db/documents/index.d.ts +1 -0
  17. package/dist_ts/db/documents/index.js +2 -1
  18. package/dist_ts/dns/classes.dns-server-runtime.d.ts +109 -1
  19. package/dist_ts/dns/classes.dns-server-runtime.js +212 -34
  20. package/dist_ts/dns/domain-ownership.d.ts +111 -0
  21. package/dist_ts/dns/domain-ownership.js +152 -0
  22. package/dist_ts/dns/index.d.ts +2 -0
  23. package/dist_ts/dns/index.js +3 -1
  24. package/dist_ts/dns/manager.dns-authority.d.ts +143 -0
  25. package/dist_ts/dns/manager.dns-authority.js +481 -0
  26. package/dist_ts/dns/manager.dns.d.ts +121 -12
  27. package/dist_ts/dns/manager.dns.js +298 -28
  28. package/dist_ts/errors/error.codes.d.ts +4 -0
  29. package/dist_ts/errors/error.codes.js +5 -1
  30. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  31. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  32. package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
  33. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
  34. package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
  35. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  36. package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
  37. package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
  38. package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
  39. package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
  40. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
  41. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/index.js +2 -1
  43. package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
  44. package/dist_ts_interfaces/data/dns-authority.js +26 -0
  45. package/dist_ts_interfaces/data/index.d.ts +1 -0
  46. package/dist_ts_interfaces/data/index.js +2 -1
  47. package/dist_ts_interfaces/data/route-management.d.ts +9 -2
  48. package/dist_ts_interfaces/data/route-management.js +3 -1
  49. package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
  50. package/dist_ts_interfaces/requests/certificate.js +1 -1
  51. package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
  52. package/dist_ts_interfaces/requests/dns-authority.js +3 -0
  53. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  54. package/dist_ts_interfaces/requests/index.js +2 -1
  55. package/dist_ts_oci_container/index.js +9 -4
  56. package/dist_ts_web/00_commitinfo_data.js +2 -2
  57. package/package.json +1 -1
  58. package/readme.hints.md +412 -0
  59. package/readme.md +48 -6
  60. package/ts/00_commitinfo_data.ts +1 -1
  61. package/ts/acme/acme-failure-classification.ts +201 -0
  62. package/ts/acme/classes.smartacme-lifecycle.ts +104 -3
  63. package/ts/acme/index.ts +1 -0
  64. package/ts/classes.dcrouter.ts +193 -23
  65. package/ts/config/classes.route-config-manager.ts +197 -0
  66. package/ts/db/documents/classes.dns-authority.doc.ts +49 -0
  67. package/ts/db/documents/index.ts +1 -0
  68. package/ts/dns/classes.dns-server-runtime.ts +257 -38
  69. package/ts/dns/domain-ownership.ts +272 -0
  70. package/ts/dns/index.ts +2 -0
  71. package/ts/dns/manager.dns-authority.ts +558 -0
  72. package/ts/dns/manager.dns.ts +373 -27
  73. package/ts/errors/error.codes.ts +4 -0
  74. package/ts/opsserver/classes.opsserver.ts +2 -0
  75. package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
  76. package/ts/opsserver/handlers/certificate.handler.ts +103 -8
  77. package/ts/opsserver/handlers/config.handler.ts +3 -1
  78. package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
  79. package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
  80. package/ts/opsserver/handlers/domain.handler.ts +12 -0
  81. package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
  82. package/ts/opsserver/handlers/index.ts +1 -0
  83. package/ts/readme.md +1 -1
  84. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,201 @@
1
+ import { PlatformError, type IErrorContext } from '../errors/base.errors.js';
2
+ import {
3
+ DCR_ACME_PERMANENT_FAILURE,
4
+ ErrorCategory,
5
+ ErrorRecoverability,
6
+ ErrorSeverity,
7
+ } from '../errors/error.codes.js';
8
+ import { DomainOwnershipError } from '../dns/domain-ownership.js';
9
+
10
+ /**
11
+ * ACME failure classification.
12
+ *
13
+ * dcrouter has two independent ACME retry budgets, and neither used to look at
14
+ * *why* a failure happened:
15
+ *
16
+ * 1. `SmartAcmeLifecycle` — SmartAcme provider startup. 5 s→1 h exponential
17
+ * backoff with jitter, hard cap of 20 attempts, then permanent give-up.
18
+ * Only a SmartProxy rebuild or a process restart re-arms it.
19
+ * 2. `CertProvisionScheduler` — per-domain certificate provisioning.
20
+ * `min(failures², 24 h)` backoff, **no cap**, re-armed by time forever.
21
+ * This is the budget that reached 31–45 failures on the broken domains.
22
+ *
23
+ * Retrying is correct for rate limits, DNS propagation and transport faults. It
24
+ * is never correct for a configuration cause: a hostname with no managed domain
25
+ * cannot acquire one by waiting, so every one of those attempts was a silent
26
+ * no-op that also kept the real reason out of the operator's view. Permanent
27
+ * causes must therefore skip the budget entirely and surface attributably.
28
+ *
29
+ * Unclassified causes stay transient on purpose. Guessing "permanent" would
30
+ * strand recoverable domains, so the default preserves existing retry behaviour;
31
+ * only causes we can positively recognise are treated as terminal.
32
+ */
33
+
34
+ export type TAcmeFailureReason =
35
+ /** Ownership of the hostname could not be proven (no DomainDoc / no provider zone / not delegation-verified). */
36
+ | 'domain-ownership-unverified'
37
+ /** The DNS-01 dispatcher found no managed zone able to hold the challenge record. */
38
+ | 'no-managed-dns-zone'
39
+ /** No challenge handler / provider is wired for this domain at all. */
40
+ | 'no-challenge-handler'
41
+ /** The ACME account itself is misconfigured (email, terms, key, directory URL). */
42
+ | 'acme-account-configuration'
43
+ /** CAA forbids our issuer — only the domain holder can change this. */
44
+ | 'caa-forbids-issuance'
45
+ /** ACME server rate limit — retrying is exactly right. */
46
+ | 'rate-limited'
47
+ /** Challenge not yet visible, propagation delay, transport fault. */
48
+ | 'transient'
49
+ /** Nothing recognised. Treated as transient so recoverable causes keep retrying. */
50
+ | 'unclassified';
51
+
52
+ export interface IAcmeFailureClassification {
53
+ reason: TAcmeFailureReason;
54
+ /** True when no retry can resolve the cause, so the retry budget must not be consumed. */
55
+ permanent: boolean;
56
+ message: string;
57
+ }
58
+
59
+ interface IReasonPattern {
60
+ reason: TAcmeFailureReason;
61
+ permanent: boolean;
62
+ patterns: RegExp[];
63
+ }
64
+
65
+ /**
66
+ * Ordered most-specific-first. Each pattern must only match text that uniquely
67
+ * identifies the cause — a false "permanent" verdict silently stops legitimate
68
+ * retries, which is a worse failure than an extra retry.
69
+ */
70
+ const reasonPatterns: IReasonPattern[] = [
71
+ {
72
+ // Thrown by DnsManager.buildAcmeConvenientDnsProvider() when no DomainDoc covers the FQDN.
73
+ reason: 'no-managed-dns-zone',
74
+ permanent: true,
75
+ patterns: [
76
+ /no managed domain found for/i,
77
+ /add the domain in domains before issuing certificates/i,
78
+ ],
79
+ },
80
+ {
81
+ reason: 'no-challenge-handler',
82
+ permanent: true,
83
+ patterns: [
84
+ /no (?:challenge )?handler (?:found |available )?for/i,
85
+ /no dns-01 (?:handler|provider)/i,
86
+ /domain is not supported by any challenge handler/i,
87
+ ],
88
+ },
89
+ {
90
+ reason: 'caa-forbids-issuance',
91
+ permanent: true,
92
+ patterns: [
93
+ /caa record(?:s)? (?:for [^\s]+ )?(?:prevent|forbid|do not allow)/i,
94
+ /urn:ietf:params:acme:error:caa/i,
95
+ ],
96
+ },
97
+ {
98
+ reason: 'acme-account-configuration',
99
+ permanent: true,
100
+ patterns: [
101
+ /urn:ietf:params:acme:error:invalidemail/i,
102
+ /urn:ietf:params:acme:error:accountdoesnotexist/i,
103
+ /urn:ietf:params:acme:error:unsupportedcontact/i,
104
+ /must agree to (?:the )?terms of service/i,
105
+ /accountemail is required/i,
106
+ ],
107
+ },
108
+ {
109
+ reason: 'rate-limited',
110
+ permanent: false,
111
+ patterns: [
112
+ /urn:ietf:params:acme:error:ratelimited/i,
113
+ /too many (?:certificates|requests|failed authorizations)/i,
114
+ /rate ?limit/i,
115
+ ],
116
+ },
117
+ ];
118
+
119
+ const extractMessage = (errorArg: unknown): string => {
120
+ if (errorArg instanceof Error) return errorArg.message;
121
+ if (typeof errorArg === 'string') return errorArg;
122
+ if (errorArg && typeof errorArg === 'object' && 'message' in errorArg) {
123
+ return String((errorArg as { message: unknown }).message);
124
+ }
125
+ return String(errorArg);
126
+ };
127
+
128
+ /**
129
+ * Classify an ACME/provisioning failure into a retryable or terminal cause.
130
+ * Structured errors win over text matching: a `DomainOwnershipError` and any
131
+ * NON_RECOVERABLE CONFIGURATION `PlatformError` are permanent by declaration.
132
+ */
133
+ export const classifyAcmeFailure = (errorArg: unknown): IAcmeFailureClassification => {
134
+ const message = extractMessage(errorArg);
135
+
136
+ if (errorArg instanceof DomainOwnershipError) {
137
+ return { reason: 'domain-ownership-unverified', permanent: true, message };
138
+ }
139
+
140
+ if (
141
+ errorArg instanceof PlatformError
142
+ && errorArg.category === ErrorCategory.CONFIGURATION
143
+ && errorArg.recoverability === ErrorRecoverability.NON_RECOVERABLE
144
+ ) {
145
+ return { reason: 'acme-account-configuration', permanent: true, message };
146
+ }
147
+
148
+ for (const candidate of reasonPatterns) {
149
+ if (candidate.patterns.some((pattern) => pattern.test(message))) {
150
+ return { reason: candidate.reason, permanent: candidate.permanent, message };
151
+ }
152
+ }
153
+
154
+ return { reason: 'unclassified', permanent: false, message };
155
+ };
156
+
157
+ /**
158
+ * Terminal ACME failure. HIGH severity so the automatic PlatformError log lands
159
+ * at `error` rather than being lost in provisioning warn noise, and
160
+ * NON_RECOVERABLE so `isRetryable()` and every downstream retry layer agree that
161
+ * this must not be retried.
162
+ */
163
+ export class AcmePermanentFailureError extends PlatformError {
164
+ public readonly classification: IAcmeFailureClassification;
165
+
166
+ constructor(
167
+ classification: IAcmeFailureClassification,
168
+ operation: string,
169
+ component: string,
170
+ context: IErrorContext = {},
171
+ ) {
172
+ super(
173
+ `${operation} failed permanently (${classification.reason}): ${classification.message}`,
174
+ DCR_ACME_PERMANENT_FAILURE,
175
+ ErrorSeverity.HIGH,
176
+ ErrorCategory.CONFIGURATION,
177
+ ErrorRecoverability.NON_RECOVERABLE,
178
+ {
179
+ component,
180
+ operation,
181
+ userMessage:
182
+ `${operation} cannot succeed until its configuration is fixed (${classification.reason}): ${classification.message}`,
183
+ ...context,
184
+ data: {
185
+ acmeFailureReason: classification.reason,
186
+ ...context.data,
187
+ },
188
+ },
189
+ );
190
+ this.classification = classification;
191
+ }
192
+
193
+ protected createWithContext(context: IErrorContext): PlatformError {
194
+ return new AcmePermanentFailureError(
195
+ this.classification,
196
+ this.context.operation || 'operation',
197
+ this.context.component || 'acme',
198
+ context,
199
+ );
200
+ }
201
+ }
@@ -1,18 +1,58 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { logger } from '../logger.js';
3
3
  import type { DcRouter } from '../classes.dcrouter.js';
4
+ import {
5
+ AcmePermanentFailureError,
6
+ classifyAcmeFailure,
7
+ type TAcmeFailureReason,
8
+ } from './acme-failure-classification.js';
9
+
10
+ /** Terminal state of the SmartAcme provider startup budget. */
11
+ export interface IAcmeStartFailure {
12
+ reason: TAcmeFailureReason;
13
+ /** True when no further retry can resolve the cause. */
14
+ permanent: boolean;
15
+ /** Attempts consumed when the budget ended. */
16
+ attempts: number;
17
+ message: string;
18
+ at: number;
19
+ /** What an operator has to do to re-arm startup. */
20
+ rearmedBy: string;
21
+ }
22
+
23
+ /** Attempts allowed for transient SmartAcme startup failures before giving up. */
24
+ export const smartAcmeStartMaxAttempts = 20;
4
25
 
5
26
  /**
6
27
  * Background start/retry/stop lifecycle for the DcRouter-owned SmartAcme
7
28
  * instance. SmartAcme startup can hit ACME rate limits, so startup runs in
8
29
  * the background with generation-guarded exponential retry, and certificate
9
30
  * provisioning is re-triggered once DNS-01 becomes ready.
31
+ *
32
+ * Retry semantics, which are NOT shared with the per-domain
33
+ * `CertProvisionScheduler` budget:
34
+ * - increments once per failed `smartAcme.start()`;
35
+ * - 5 s → 1 h exponential backoff with ±20% jitter;
36
+ * - capped at `smartAcmeStartMaxAttempts` transient attempts;
37
+ * - reset by `startInBackground()`, `stop()`, and a successful start;
38
+ * - re-armed only by `startInBackground()`, i.e. a SmartProxy rebuild, an
39
+ * explicit `rearm()` after a configuration change, or a process restart.
40
+ * Nothing re-arms it on a timer, so the terminal state is recorded in
41
+ * `startFailure` and logged at `error` instead of scrolling past as a warning.
42
+ *
43
+ * A permanent cause never enters the backoff at all: retrying a misconfigured
44
+ * ACME account cannot fix it, and spending the budget on it hides the reason.
10
45
  */
11
46
  export class SmartAcmeLifecycle {
12
47
  /** True once the SmartAcme DNS-01 provider finished starting. */
13
48
  public ready = false;
14
49
  /** Tracks whether the taskbuffer SmartAcme service is started, so SmartProxy rebuilds can re-kick startup. */
15
50
  public serviceStarted = false;
51
+ /**
52
+ * Set when the startup budget ended — either immediately for a permanent cause
53
+ * or after the transient attempt cap. Cleared on every (re-)arm and on success.
54
+ */
55
+ public startFailure?: IAcmeStartFailure;
16
56
 
17
57
  private startGeneration = 0;
18
58
  private startPromise?: Promise<void>;
@@ -30,14 +70,35 @@ export class SmartAcmeLifecycle {
30
70
  const generation = ++this.startGeneration;
31
71
  this.ready = false;
32
72
  this.retryAttempt = 0;
73
+ this.startFailure = undefined;
33
74
  this.clearRetryTimer();
34
75
  this.scheduleStart(generation, 0);
35
76
  }
36
77
 
78
+ /**
79
+ * Re-arm startup after a configuration change that can plausibly fix a
80
+ * previously terminal cause (e.g. the ACME account settings were corrected).
81
+ * Without this, an exhausted or permanently-failed budget could only be reset
82
+ * by a SmartProxy rebuild or a full restart — and a dcrouter restart is a
83
+ * measured 30–60 s of total public outage.
84
+ */
85
+ public rearm(reasonArg: string): boolean {
86
+ if (!this.dcRouterRef.smartAcme || !this.serviceStarted) {
87
+ return false;
88
+ }
89
+ if (this.ready && !this.startFailure) {
90
+ return false;
91
+ }
92
+ logger.log('info', `Re-arming SmartAcme DNS-01 provider startup: ${reasonArg}`);
93
+ this.startInBackground();
94
+ return true;
95
+ }
96
+
37
97
  public async stop(): Promise<void> {
38
98
  this.startGeneration++;
39
99
  this.ready = false;
40
100
  this.retryAttempt = 0;
101
+ this.startFailure = undefined;
41
102
  this.clearRetryTimer();
42
103
 
43
104
  const smartAcme = this.dcRouterRef.smartAcme;
@@ -91,6 +152,7 @@ export class SmartAcmeLifecycle {
91
152
 
92
153
  this.ready = true;
93
154
  this.retryAttempt = 0;
155
+ this.startFailure = undefined;
94
156
  logger.log('info', 'SmartAcme DNS-01 provider is now ready');
95
157
  this.retriggerCertificateProvisioning();
96
158
  } catch (err) {
@@ -102,9 +164,48 @@ export class SmartAcmeLifecycle {
102
164
  await smartAcme.stop().catch((stopErr) => {
103
165
  logger.log('warn', `Failed to clean up SmartAcme after startup failure: ${(stopErr as Error).message}`);
104
166
  });
167
+
168
+ // Classify before spending any of the budget: a permanent cause is not made
169
+ // truer by 20 more attempts, and every attempt spent on it is an hour in
170
+ // which nothing tells the operator what is actually wrong.
171
+ const classification = classifyAcmeFailure(err);
172
+ if (classification.permanent) {
173
+ this.startFailure = {
174
+ reason: classification.reason,
175
+ permanent: true,
176
+ attempts: this.retryAttempt,
177
+ message: classification.message,
178
+ at: Date.now(),
179
+ rearmedBy: 'fixing the ACME configuration (which calls rearm()), a SmartProxy rebuild, or a process restart',
180
+ };
181
+ // Constructing this logs at `error` with the code and category attached.
182
+ new AcmePermanentFailureError(
183
+ classification,
184
+ 'SmartAcme DNS-01 provider startup',
185
+ 'smartacme-lifecycle',
186
+ { data: { attempts: this.retryAttempt, rearmedBy: this.startFailure.rearmedBy } },
187
+ );
188
+ return;
189
+ }
190
+
105
191
  this.retryAttempt++;
106
- if (this.retryAttempt > 20) {
107
- logger.log('error', `SmartAcme DNS-01 provider failed after 20 startup attempts: ${(err as Error).message}`);
192
+ if (this.retryAttempt > smartAcmeStartMaxAttempts) {
193
+ this.startFailure = {
194
+ reason: classification.reason,
195
+ permanent: false,
196
+ attempts: this.retryAttempt - 1,
197
+ message: classification.message,
198
+ at: Date.now(),
199
+ rearmedBy: 'a SmartProxy rebuild, an explicit rearm() after a configuration change, or a process restart',
200
+ };
201
+ logger.log(
202
+ 'error',
203
+ `SmartAcme DNS-01 provider gave up after ${smartAcmeStartMaxAttempts} startup attempts `
204
+ + `(${classification.reason}): ${classification.message}. `
205
+ + 'Nothing re-arms this on a timer — DNS-01 stays unavailable and every affected route falls back to a '
206
+ + `no-op http-01 path until ${this.startFailure.rearmedBy}.`,
207
+ { acmeFailureReason: classification.reason, attempts: this.startFailure.attempts },
208
+ );
108
209
  return;
109
210
  }
110
211
 
@@ -113,7 +214,7 @@ export class SmartAcmeLifecycle {
113
214
  const delayMs = Math.min(baseDelayMs * Math.pow(2, this.retryAttempt - 1), maxDelayMs);
114
215
  const jitter = 0.8 + Math.random() * 0.4;
115
216
  const actualDelayMs = Math.floor(delayMs * jitter);
116
- logger.log('warn', `SmartAcme DNS-01 provider startup failed: ${(err as Error).message}; retrying in ${actualDelayMs}ms (attempt ${this.retryAttempt}/20)`);
217
+ logger.log('warn', `SmartAcme DNS-01 provider startup failed: ${(err as Error).message}; retrying in ${actualDelayMs}ms (attempt ${this.retryAttempt}/${smartAcmeStartMaxAttempts})`);
117
218
  this.scheduleStart(generation, actualDelayMs);
118
219
  } finally {
119
220
  if (this.startPromise === startPromise) {
package/ts/acme/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './manager.acme-config.js';
2
2
  export * from './classes.smartacme-lifecycle.js';
3
+ export * from './acme-failure-classification.js';