@serve.zone/dcrouter 34.7.1 → 34.7.2

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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.7.1',
6
+ version: '34.7.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "34.7.1",
4
+ "version": "34.7.2",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "@push.rocks/lik": "^6.4.6",
46
46
  "@push.rocks/projectinfo": "^5.1.0",
47
47
  "@push.rocks/qenv": "8.0.0",
48
- "@push.rocks/smartacme": "^9.8.1",
48
+ "@push.rocks/smartacme": "^9.8.3",
49
49
  "@push.rocks/smartchallenge": "^1.1.0",
50
50
  "@push.rocks/smartdb": "5.7.1",
51
51
  "@push.rocks/smartdns": "^11.2.1",
package/readme.md CHANGED
@@ -75,10 +75,11 @@ dcrouter adopt --controller <https origin> --code <adoption code>
75
75
 
76
76
  `dcrouter` starts the router only without arguments; from 34.2.0 on it refuses any argument it does not know (exit code 2) and starts nothing. Releases up to 34.1.0 started the router on an unknown argument, so check `dcrouter --version` before running a command a later release added.
77
77
 
78
- `dcrouter migrate` walks the data of a **stopped** dcrouter back to an earlier data version through the migration steps' down handlers; forward migrations happen when dcrouter starts. A dcrouter refuses to start on a data version a newer release wrote, so a rollback runs the newer release's `dcrouter migrate` first. Two downgrades are supported:
78
+ `dcrouter migrate` walks the data of a **stopped** dcrouter back to an earlier data version through the migration steps' down handlers; forward migrations happen when dcrouter starts. A dcrouter refuses to start on a data version a newer release wrote, so a rollback runs the newer release's `dcrouter migrate` first. The data version of this release is `34.7.2`. Three downgrades are supported:
79
79
 
80
+ - data version `34.7.2` → `34.5.0`, the data dcrouter 34.5.0 through 34.7.1 read: the `floor-exact-certificate-renew-after` step changes nothing on the way back. On the way up it rounds down the renewal time of exact certificates that were stored with a fraction of a millisecond; the rounded value is valid in every release, so the way back keeps it.
80
81
  - data version `34.5.0` → `34.0.0`, the data dcrouter 34.0.0 through 34.4.0 read: the `gateway-route-request-headers` step deletes the request headers gateway clients set on their routes (`headers.request` of every gateway-client route), because those releases would list the values. It logs each route and the field names, never a value. Nothing keeps the values: after upgrading again, each gateway client's next full sync sets them again. Operator routes keep their headers.
81
- - data version `34.0.0` → `32.6.0`, the data dcrouter 33.0.0 reads, which returns the route DNS ownership the `release-annexed-gateway-route-dns-records` step released. From 34.5.0 data, `--to 32.6.0` runs both steps, newest first; each commits with its own ledger stamp, so a refusal of the second leaves the data at `34.0.0`.
82
+ - data version `34.0.0` → `32.6.0`, the data dcrouter 33.0.0 reads, which returns the route DNS ownership the `release-annexed-gateway-route-dns-records` step released. From 34.7.2 data, `--to 32.6.0` runs all three steps, newest first; each commits with its own ledger stamp, so a refusal of the last leaves the data at `34.0.0`.
82
83
 
