@serve.zone/dcrouter 17.2.4 → 17.4.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/deno.json +1 -1
- package/dist_serve/bundle.js +2024 -1758
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +11 -1
- package/dist_ts/classes.dcrouter.js +63 -3
- package/dist_ts/config/classes.api-token-manager.d.ts +20 -2
- package/dist_ts/config/classes.api-token-manager.js +201 -6
- package/dist_ts/config/classes.gateway-client-manager.d.ts +36 -2
- package/dist_ts/config/classes.gateway-client-manager.js +391 -27
- package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
- package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
- package/dist_ts/db/documents/classes.cached.email.d.ts +10 -1
- package/dist_ts/db/documents/classes.cached.email.js +27 -2
- package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
- package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
- package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
- package/dist_ts/db/documents/index.d.ts +1 -0
- package/dist_ts/db/documents/index.js +2 -1
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
- package/dist_ts/dns/index.d.ts +1 -0
- package/dist_ts/dns/index.js +2 -1
- package/dist_ts/dns/manager.dns.d.ts +19 -0
- package/dist_ts/dns/manager.dns.js +25 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
- package/dist_ts/email/classes.accepted-email-spool.js +93 -1
- package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
- package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
- package/dist_ts/email/smartmta-storage-policy.js +3 -1
- package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
- package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
- package/dist_ts/monitoring/index.d.ts +1 -0
- package/dist_ts/monitoring/index.js +2 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
- package/dist_ts/opsserver/classes.opsserver.js +3 -1
- package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
- package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
- package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
- package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
- package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
- package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
- package/dist_ts_interfaces/data/config-events.d.ts +34 -0
- package/dist_ts_interfaces/data/config-events.js +8 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/route-management.d.ts +28 -1
- package/dist_ts_interfaces/data/route-management.js +3 -1
- package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
- package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
- package/dist_ts_interfaces/requests/config-events.js +4 -0
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
- package/dist_ts_interfaces/requests/realtime.js +2 -1
- package/dist_ts_migrations/index.js +148 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/config-events.d.ts +19 -0
- package/dist_ts_web/appstate/config-events.js +68 -0
- package/dist_ts_web/appstate/runtime.js +7 -1
- package/dist_ts_web/appstate.d.ts +1 -0
- package/dist_ts_web/appstate.js +2 -1
- package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
- package/dist_ts_web/elements/ops-platform-events.js +361 -0
- package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
- package/dist_ts_web/elements/ops-view-logs.js +96 -9
- package/package.json +4 -4
- package/readme.plan.md +22 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +72 -2
- package/ts/config/classes.api-token-manager.ts +237 -4
- package/ts/config/classes.gateway-client-manager.ts +463 -19
- package/ts/db/documents/classes.api-token.doc.ts +8 -1
- package/ts/db/documents/classes.cached.email.ts +21 -1
- package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
- package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
- package/ts/db/documents/index.ts +1 -0
- package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
- package/ts/dns/index.ts +1 -0
- package/ts/dns/manager.dns.ts +36 -0
- package/ts/email/classes.accepted-email-spool.ts +111 -0
- package/ts/email/classes.smartmta-storage-manager.ts +57 -1
- package/ts/email/smartmta-storage-policy.ts +2 -0
- package/ts/monitoring/classes.ops-event-manager.ts +99 -0
- package/ts/monitoring/index.ts +2 -1
- package/ts/opsserver/classes.opsserver.ts +2 -0
- package/ts/opsserver/handlers/certificate.handler.ts +51 -4
- package/ts/opsserver/handlers/config-event.handler.ts +62 -0
- package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
- package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
- package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
- package/ts/opsserver/handlers/index.ts +1 -0
- package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
- package/ts/plugins.ts +2 -0
- package/ts_apiclient/classes.gatewayclient.ts +45 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/config-events.ts +98 -0
- package/ts_web/appstate/runtime.ts +8 -1
- package/ts_web/appstate.ts +1 -0
- package/ts_web/elements/ops-platform-events.ts +315 -0
- package/ts_web/elements/ops-view-logs.ts +84 -7
package/ts/dns/index.ts
CHANGED
package/ts/dns/manager.dns.ts
CHANGED
|
@@ -961,6 +961,42 @@ export class DnsManager {
|
|
|
961
961
|
return { success: true, id: doc.id };
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
/**
|
|
965
|
+
* Adopt an already-correct record into an automation lifecycle without
|
|
966
|
+
* mutating provider DNS. The expected value fields make adoption fail closed
|
|
967
|
+
* if the provider mirror changed between inspection and persistence.
|
|
968
|
+
*/
|
|
969
|
+
public async adoptRecordManagement(args: {
|
|
970
|
+
id: string;
|
|
971
|
+
name: string;
|
|
972
|
+
type: TDnsRecordType;
|
|
973
|
+
value: string;
|
|
974
|
+
ttl: number;
|
|
975
|
+
proxied: boolean;
|
|
976
|
+
managedBy: string;
|
|
977
|
+
managedOwnerId: string;
|
|
978
|
+
managedRecordKey: string;
|
|
979
|
+
}): Promise<{ success: boolean; message?: string }> {
|
|
980
|
+
const doc = await DnsRecordDoc.findById(args.id);
|
|
981
|
+
if (!doc) return { success: false, message: 'Record not found' };
|
|
982
|
+
if (
|
|
983
|
+
doc.name.toLowerCase().replace(/\.+$/, '') !== args.name.toLowerCase().replace(/\.+$/, '')
|
|
984
|
+
|| doc.type !== args.type
|
|
985
|
+
|| doc.value !== args.value
|
|
986
|
+
|| doc.ttl !== args.ttl
|
|
987
|
+
|| doc.proxied !== args.proxied
|
|
988
|
+
|| doc.managedBy
|
|
989
|
+
) {
|
|
990
|
+
return { success: false, message: 'DNS record changed before provenance adoption' };
|
|
991
|
+
}
|
|
992
|
+
doc.managedBy = args.managedBy;
|
|
993
|
+
doc.managedOwnerId = args.managedOwnerId;
|
|
994
|
+
doc.managedRecordKey = args.managedRecordKey;
|
|
995
|
+
doc.updatedAt = Date.now();
|
|
996
|
+
await doc.save();
|
|
997
|
+
return { success: true };
|
|
998
|
+
}
|
|
999
|
+
|
|
964
1000
|
public async updateRecord(args: {
|
|
965
1001
|
id: string;
|
|
966
1002
|
name?: string;
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from '../db/documents/classes.cached.email.js';
|
|
8
8
|
import type {
|
|
9
9
|
Email,
|
|
10
|
+
IAcceptEnvelopeContext,
|
|
10
11
|
IExtendedSmtpSession,
|
|
11
12
|
IMessageAcceptanceContext,
|
|
12
13
|
IMessageAcceptanceDecision,
|
|
@@ -20,6 +21,8 @@ const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
|
|
|
20
21
|
const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
|
|
21
22
|
const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
|
|
22
23
|
const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** Retention for catch-all stored inbound mail (30 days). */
|
|
25
|
+
const INBOUND_STORE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
26
|
|
|
24
27
|
export type TSmartMtaQueueItemLike = {
|
|
25
28
|
id?: string;
|
|
@@ -252,6 +255,114 @@ export class AcceptedEmailSpool {
|
|
|
252
255
|
};
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Durable acceptance for envelopes containing at least one catch-all store
|
|
260
|
+
* route: anchors the exact raw message + metadata as a CachedEmail before
|
|
261
|
+
* SMTP 250, then dispatches any non-store recipients through SmartMTA's
|
|
262
|
+
* exact-plan executor. Store recipients are fulfilled by the anchor itself.
|
|
263
|
+
*/
|
|
264
|
+
public async acceptEnvelope(
|
|
265
|
+
context: IAcceptEnvelopeContext,
|
|
266
|
+
emailServer: UnifiedEmailServer,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
if (!this.dcRouterRef.dcRouterDb?.isReady()) {
|
|
269
|
+
throw new Error('DcRouterDb is not available for durable envelope acceptance');
|
|
270
|
+
}
|
|
271
|
+
this.throwIfMessageAcceptanceAborted(context.abortSignal);
|
|
272
|
+
|
|
273
|
+
const email = context.email;
|
|
274
|
+
const session = context.session;
|
|
275
|
+
const cachedEmail = CachedEmail.createNew();
|
|
276
|
+
this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
|
|
277
|
+
email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
|
|
278
|
+
cachedEmail.messageId = email.headers['Message-ID'] || email.headers['message-id'] || cachedEmail.id;
|
|
279
|
+
cachedEmail.from = context.envelope.mailFrom || email.from || '';
|
|
280
|
+
cachedEmail.to = context.envelope.rcptTo.length > 0
|
|
281
|
+
? [...context.envelope.rcptTo]
|
|
282
|
+
: Array.isArray(email.to) ? email.to : [];
|
|
283
|
+
cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
|
|
284
|
+
cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
|
|
285
|
+
cachedEmail.subject = email.subject || '';
|
|
286
|
+
cachedEmail.attachmentCount = context.attachmentCount;
|
|
287
|
+
if (context.securityResults) {
|
|
288
|
+
cachedEmail.inboundSecurityResults = JSON.stringify(context.securityResults);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Build the exact per-recipient dispatch plan before durably committing,
|
|
292
|
+
// so an unplannable envelope is refused instead of half-anchored.
|
|
293
|
+
const idempotencyKeys = context.resolvedRecipientRoutes.map(
|
|
294
|
+
(resolution) => `${cachedEmail.id}:${resolution.recipient}`,
|
|
295
|
+
);
|
|
296
|
+
const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
|
|
297
|
+
const nonStoreEntries = plan.filter((entry) => entry.action.type !== 'store');
|
|
298
|
+
|
|
299
|
+
const persistedRawMessage = this.setDcRouterCacheIdHeader(
|
|
300
|
+
context.rawMessage.toString('utf8'),
|
|
301
|
+
cachedEmail.id,
|
|
302
|
+
);
|
|
303
|
+
await this.persistRawMessage(cachedEmail, persistedRawMessage);
|
|
304
|
+
cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
|
|
305
|
+
cachedEmail.acceptedAt = Date.now();
|
|
306
|
+
cachedEmail.markStored(INBOUND_STORE_RETENTION_MS);
|
|
307
|
+
cachedEmail.routeData = JSON.stringify({
|
|
308
|
+
acceptedAt: new Date().toISOString(),
|
|
309
|
+
acceptance: 'durable-envelope',
|
|
310
|
+
recipientPlans: plan.map((entry) => ({
|
|
311
|
+
recipient: entry.recipient,
|
|
312
|
+
routeName: entry.routeName,
|
|
313
|
+
source: entry.source,
|
|
314
|
+
actionType: entry.action.type,
|
|
315
|
+
})),
|
|
316
|
+
session: {
|
|
317
|
+
id: session.id,
|
|
318
|
+
remoteAddress: session.remoteAddress,
|
|
319
|
+
clientHostname: session.clientHostname,
|
|
320
|
+
secure: !!session.secure,
|
|
321
|
+
authenticated: !!session.authenticated,
|
|
322
|
+
user: session.user,
|
|
323
|
+
envelope: context.envelope,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
cachedEmail.updateSenderDomain();
|
|
327
|
+
cachedEmail.updateRecipientDomains();
|
|
328
|
+
try {
|
|
329
|
+
await cachedEmail.save();
|
|
330
|
+
await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-stored');
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await this.deleteRawMessage(cachedEmail).catch(() => undefined);
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
if (context.abortSignal?.aborted) {
|
|
336
|
+
cachedEmail.markFailed('Envelope acceptance aborted before SMTP success');
|
|
337
|
+
await cachedEmail.save();
|
|
338
|
+
await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-acceptance-aborted');
|
|
339
|
+
throw new Error('Envelope acceptance aborted before SMTP success');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (nonStoreEntries.length > 0) {
|
|
343
|
+
const dispatchResult = await emailServer.dispatchAcceptedEnvelope(
|
|
344
|
+
context.rawMessage,
|
|
345
|
+
plan,
|
|
346
|
+
{
|
|
347
|
+
mailFrom: context.envelope.mailFrom,
|
|
348
|
+
session: {
|
|
349
|
+
id: session.id,
|
|
350
|
+
remoteAddress: session.remoteAddress,
|
|
351
|
+
clientHostname: session.clientHostname,
|
|
352
|
+
secure: !!session.secure,
|
|
353
|
+
authenticated: !!session.authenticated,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
const failed = dispatchResult.results.filter(
|
|
358
|
+
(result) => result.status === 'failed' || result.status === 'rejected',
|
|
359
|
+
);
|
|
360
|
+
if (failed.length > 0) {
|
|
361
|
+
logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
/** Start the interval-driven spool processor and trigger an immediate run. */
|
|
256
367
|
public start(): void {
|
|
257
368
|
this.clearSpoolTimer();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
IStorageManager,
|
|
4
|
+
IStorageListPageOptions,
|
|
5
|
+
IStorageListPageResult,
|
|
6
|
+
} from '@push.rocks/smartmta';
|
|
3
7
|
import {
|
|
4
8
|
getSmartMtaTextNamespace,
|
|
5
9
|
hashSmartMtaStorageKey,
|
|
@@ -98,6 +102,58 @@ export class SmartMtaStorageManager implements IStorageManager {
|
|
|
98
102
|
.sort();
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Bounded, deletion-stable page listing: seeks strictly beyond the last
|
|
107
|
+
* returned key, so tokens stay valid while pages are deleted and an
|
|
108
|
+
* otherwise stable prefix is always exhausted.
|
|
109
|
+
*/
|
|
110
|
+
public async listPage(
|
|
111
|
+
prefix: string,
|
|
112
|
+
options: IStorageListPageOptions,
|
|
113
|
+
): Promise<IStorageListPageResult> {
|
|
114
|
+
const normalizedPrefix = normalizeSmartMtaStorageKey(prefix);
|
|
115
|
+
getSmartMtaTextNamespace(normalizedPrefix);
|
|
116
|
+
const requestedLimit = Number.isSafeInteger(options?.limit) && options.limit > 0
|
|
117
|
+
? options.limit
|
|
118
|
+
: 100;
|
|
119
|
+
const limit = Math.min(requestedLimit, 1000);
|
|
120
|
+
let seekAfter: string | undefined;
|
|
121
|
+
if (options?.continuationToken) {
|
|
122
|
+
const decoded = Buffer.from(options.continuationToken, 'base64url').toString('utf8');
|
|
123
|
+
if (!decoded.startsWith(normalizedPrefix)) {
|
|
124
|
+
throw new Error('SmartMTA storage continuation token does not match the requested prefix');
|
|
125
|
+
}
|
|
126
|
+
seekAfter = decoded;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const documents = await this.collection
|
|
130
|
+
.find({
|
|
131
|
+
key: {
|
|
132
|
+
...(seekAfter ? { $gt: seekAfter } : { $gte: normalizedPrefix }),
|
|
133
|
+
$lt: `${normalizedPrefix}`,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
.sort({ key: 1 })
|
|
137
|
+
.limit(limit + 1)
|
|
138
|
+
.toArray();
|
|
139
|
+
|
|
140
|
+
const hasMore = documents.length > limit;
|
|
141
|
+
const pageDocuments = documents.slice(0, limit);
|
|
142
|
+
const keys = pageDocuments
|
|
143
|
+
.map((document: ISmartMtaStorageDocument) => this.validateDocument(document).key)
|
|
144
|
+
.filter((key: string) => key.startsWith(normalizedPrefix));
|
|
145
|
+
const lastKey = pageDocuments.length > 0
|
|
146
|
+
? (pageDocuments[pageDocuments.length - 1] as ISmartMtaStorageDocument).key
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
keys,
|
|
151
|
+
...(hasMore && lastKey
|
|
152
|
+
? { continuationToken: Buffer.from(lastKey, 'utf8').toString('base64url') }
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
101
157
|
public async delete(key: string): Promise<void> {
|
|
102
158
|
const normalizedKey = normalizeSmartMtaStorageKey(key);
|
|
103
159
|
getSmartMtaTextNamespace(normalizedKey);
|
|
@@ -11,6 +11,8 @@ export const smartMtaTextNamespaces: readonly ISmartMtaTextNamespace[] = Object.
|
|
|
11
11
|
{ prefix: '/email/bounces/', maxBytes: 8 * 1024 * 1024 },
|
|
12
12
|
{ prefix: '/email/templates/', maxBytes: 4 * 1024 * 1024 },
|
|
13
13
|
{ prefix: '/email/routes/config.json', maxBytes: 4 * 1024 * 1024 },
|
|
14
|
+
// SmartMTA 8.1+ accepted-envelope dispatch bookkeeping (idempotent replay records).
|
|
15
|
+
{ prefix: '/email/accepted-envelope-dispatch/', maxBytes: 64 * 1024 },
|
|
14
16
|
{ prefix: '/security/ip-reputation-cache.json', maxBytes: 8 * 1024 * 1024 },
|
|
15
17
|
{ prefix: '/workhosters/mail-identities.json', maxBytes: 4 * 1024 * 1024 },
|
|
16
18
|
]);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
import {
|
|
4
|
+
OpsConfigEventDoc,
|
|
5
|
+
type IOpsConfigEventFilter,
|
|
6
|
+
} from '../db/documents/classes.ops-config-event.doc.js';
|
|
7
|
+
import type {
|
|
8
|
+
IOpsConfigEvent,
|
|
9
|
+
IOpsConfigEventCursor,
|
|
10
|
+
IOpsConfigEventContext,
|
|
11
|
+
TOpsConfigEventSeverity,
|
|
12
|
+
} from '../../ts_interfaces/data/config-events.js';
|
|
13
|
+
|
|
14
|
+
export interface IRecordOpsConfigEventOptions {
|
|
15
|
+
severity: TOpsConfigEventSeverity;
|
|
16
|
+
category: string;
|
|
17
|
+
title: string;
|
|
18
|
+
detail: string;
|
|
19
|
+
context?: IOpsConfigEventContext;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Retention for acknowledged events (90 days). */
|
|
23
|
+
const ACKNOWLEDGED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* OpsEventManager — durable, acknowledgeable platform configuration events.
|
|
28
|
+
*
|
|
29
|
+
* These are operator-facing config conflicts (e.g. gateway DNS automation
|
|
30
|
+
* overwriting a manual record), not application runtime errors. Producers call
|
|
31
|
+
* `recordEvent()` fire-and-forget; the ops UI lists and acknowledges them.
|
|
32
|
+
*
|
|
33
|
+
* Repeated occurrences of the same unacknowledged conflict coalesce into one
|
|
34
|
+
* event (refreshed timestamp/detail) so reconcile loops cannot flood the inbox.
|
|
35
|
+
*/
|
|
36
|
+
export class OpsEventManager {
|
|
37
|
+
private pruneInterval: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
|
|
39
|
+
public async start(): Promise<void> {
|
|
40
|
+
this.pruneInterval = setInterval(() => {
|
|
41
|
+
OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch((error: unknown) => {
|
|
42
|
+
logger.log('warn', `OpsEventManager prune failed: ${(error as Error).message}`);
|
|
43
|
+
});
|
|
44
|
+
}, PRUNE_INTERVAL_MS);
|
|
45
|
+
// Prune once at startup so long-stopped instances converge immediately.
|
|
46
|
+
await OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch(() => 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async stop(): Promise<void> {
|
|
50
|
+
if (this.pruneInterval) {
|
|
51
|
+
clearInterval(this.pruneInterval);
|
|
52
|
+
this.pruneInterval = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public async recordEvent(options: IRecordOpsConfigEventOptions): Promise<IOpsConfigEvent> {
|
|
57
|
+
const context = options.context || {};
|
|
58
|
+
const sourceKey = [
|
|
59
|
+
options.title,
|
|
60
|
+
context.recordName || '',
|
|
61
|
+
context.domain || '',
|
|
62
|
+
context.routeRef || '',
|
|
63
|
+
].join('|');
|
|
64
|
+
const openDedupeKey = plugins.crypto
|
|
65
|
+
.createHash('sha256')
|
|
66
|
+
.update(sourceKey)
|
|
67
|
+
.digest('hex');
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const { event, created } = await OpsConfigEventDoc.upsertOpenEvent({
|
|
70
|
+
id: `oce_${now.toString(36)}_${Math.random().toString(36).slice(2, 10)}`,
|
|
71
|
+
openDedupeKey,
|
|
72
|
+
severity: options.severity,
|
|
73
|
+
category: options.category,
|
|
74
|
+
title: options.title,
|
|
75
|
+
detail: options.detail,
|
|
76
|
+
context,
|
|
77
|
+
createdAt: now,
|
|
78
|
+
});
|
|
79
|
+
if (created) {
|
|
80
|
+
logger.log(
|
|
81
|
+
options.severity === 'error' ? 'error' : 'warn',
|
|
82
|
+
`Platform config event [${options.category}] ${options.title}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return event.toApiObject();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public async listEvents(
|
|
89
|
+
filter: IOpsConfigEventFilter = {},
|
|
90
|
+
): Promise<{ events: IOpsConfigEvent[]; total: number; unacknowledgedCount: number; nextCursor?: IOpsConfigEventCursor }> {
|
|
91
|
+
const { events, total, nextCursor } = await OpsConfigEventDoc.findFiltered(filter);
|
|
92
|
+
const unacknowledgedCount = await OpsConfigEventDoc.countUnacknowledged();
|
|
93
|
+
return { events: events.map((doc) => doc.toApiObject()), total, unacknowledgedCount, nextCursor };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public async acknowledgeEvents(ids: string[], userId: string): Promise<number> {
|
|
97
|
+
return OpsConfigEventDoc.acknowledgeByIds(ids, userId);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/ts/monitoring/index.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export * from './classes.metricsmanager.js';
|
|
1
|
+
export * from './classes.metricsmanager.js';
|
|
2
|
+
export * from './classes.ops-event-manager.js';
|
|
@@ -41,6 +41,7 @@ export class OpsServer {
|
|
|
41
41
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
|
42
42
|
private emailDomainHandler!: handlers.EmailDomainHandler;
|
|
43
43
|
private gatewayClientHandler!: handlers.GatewayClientHandler;
|
|
44
|
+
private configEventHandler!: handlers.ConfigEventHandler;
|
|
44
45
|
private smtpRealtimeUnsubscribe?: () => void;
|
|
45
46
|
private emailQueueRealtimeUnsubscribe?: () => void;
|
|
46
47
|
|
|
@@ -105,6 +106,7 @@ export class OpsServer {
|
|
|
105
106
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
|
106
107
|
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
|
107
108
|
this.gatewayClientHandler = new handlers.GatewayClientHandler(this);
|
|
109
|
+
this.configEventHandler = new handlers.ConfigEventHandler(this);
|
|
108
110
|
this.smtpRealtimeUnsubscribe =
|
|
109
111
|
this.dcRouterRef.acceptedEmailSpool?.onSmtpTransactionPersisted?.((eventArg) => {
|
|
110
112
|
this.invalidateRealtime(['smtpAttempts', 'emailQueue'], {
|
|
@@ -4,6 +4,12 @@ import * as interfaces from '../../../ts_interfaces/index.js';
|
|
|
4
4
|
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
|
5
5
|
import { logger } from '../../logger.js';
|
|
6
6
|
import { requireOpsAuth } from '../helpers/auth.js';
|
|
7
|
+
import {
|
|
8
|
+
hasOwnedEnabledGatewayRoute,
|
|
9
|
+
matchesGatewayHostnamePatterns,
|
|
10
|
+
normalizeGatewayHostname,
|
|
11
|
+
requireGatewayMachineAuth,
|
|
12
|
+
} from '../helpers/gateway-client-auth.js';
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
|
|
@@ -45,6 +51,40 @@ export class CertificateHandler {
|
|
|
45
51
|
return auth.userId;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
private async requireCertificateAuth(
|
|
55
|
+
requestArg: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
|
56
|
+
domainArg: string,
|
|
57
|
+
capabilityArg: 'readCertificates' | 'requestCertificates',
|
|
58
|
+
): Promise<{ userId: string; domain: string }> {
|
|
59
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
60
|
+
const candidate = requestArg.apiToken && tokenManager
|
|
61
|
+
? await tokenManager.validateToken(requestArg.apiToken)
|
|
62
|
+
: null;
|
|
63
|
+
if (candidate?.policy?.role === 'gatewayClient') {
|
|
64
|
+
const auth = await requireGatewayMachineAuth(
|
|
65
|
+
this.opsServerRef,
|
|
66
|
+
requestArg,
|
|
67
|
+
capabilityArg === 'readCertificates' ? 'gateway-clients:read' : 'gateway-clients:write',
|
|
68
|
+
);
|
|
69
|
+
if (auth.policy?.capabilities?.[capabilityArg] !== true) {
|
|
70
|
+
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capabilityArg}`);
|
|
71
|
+
}
|
|
72
|
+
const domain = normalizeGatewayHostname(domainArg);
|
|
73
|
+
if (!matchesGatewayHostnamePatterns(domain, auth.policy.hostnamePatterns || [], auth.managedZoneNames)) {
|
|
74
|
+
throw new plugins.typedrequest.TypedResponseError('certificate hostname is outside gateway-client policy');
|
|
75
|
+
}
|
|
76
|
+
if (!hasOwnedEnabledGatewayRoute(this.opsServerRef, auth, domain)) {
|
|
77
|
+
throw new plugins.typedrequest.TypedResponseError('certificate hostname has no owned enabled gateway route');
|
|
78
|
+
}
|
|
79
|
+
return { userId: auth.userId, domain };
|
|
80
|
+
}
|
|
81
|
+
const userId = await this.requireAuth(
|
|
82
|
+
requestArg,
|
|
83
|
+
capabilityArg === 'readCertificates' ? 'certificates:read' : 'certificates:write',
|
|
84
|
+
);
|
|
85
|
+
return { userId, domain: normalizeGatewayHostname(domainArg) };
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
private registerHandlers(): void {
|
|
49
89
|
const router = this.typedrouter;
|
|
50
90
|
|
|
@@ -66,8 +106,8 @@ export class CertificateHandler {
|
|
|
66
106
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
|
67
107
|
'reprovisionCertificateDomain',
|
|
68
108
|
async (dataArg) => {
|
|
69
|
-
await this.
|
|
70
|
-
const result = await this.reprovisionCertificateDomain(
|
|
109
|
+
const auth = await this.requireCertificateAuth(dataArg, dataArg.domain, 'requestCertificates');
|
|
110
|
+
const result = await this.reprovisionCertificateDomain(auth.domain, dataArg.forceRenew);
|
|
71
111
|
if (result.success) {
|
|
72
112
|
this.opsServerRef.invalidateRealtime?.('certificates', {
|
|
73
113
|
changedIds: [dataArg.domain],
|
|
@@ -102,8 +142,8 @@ export class CertificateHandler {
|
|
|
102
142
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ExportCertificate>(
|
|
103
143
|
'exportCertificate',
|
|
104
144
|
async (dataArg) => {
|
|
105
|
-
await this.
|
|
106
|
-
return this.exportCertificate(
|
|
145
|
+
const auth = await this.requireCertificateAuth(dataArg, dataArg.domain, 'readCertificates');
|
|
146
|
+
return this.exportCertificate(auth.domain);
|
|
107
147
|
}
|
|
108
148
|
)
|
|
109
149
|
);
|
|
@@ -113,6 +153,13 @@ export class CertificateHandler {
|
|
|
113
153
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ImportCertificate>(
|
|
114
154
|
'importCertificate',
|
|
115
155
|
async (dataArg) => {
|
|
156
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
157
|
+
const candidate = dataArg.apiToken && tokenManager
|
|
158
|
+
? await tokenManager.validateToken(dataArg.apiToken)
|
|
159
|
+
: null;
|
|
160
|
+
if (candidate?.policy?.role === 'gatewayClient') {
|
|
161
|
+
throw new plugins.typedrequest.TypedResponseError('gateway-client credentials cannot import certificates');
|
|
162
|
+
}
|
|
116
163
|
await this.requireAuth(dataArg, 'certificates:write');
|
|
117
164
|
const result = await this.importCertificate(dataArg.cert);
|
|
118
165
|
if (result.success) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
|
+
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
4
|
+
import { requireOpsAuth } from '../helpers/auth.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handlers for the platform configuration event inbox (list + acknowledge).
|
|
8
|
+
*/
|
|
9
|
+
export class ConfigEventHandler {
|
|
10
|
+
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
11
|
+
|
|
12
|
+
constructor(private opsServerRef: OpsServer) {
|
|
13
|
+
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
14
|
+
this.registerHandlers();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private registerHandlers(): void {
|
|
18
|
+
this.typedrouter.addTypedHandler(
|
|
19
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListConfigEvents>(
|
|
20
|
+
'listConfigEvents',
|
|
21
|
+
async (dataArg) => {
|
|
22
|
+
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config-events:read' });
|
|
23
|
+
const opsEventManager = this.opsServerRef.dcRouterRef.opsEventManager;
|
|
24
|
+
if (!opsEventManager) {
|
|
25
|
+
return { events: [], total: 0, unacknowledgedCount: 0, nextCursor: undefined };
|
|
26
|
+
}
|
|
27
|
+
return opsEventManager.listEvents({
|
|
28
|
+
acknowledged: dataArg.acknowledged,
|
|
29
|
+
severity: dataArg.severity,
|
|
30
|
+
category: dataArg.category,
|
|
31
|
+
limit: dataArg.limit,
|
|
32
|
+
cursor: dataArg.cursor,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
this.typedrouter.addTypedHandler(
|
|
39
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AcknowledgeConfigEvents>(
|
|
40
|
+
'acknowledgeConfigEvents',
|
|
41
|
+
async (dataArg) => {
|
|
42
|
+
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
|
43
|
+
scope: 'config-events:write',
|
|
44
|
+
requireAdminIdentity: true,
|
|
45
|
+
});
|
|
46
|
+
const opsEventManager = this.opsServerRef.dcRouterRef.opsEventManager;
|
|
47
|
+
if (!opsEventManager) {
|
|
48
|
+
return { success: false, acknowledgedCount: 0, message: 'OpsEventManager not initialized' };
|
|
49
|
+
}
|
|
50
|
+
const acknowledgedCount = await opsEventManager.acknowledgeEvents(
|
|
51
|
+
dataArg.ids || [],
|
|
52
|
+
auth.userId,
|
|
53
|
+
);
|
|
54
|
+
this.opsServerRef.invalidateRealtime?.(['configEvents'], {
|
|
55
|
+
reason: 'config-events-acknowledged',
|
|
56
|
+
});
|
|
57
|
+
return { success: true, acknowledgedCount };
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
3
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
4
|
+
import { logger } from '../../logger.js';
|
|
4
5
|
import { requireOpsAuth } from '../helpers/auth.js';
|
|
6
|
+
import { normalizeGatewayHostname } from '../helpers/gateway-client-auth.js';
|
|
7
|
+
import { getActiveGatewayDnsClaims } from '../../dns/classes.gateway-route-dns-reconciler.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* CRUD handlers for DnsRecordDoc.
|
|
@@ -62,6 +65,14 @@ export class DnsRecordHandler {
|
|
|
62
65
|
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
|
63
66
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
|
64
67
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
|
68
|
+
const claimError = await this.rejectClaimedHostnameMutation(
|
|
69
|
+
dataArg.domainId,
|
|
70
|
+
dataArg.name,
|
|
71
|
+
dataArg.type,
|
|
72
|
+
userId,
|
|
73
|
+
'create',
|
|
74
|
+
);
|
|
75
|
+
if (claimError) return { success: false, message: claimError };
|
|
65
76
|
const result = await dnsManager.createRecord({
|
|
66
77
|
domainId: dataArg.domainId,
|
|
67
78
|
name: dataArg.name,
|
|
@@ -87,9 +98,30 @@ export class DnsRecordHandler {
|
|
|
87
98
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
|
88
99
|
'updateDnsRecord',
|
|
89
100
|
async (dataArg) => {
|
|
90
|
-
await this.requireAuth(dataArg, 'dns-records:write');
|
|
101
|
+
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
|
91
102
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
|
92
103
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
|
104
|
+
const existing = await dnsManager.getRecord(dataArg.id);
|
|
105
|
+
if (existing) {
|
|
106
|
+
const currentClaimError = await this.rejectClaimedHostnameMutation(
|
|
107
|
+
existing.domainId,
|
|
108
|
+
existing.name,
|
|
109
|
+
existing.type,
|
|
110
|
+
userId,
|
|
111
|
+
'update',
|
|
112
|
+
);
|
|
113
|
+
if (currentClaimError) return { success: false, message: currentClaimError };
|
|
114
|
+
if (dataArg.name !== undefined) {
|
|
115
|
+
const nextClaimError = await this.rejectClaimedHostnameMutation(
|
|
116
|
+
existing.domainId,
|
|
117
|
+
dataArg.name,
|
|
118
|
+
existing.type,
|
|
119
|
+
userId,
|
|
120
|
+
'update',
|
|
121
|
+
);
|
|
122
|
+
if (nextClaimError) return { success: false, message: nextClaimError };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
93
125
|
const result = await dnsManager.updateRecord({
|
|
94
126
|
id: dataArg.id,
|
|
95
127
|
name: dataArg.name,
|
|
@@ -113,9 +145,20 @@ export class DnsRecordHandler {
|
|
|
113
145
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
|
114
146
|
'deleteDnsRecord',
|
|
115
147
|
async (dataArg) => {
|
|
116
|
-
await this.requireAuth(dataArg, 'dns-records:write');
|
|
148
|
+
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
|
117
149
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
|
118
150
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
|
151
|
+
const existing = await dnsManager.getRecord(dataArg.id);
|
|
152
|
+
if (existing) {
|
|
153
|
+
const claimError = await this.rejectClaimedHostnameMutation(
|
|
154
|
+
existing.domainId,
|
|
155
|
+
existing.name,
|
|
156
|
+
existing.type,
|
|
157
|
+
userId,
|
|
158
|
+
'delete',
|
|
159
|
+
);
|
|
160
|
+
if (claimError) return { success: false, message: claimError };
|
|
161
|
+
}
|
|
119
162
|
const result = await dnsManager.deleteRecord(dataArg.id);
|
|
120
163
|
if (result.success) {
|
|
121
164
|
this.opsServerRef.invalidateRealtime?.(['dns', 'emailDomains'], {
|
|
@@ -128,4 +171,60 @@ export class DnsRecordHandler {
|
|
|
128
171
|
),
|
|
129
172
|
);
|
|
130
173
|
}
|
|
174
|
+
|
|
175
|
+
private async rejectClaimedHostnameMutation(
|
|
176
|
+
domainIdArg: string,
|
|
177
|
+
hostnameArg: string,
|
|
178
|
+
recordTypeArg: interfaces.data.TDnsRecordType,
|
|
179
|
+
actorArg: string,
|
|
180
|
+
actionArg: 'create' | 'update' | 'delete',
|
|
181
|
+
): Promise<string | undefined> {
|
|
182
|
+
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
|
183
|
+
if (!dnsManager) return undefined;
|
|
184
|
+
if (recordTypeArg !== 'A' && recordTypeArg !== 'AAAA' && recordTypeArg !== 'CNAME') return undefined;
|
|
185
|
+
let hostname: string;
|
|
186
|
+
try {
|
|
187
|
+
hostname = normalizeGatewayHostname(hostnameArg);
|
|
188
|
+
} catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
const claims = getActiveGatewayDnsClaims(this.opsServerRef.dcRouterRef, hostname);
|
|
192
|
+
if (!claims.length) return undefined;
|
|
193
|
+
const ownerSummary = claims
|
|
194
|
+
.map((claimArg) => `${claimArg.gatewayClientId} (${claimArg.routeExternalKeys.join(', ')})`)
|
|
195
|
+
.join('; ');
|
|
196
|
+
const message = `DNS hostname ${hostname} is claimed by gateway client route(s) ${ownerSummary}; actor ${actorArg} cannot ${actionArg} ${recordTypeArg} records at this hostname`;
|
|
197
|
+
let domainName: string | undefined;
|
|
198
|
+
try {
|
|
199
|
+
domainName = (await dnsManager.getDomain(domainIdArg))?.name;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logger.log('warn', `Failed to load DNS domain context for rejected mutation on ${hostname}: ${(error as Error).message}`);
|
|
202
|
+
}
|
|
203
|
+
if (this.opsServerRef.dcRouterRef.opsEventManager) {
|
|
204
|
+
try {
|
|
205
|
+
await this.opsServerRef.dcRouterRef.opsEventManager.recordEvent({
|
|
206
|
+
severity: 'error',
|
|
207
|
+
category: 'dns-conflict',
|
|
208
|
+
title: 'Rejected manual DNS mutation on gateway-managed hostname',
|
|
209
|
+
detail: message,
|
|
210
|
+
context: {
|
|
211
|
+
domain: domainName,
|
|
212
|
+
recordName: hostname,
|
|
213
|
+
routeRef: claims.flatMap((claimArg) => claimArg.routeExternalKeys).join(', '),
|
|
214
|
+
actor: actorArg,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.log('warn', `Failed to record rejected gateway DNS mutation for ${hostname}: ${(error as Error).message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
this.opsServerRef.invalidateRealtime?.('configEvents', {
|
|
223
|
+
reason: 'gateway-dns-manual-mutation-rejected',
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logger.log('warn', `Failed to invalidate config events after rejected DNS mutation for ${hostname}: ${(error as Error).message}`);
|
|
227
|
+
}
|
|
228
|
+
return message;
|
|
229
|
+
}
|
|
131
230
|
}
|