83
84
  ```bash
84
85
  # the embedded database's default root is ~/.serve.zone/dcrouter/tsmdb (dbConfig.storagePath overrides it)
@@ -359,6 +360,12 @@ registration. At most 128 access requests enter/wait for the local admission
359
360
  boundary. ACME has a separate global cap of 256 unresolved TXT resources.
360
361
  Revocation cleanup retains one captured record operation per grant so a full
361
362
  client quota cannot prevent cleanup. Maintenance uses bounded batches of 25.
363
+ A revoked grant whose cleanup cannot go on (an unresolved certificate job or DNS
364
+ mutation still holds the hostname, a provider delete is not confirmed, or the
365
+ cleanup fails) is tried again after a wait that starts at 2 seconds and doubles
366
+ up to 5 minutes while the reason stays the same. After the blocking operation
367
+ is resolved, `cleanupState` becomes `completed` once the current wait is over,
368
+ so poll it for more than 5 minutes before treating the cleanup as stuck.
362
369
 
363
370
  Resolved jobs and request receipts are retained for 30 days, then explicitly
364
371
  removed with their capacity counters. Known expired references return `expired`;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.7.1',
6
+ version: '34.7.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
2
3
  import type { DnsManager } from '../dns/manager.dns.js';
3
4
  import { CertificateIssuance, testingCertificateNamespace } from '../acme/classes.certificate-issuance.js';
4
5
  import { ExactIssuanceStore } from '../acme/classes.exact-issuance-store.js';
@@ -12,6 +13,7 @@ import { testingLimits, type TTestingCertificateJob, type ITestingCertificateMet
12
13
  type ITestingCertificateDownload, type ITestingOperationRecovery, type TTestingRecoveryAction,
13
14
  type ITestingPageRequest, type ITestingPage, type TTestingErrorCode } from '../../ts_interfaces/data/testing.js';
14
15
  import { TestingError } from './errors.js';
16
+ import { testingErrorLogFields } from './error-log-fields.js';
15
17
  import { testingCheck as check, testingIdCheck } from './validation.js';
16
18
  import { requireTestingInput, testingId, testingKey, testingPage, testingRequestDigest } from './request-validation.js';
17
19
 
@@ -268,21 +270,36 @@ export class TestingCertificateManager {
268
270
  // that a dispatched CA order did not happen.
269
271
  let status: plugins.smartacme.IExactIssuanceInfo | null | undefined;
270
272
  try { status = await this.issuance.getExactStatus(testingCertificateNamespace(snapshot.data.grantId), snapshot.data.hostname); } catch { /* Unavailable owner is unknown. */ }
273
+ const claimed = snapshot;
271
274
  await this.dns.runDnsMutationExclusive(async () => {
272
275
  const current = await TestingCertificateJobDoc.exact.findStoredOne({ id, 'data.state': 'running', 'data.leaseOwner': leaseOwner });
273
- if (!current) return;
274
276
  const code = error instanceof TestingError ? error.publicError.code : 'issuance_failed';
275
- const unapplied = current.data.evidence === 'intent_persisted' || status === null || status?.status === 'prepared';
277
+ const unapplied = current?.data.evidence === 'intent_persisted' || status === null || status?.status === 'prepared';
278
+ const cancelled = unapplied && !['acme_unavailable', 'service_unavailable', 'issuance_failed'].includes(code);
279
+ const state = status?.status === 'abandoned_unverified' ? 'abandoned_unverified' : unapplied ? 'retry_wait' : 'indeterminate';
280
+ const outcome = !current ? 'claim_lost' : cancelled ? 'cancelled' : state;
281
+ const job = current ?? claimed;
282
+ // One line per failed pass, before the first state write, so a write that throws keeps it. A grant
283
+ // revoked before anything was ordered cancels its job by design; every other outcome needs a look.
284
+ // Fields only: never the error, its stack or cause, the status object or the job record.
285
+ logger.log(outcome === 'cancelled' ? 'info' : 'warn', 'Testing certificate job pass ended with an error', {
286
+ jobId: job.id, grantId: job.data.grantId, hostname: job.data.hostname, attempt: job.data.attempts,
287
+ outcome, ...testingErrorLogFields(error),
288
+ issuanceStatus: status === undefined ? 'unavailable' : status === null ? 'none' : status.status,
289
+ ...(status ? { issuanceRevision: status.revision } : {}),
290
+ ...(status?.errorCode ? { issuanceErrorCode: status.errorCode } : {}),
291
+ });
292
+ if (!current) return;
276
293
  const changed = await this.change(current, (data) => {
277
294
  delete data.recoveryAction;
278
295
  if (status) { data.issuanceId = status.issuanceId; data.issuanceRevision = status.revision; }
279
296
  data.evidence = unapplied ? 'effect_proven_unapplied' : status?.errorCode === 'challenge_cleanup_pending' ? 'cleanup_pending' : 'effect_unacknowledged';
280
297
  });
281
- if (unapplied && !['acme_unavailable', 'service_unavailable', 'issuance_failed'].includes(code)) {
298
+ if (cancelled) {
282
299
  await this.finish(changed, 'cancelled', undefined, code);
283
300
  } else await this.change(changed, (data) => {
284
- data.state = status?.status === 'abandoned_unverified' ? 'abandoned_unverified' : unapplied ? 'retry_wait' : 'indeterminate';
285
- if (data.state === 'abandoned_unverified') data.evidence = 'abandoned_unverified';
301
+ data.state = state;
302
+ if (state === 'abandoned_unverified') data.evidence = 'abandoned_unverified';
286
303
  data.errorCode = unapplied ? code : 'indeterminate';
287
304
  if (unapplied) data.retryAt = Date.now() + testingLimits.pollAfterMs;
288
305
  });
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
2
3
  import type { DnsManager } from '../dns/manager.dns.js';
3
4
  import { ManagedDnsEffect, type IManagedDnsEffect } from '../dns/providers/classes.managed-dns-effect.js';
4
5
  import { TestingDomainGrantDoc, type ITestingDomainGrantRecord } from '../db/documents/classes.testing-domain-grant.doc.js';
@@ -13,13 +14,32 @@ import type { ITestingDnsCleanup } from './dns-cleanup.js';
13
14
  import { testingLimits, type ITestingOperationRecovery, type ITestingPageRequest, type ITestingPage,
14
15
  type TTestingRecoveryAction } from '../../ts_interfaces/data/testing.js';
15
16
  import { TestingError } from './errors.js';
17
+ import { testingErrorLogFields } from './error-log-fields.js';
16
18
  import { testingCheck as check, testingIdCheck } from './validation.js';
17
19
  import { requireTestingInput, testingId, testingPage, testingKey, testingRequestDigest } from './request-validation.js';
18
20
 
19
21
  type TGrant = plugins.smartdata.TStoredDocument<ITestingDomainGrantRecord>;
20
22
 
23
+ const sweepLimit = 25;
24
+ /** The longest wait between two visits to a grant whose cleanup makes no progress. */
25
+ const stallDelayCapMs = 5 * 60 * 1000;
26
+
27
+ interface ICleanupStall {
28
+ /** What held the cleanup on the last visit. */
29
+ key: string;
30
+ delayMs: number;
31
+ /** The `cleanupRetryAt` written with it. */
32
+ retryAt: number;
33
+ }
34
+
21
35
  /** Revocation reuses one grant-owned intent slot; a full client quota cannot prevent cleanup. */
22
36
  export class TestingDnsCleanupManager {
37
+ /**
38
+ * How this process paces a stalled grant: grant id to the reason and the current wait. Only
39
+ * `cleanupRetryAt` is stored, so a restart begins the waits again and logs each stall once more.
40
+ */
41
+ private stalls = new Map<string, ICleanupStall>();
42
+
23
43
  constructor(private dns: DnsManager, private mutations: TestingDnsManager, private certificates: TestingCertificateManager) {}
24
44
 
25
45
  private async change(current: TGrant, change: (data: ITestingDomainGrantRecord['data']) => void): Promise<TGrant> {
@@ -36,30 +56,87 @@ export class TestingDnsCleanupManager {
36
56
  previous: { ...intended, providerRecordId: operation.providerRecordId! }, siblings: [] };
37
57
  }
38
58
 
39
- private async blocked(grantId: string): Promise<boolean> {
40
- return Boolean(await TestingDnsMutationDoc.exact.findStoredOne({ 'data.grantId': grantId, 'data.activeConflictKey': { $exists: true } })
41
- || await TestingCertificateJobDoc.exact.findStoredOne({ 'data.grantId': grantId, 'data.activeConflictKey': { $exists: true } }));
59
+ /** The unresolved operation that still holds the grant's hostname, the lowest id of its kind first. */
60
+ private async blocker(grantId: string): Promise<{ kind: 'dns' | 'certificate'; operationId: string } | null> {
61
+ const options = { filter: { 'data.grantId': grantId, 'data.activeConflictKey': { $exists: true } }, sort: { id: 1 as const }, limit: 1 };
62
+ const [mutation] = await TestingDnsMutationDoc.exact.findStored(options);
63
+ if (mutation) return { kind: 'dns', operationId: mutation.id };
64
+ const [job] = await TestingCertificateJobDoc.exact.findStored(options);
65
+ return job ? { kind: 'certificate', operationId: job.id } : null;
66
+ }
67
+
68
+ /** The grant as the sweep may work on it: revoked, cleanup unfinished and not abandoned by an operator. */
69
+ private async sweepable(grantId: string): Promise<TGrant | null> {
70
+ const current = await TestingDomainGrantDoc.exact.findStoredOne({ id: grantId, 'data.state': 'revoked' });
71
+ return !current || current.data.cleanupState === 'completed' || current.data.cleanupOperation?.phase === 'abandoned_unverified'
72
+ ? null : current;
73
+ }
74
+
75
+ /**
76
+ * The one stall rule: a visit that got the cleanup no further defers the grant, and nothing else writes
77
+ * `cleanupRetryAt`. The same reason as on the last visit doubles the wait, up to the cap. A new reason
78
+ * starts at the poll interval and is logged once. Between two visits the sweep does not select the grant,
79
+ * so a stalled grant is written once per wait. `stateChange` goes into that one write.
80
+ */
81
+ private async stall(current: TGrant, key: string, context: Record<string, string | number>,
82
+ stateChange?: (data: ITestingDomainGrantRecord['data']) => void): Promise<void> {
83
+ const known = this.stalls.get(current.id);
84
+ const repeated = known?.key === key;
85
+ const delayMs = repeated ? Math.min(known.delayMs * 2, stallDelayCapMs) : testingLimits.pollAfterMs;
86
+ const retryAt = Date.now() + delayMs;
87
+ await this.change(current, (data) => { stateChange?.(data); data.cleanupRetryAt = retryAt; });
88
+ this.stalls.set(current.id, { key, delayMs, retryAt });
89
+ if (!repeated) logger.log('warn', 'Testing DNS cleanup of a revoked grant is stalled', {
90
+ grantId: current.id, hostname: current.data.hostname, ...context });
91
+ }
92
+
93
+ /** One grant under DNS exclusion. It ends finished, a record further, or stalled. */
94
+ private async visit(grantId: string): Promise<void> {
95
+ const current = await this.sweepable(grantId);
96
+ if (!current) { this.stalls.delete(grantId); return; }
97
+ await this.mutations.cancelForRevokedGrant(current.id);
98
+ await this.certificates.cancelForRevokedGrant(current.id);
99
+ const blocker = await this.blocker(current.id);
100
+ if (blocker) {
101
+ await this.stall(current, `${blocker.kind}:${blocker.operationId}`,
102
+ { stalledBy: 'operation', blockingKind: blocker.kind, blockingOperationId: blocker.operationId },
103
+ (data) => { data.cleanupState = 'indeterminate'; });
104
+ return;
105
+ }
106
+ const advanced = await this.advance(current, false);
107
+ const operation = advanced.data.cleanupOperation;
108
+ // Finished, or a record is gone and more remain: nothing is written and the next pass takes the grant.
109
+ if (!operation) { this.stalls.delete(grantId); return; }
110
+ await this.stall(advanced, `${operation.recordId}:${operation.phase}:${advanced.data.cleanupState}`,
111
+ { stalledBy: 'record', recordId: operation.recordId, phase: operation.phase, cleanupState: advanced.data.cleanupState });
112
+ }
113
+
114
+ /** A visit that threw is deferred from a fresh read: it may have written the grant before it threw. */
115
+ private async deferThrown(grantId: string, error: unknown): Promise<void> {
116
+ const current = await this.sweepable(grantId);
117
+ if (!current) { this.stalls.delete(grantId); return; }
118
+ const fields = testingErrorLogFields(error);
119
+ await this.stall(current, `error:${fields.errorCode ?? fields.errorName}`, { stalledBy: 'error', ...fields });
42
120
  }
43
121
 
44
122
  /** Bounded maintenance, with no provider retry after an unacknowledged delete. */
45
123
  public async sweep(): Promise<void> {
124
+ const now = Date.now();
46
125
  const grants = await TestingDomainGrantDoc.exact.findStored({ filter: {
47
126
  'data.state': 'revoked', 'data.cleanupState': { $in: ['pending', 'indeterminate'] },
48
127
  'data.cleanupOperation.phase': { $ne: 'abandoned_unverified' },
49
- $or: [{ 'data.cleanupRetryAt': { $exists: false } }, { 'data.cleanupRetryAt': { $lte: Date.now() } }],
50
- }, sort: { 'data.cleanupRetryAt': 1, id: 1 }, limit: 25 });
128
+ $or: [{ 'data.cleanupRetryAt': { $exists: false } }, { 'data.cleanupRetryAt': { $lte: now } }],
129
+ }, sort: { 'data.cleanupRetryAt': 1, id: 1 }, limit: sweepLimit });
130
+ // A grant an operator abandoned, or one that is gone, leaves the sweep without a visit: its wait is over
131
+ // and it is not listed. A full page does not list every due grant, so it says nothing about the others.
132
+ if (grants.length < sweepLimit) {
133
+ const due = new Set(grants.map((grant) => grant.id));
134
+ for (const [grantId, stall] of this.stalls) if (stall.retryAt <= now && !due.has(grantId)) this.stalls.delete(grantId);
135
+ }
51
136
  for (const grant of grants) {
52
- await this.dns.runDnsMutationExclusive(async () => {
53
- let current = await TestingDomainGrantDoc.exact.findStoredOne({ id: grant.id, 'data.state': 'revoked' });
54
- if (!current || current.data.cleanupState === 'completed' || current.data.cleanupOperation?.phase === 'abandoned_unverified') return;
55
- current = await this.change(current, (data) => { data.cleanupRetryAt = Date.now() + testingLimits.pollAfterMs; });
56
- await this.mutations.cancelForRevokedGrant(current.id);
57
- await this.certificates.cancelForRevokedGrant(current.id);
58
- if (await this.blocked(current.id)) {
59
- await this.change(current, (data) => { data.cleanupState = 'indeterminate'; }); return;
60
- }
61
- await this.advance(current, false);
62
- });
137
+ // One grant that throws must not keep the pass from the grants behind it.
138
+ try { await this.dns.runDnsMutationExclusive(() => this.visit(grant.id)); }
139
+ catch (error) { await this.dns.runDnsMutationExclusive(() => this.deferThrown(grant.id, error)); }
63
140
  }
64
141
  }
65
142
 
@@ -117,7 +194,7 @@ export class TestingDnsCleanupManager {
117
194
  }
118
195
 
119
196
  private async complete(current: TGrant): Promise<TGrant> {
120
- if (await this.blocked(current.id)) return this.change(current, (data) => { data.cleanupState = 'indeterminate'; });
197
+ if (await this.blocker(current.id)) return this.change(current, (data) => { data.cleanupState = 'indeterminate'; });
121
198
  const session = DcRouterDb.getInstance().getDb().createSession();
122
199
  try {
123
200
  const result = await session.withTransaction(async (transaction) => {
@@ -139,7 +216,10 @@ export class TestingDnsCleanupManager {
139
216
  if (result.status === 'concurrent_change') throw new TestingError('revision_conflict');
140
217
  return result.document;
141
218
  });
142
- if (!result) throw new TestingError('service_unavailable'); return result;
219
+ if (!result) throw new TestingError('service_unavailable');
220
+ // Every way to completion ends here, an operator's recheck included.
221
+ this.stalls.delete(current.id);
222
+ return result;
143
223
  } finally { await session.close(); }
144
224
  }
145
225
 
@@ -0,0 +1,35 @@
1
+ import { TestingError } from './errors.js';
2
+
3
+ export interface ITestingErrorLogFields {
4
+ errorName: string;
5
+ errorCode?: string | number;
6
+ errorMessage: string;
7
+ }
8
+
9
+ const withheldMessage = 'withheld: the message may hold key material or a token';
10
+ /** A PEM armor line, or an unbroken run long enough to be a token, a key or a digest. */
11
+ const secretShape = /-----BEGIN|[A-Za-z0-9+_=-]{40,}/;
12
+ /** Shorter than the run `secretShape` withholds, so a code never carries what a message may not. */
13
+ const codeShape = /^[A-Za-z0-9_.-]{1,39}$/;
14
+
15
+ /**
16
+ * What a log line may say about a caught error. A caught error is foreign text: it can quote a provider
17
+ * response, a certificate or a credential. The logger never gets the error itself, its stack or its cause.
18
+ */
19
+ export function testingErrorLogFields(error: unknown): ITestingErrorLogFields {
20
+ // An error's `message` and `name` are whatever its thrower assigned: only a string is text to log.
21
+ const foreignMessage: unknown = error instanceof Error ? error.message : error;
22
+ const message = typeof foreignMessage === 'string' ? foreignMessage : '';
23
+ const foreignName: unknown = error instanceof Error ? error.name : undefined;
24
+ const name = typeof foreignName === 'string' ? foreignName : typeof error;
25
+ const code = error instanceof TestingError ? error.publicError.code
26
+ : typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
27
+ const loggable = typeof code === 'string' ? codeShape.test(code) : Number.isSafeInteger(code);
28
+ return {
29
+ errorName: name.slice(0, 64),
30
+ ...(loggable ? { errorCode: code as string | number } : {}),
31
+ // The whole message is judged before it is cut: a secret behind the first line still withholds it.
32
+ errorMessage: secretShape.test(message) ? withheldMessage
33
+ : message.split(/[\r\n\u2028\u2029]/, 1)[0].replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 300),
34
+ };
35
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.7.1',
6
+ version: '34.7.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }