@serve.zone/dcrouter 17.1.4 → 17.1.5
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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +8 -2
- package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +10 -0
- package/dist_ts/db/documents/classes.ip-intelligence.doc.js +63 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
- package/dist_ts/dns/classes.dns-server-runtime.js +8 -4
- package/dist_ts/email/classes.email-domain.manager.js +27 -15
- package/dist_ts/email/classes.mail-dns-sync.d.ts +1 -2
- package/dist_ts/email/classes.mail-dns-sync.js +58 -31
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/email/mail-dns-status.d.ts +5 -0
- package/dist_ts/email/mail-dns-status.js +31 -0
- package/dist_ts/security/classes.security-policy-manager.d.ts +17 -0
- package/dist_ts/security/classes.security-policy-manager.js +171 -26
- package/dist_ts_migrations/index.js +270 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +8 -1
- package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
- package/ts/dns/classes.dns-server-runtime.ts +9 -3
- package/ts/email/classes.email-domain.manager.ts +23 -16
- package/ts/email/classes.mail-dns-sync.ts +67 -37
- package/ts/email/index.ts +1 -0
- package/ts/email/mail-dns-status.ts +35 -0
- package/ts/security/classes.security-policy-manager.ts +193 -26
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@serve.zone/dcrouter',
|
|
6
|
-
version: '17.1.
|
|
6
|
+
version: '17.1.5',
|
|
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": "17.1.
|
|
4
|
+
"version": "17.1.5",
|
|
5
5
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@push.rocks/smartbucket": "^4.6.1",
|
|
41
41
|
"@push.rocks/smartchallenge": "^1.1.0",
|
|
42
42
|
"@push.rocks/smartdata": "^7.3.1",
|
|
43
|
-
"@push.rocks/smartdb": "^2.13.
|
|
43
|
+
"@push.rocks/smartdb": "^2.13.2",
|
|
44
44
|
"@push.rocks/smartdns": "^7.12.1",
|
|
45
45
|
"@push.rocks/smartfs": "^1.6.0",
|
|
46
46
|
"@push.rocks/smartguard": "^3.1.2",
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -816,10 +816,17 @@ export class DcRouter {
|
|
|
816
816
|
// Mail DNS reconciliation starts last: it needs persistence, provider
|
|
817
817
|
// state, the SMTP DKIM creator, and live tunnel topology. Earlier events
|
|
818
818
|
// only mark the reconciler dirty and are consumed here.
|
|
819
|
+
const mailDnsSyncDependencies = [
|
|
820
|
+
'DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress',
|
|
821
|
+
];
|
|
822
|
+
if (this.options.dnsNsDomains?.length && this.options.dnsScopes?.length) {
|
|
823
|
+
mailDnsSyncDependencies.push('DnsServer');
|
|
824
|
+
}
|
|
825
|
+
|
|
819
826
|
this.serviceManager.addService(
|
|
820
827
|
new plugins.taskbuffer.Service('MailDnsSync')
|
|
821
828
|
.optional()
|
|
822
|
-
.dependsOn(
|
|
829
|
+
.dependsOn(...mailDnsSyncDependencies)
|
|
823
830
|
.withStart(async () => {
|
|
824
831
|
await this.mailDnsSync.start();
|
|
825
832
|
})
|
|
@@ -102,4 +102,79 @@ export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntell
|
|
|
102
102
|
public static async findAll(): Promise<IpIntelligenceDoc[]> {
|
|
103
103
|
return await IpIntelligenceDoc.getInstances({});
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
public static async findRecent(options: {
|
|
107
|
+
ipAddresses?: string[];
|
|
108
|
+
limit: number;
|
|
109
|
+
}): Promise<IpIntelligenceDoc[]> {
|
|
110
|
+
if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
|
|
111
|
+
throw new Error('IpIntelligenceDoc.findRecent requires a positive integer limit');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const filter = options.ipAddresses
|
|
115
|
+
? { ipAddress: { $in: options.ipAddresses } }
|
|
116
|
+
: {};
|
|
117
|
+
const collection = (IpIntelligenceDoc as typeof IpIntelligenceDoc & {
|
|
118
|
+
collection: plugins.smartdata.SmartdataCollection<IpIntelligenceDoc>;
|
|
119
|
+
}).collection;
|
|
120
|
+
const cursor = await collection.getCursor(
|
|
121
|
+
filter,
|
|
122
|
+
IpIntelligenceDoc as unknown as typeof plugins.smartdata.SmartDataDbDoc,
|
|
123
|
+
);
|
|
124
|
+
cursor.mongodbCursor.sort({ lastSeenAt: -1, _id: -1 });
|
|
125
|
+
cursor.mongodbCursor.limit(options.limit);
|
|
126
|
+
try {
|
|
127
|
+
return await cursor.toArray();
|
|
128
|
+
} finally {
|
|
129
|
+
await cursor.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public static async countAll(): Promise<number> {
|
|
134
|
+
return await DcRouterDb.getInstance()
|
|
135
|
+
.getDb()
|
|
136
|
+
.mongoDb
|
|
137
|
+
.collection('IpIntelligenceDoc')
|
|
138
|
+
.countDocuments({});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public static async findOldestIds(options: {
|
|
142
|
+
lastSeenBefore?: number;
|
|
143
|
+
limit: number;
|
|
144
|
+
}): Promise<unknown[]> {
|
|
145
|
+
if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
|
|
146
|
+
throw new Error('IpIntelligenceDoc.findOldestIds requires a positive integer limit');
|
|
147
|
+
}
|
|
148
|
+
const collection = DcRouterDb.getInstance()
|
|
149
|
+
.getDb()
|
|
150
|
+
.mongoDb
|
|
151
|
+
.collection('IpIntelligenceDoc');
|
|
152
|
+
const filter = options.lastSeenBefore === undefined
|
|
153
|
+
? {}
|
|
154
|
+
: { lastSeenAt: { $lt: options.lastSeenBefore } };
|
|
155
|
+
const cursor = collection
|
|
156
|
+
.find(filter, { projection: { _id: 1 } })
|
|
157
|
+
.sort({ lastSeenAt: 1, _id: 1 })
|
|
158
|
+
.limit(options.limit);
|
|
159
|
+
try {
|
|
160
|
+
const rows = await cursor.toArray();
|
|
161
|
+
return rows.map((row) => row._id);
|
|
162
|
+
} finally {
|
|
163
|
+
await cursor.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public static async deleteByDocumentIds(documentIds: unknown[]): Promise<number> {
|
|
168
|
+
if (documentIds.length === 0) return 0;
|
|
169
|
+
if (documentIds.length > 500 || documentIds.some((id) => id === undefined || id === null)) {
|
|
170
|
+
throw new Error('IpIntelligenceDoc.deleteByDocumentIds requires 1 to 500 document ids');
|
|
171
|
+
}
|
|
172
|
+
const deleteFilter: Record<string, any> = { _id: { $in: documentIds } };
|
|
173
|
+
const result = await DcRouterDb.getInstance()
|
|
174
|
+
.getDb()
|
|
175
|
+
.mongoDb
|
|
176
|
+
.collection('IpIntelligenceDoc')
|
|
177
|
+
.deleteMany(deleteFilter);
|
|
178
|
+
return result.deletedCount;
|
|
179
|
+
}
|
|
105
180
|
}
|
|
@@ -150,9 +150,7 @@ export class DnsServerRuntime {
|
|
|
150
150
|
|
|
151
151
|
// Hand the DnsServer to DnsManager so DB-backed local records on
|
|
152
152
|
// dcrouter-hosted domains get registered too.
|
|
153
|
-
|
|
154
|
-
await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer);
|
|
155
|
-
}
|
|
153
|
+
await this.attachDnsServer(dnsServer);
|
|
156
154
|
} catch (error) {
|
|
157
155
|
dnsServer.removeAllListeners();
|
|
158
156
|
await dnsServer.stop().catch((stopError: Error) => {
|
|
@@ -166,6 +164,14 @@ export class DnsServerRuntime {
|
|
|
166
164
|
}
|
|
167
165
|
}
|
|
168
166
|
|
|
167
|
+
private async attachDnsServer(
|
|
168
|
+
dnsServer: plugins.smartdns.dnsServerMod.DnsServer,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
if (!this.dcRouterRef.dnsManager) return;
|
|
171
|
+
await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer);
|
|
172
|
+
this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached');
|
|
173
|
+
}
|
|
174
|
+
|
|
169
175
|
/**
|
|
170
176
|
* Create the DoH socket handler SmartProxy routes hand TLS sockets to.
|
|
171
177
|
*/
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MAIL_DNS_MAX_RETRY_INTERVAL_MS,
|
|
17
17
|
MAIL_DNS_RETRY_INTERVAL_MS,
|
|
18
18
|
} from './classes.mail-dns-sync.js';
|
|
19
|
+
import { projectMailDnsIntentStatus } from './mail-dns-status.js';
|
|
19
20
|
|
|
20
21
|
export interface IEmailDomainManagerActionResult {
|
|
21
22
|
success: boolean;
|
|
@@ -325,33 +326,39 @@ export class EmailDomainManager {
|
|
|
325
326
|
type: intent.type,
|
|
326
327
|
name: intent.name,
|
|
327
328
|
value: intent.value,
|
|
328
|
-
status: intent
|
|
329
|
-
? 'valid'
|
|
330
|
-
: intent.status === 'conflict' || intent.status === 'failed'
|
|
331
|
-
? 'invalid'
|
|
332
|
-
: 'missing',
|
|
329
|
+
status: projectMailDnsIntentStatus(intent),
|
|
333
330
|
}));
|
|
334
331
|
}
|
|
335
332
|
|
|
336
333
|
public async provisionDnsRecords(id: string): Promise<IEmailDomainManagerActionResult> {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
const
|
|
334
|
+
const current = await EmailDomainDoc.findById(id);
|
|
335
|
+
if (!current) throw new Error(`Email domain not found: ${id}`);
|
|
336
|
+
const before = new Set((await DnsRecordDoc.findByDomainId(current.linkedDomainId))
|
|
337
|
+
.filter((record) => record.managedBy === 'mail-dns-reconciler').map((record) => record.id));
|
|
338
|
+
const mailDnsSync = this.dcRouter.mailDnsSync;
|
|
339
|
+
if (!mailDnsSync) throw new Error('MailDnsSync is unavailable');
|
|
340
|
+
const ticket = await mailDnsSync.syncDomain(id, `manual provision: ${id}`);
|
|
341
|
+
const doc = ticket.domain;
|
|
342
342
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
343
|
-
const provisioned =
|
|
343
|
+
const provisioned = ticket.records
|
|
344
344
|
.filter((record) => record.managedBy === 'mail-dns-reconciler' && !before.has(record.id)).length;
|
|
345
|
-
return { ...this.resultForDoc(doc,
|
|
345
|
+
return { ...this.resultForDoc(doc, true), provisioned };
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
public async validateDns(id: string): Promise<IEmailDomainManagerActionResult> {
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
const mailDnsSync = this.dcRouter.mailDnsSync;
|
|
350
|
+
if (!mailDnsSync) throw new Error('MailDnsSync is unavailable');
|
|
351
|
+
const ticket = await mailDnsSync.syncDomain(id, `manual validation: ${id}`);
|
|
352
|
+
const doc = ticket.domain;
|
|
351
353
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
352
|
-
const records =
|
|
354
|
+
const records = (doc.reconciliation?.intents || []).map((intent) => ({
|
|
355
|
+
type: intent.type,
|
|
356
|
+
name: intent.name,
|
|
357
|
+
value: intent.value,
|
|
358
|
+
status: projectMailDnsIntentStatus(intent),
|
|
359
|
+
}));
|
|
353
360
|
return {
|
|
354
|
-
...this.resultForDoc(doc,
|
|
361
|
+
...this.resultForDoc(doc, true),
|
|
355
362
|
domain: this.docToInterface(doc),
|
|
356
363
|
records,
|
|
357
364
|
};
|
|
@@ -27,14 +27,15 @@ import {
|
|
|
27
27
|
applyMailDnsVerificationResult,
|
|
28
28
|
reconcileMailDnsSingleton,
|
|
29
29
|
} from './reconcile-mail-dns-singleton.js';
|
|
30
|
+
import { projectMailDnsIntentAggregateStatus } from './mail-dns-status.js';
|
|
30
31
|
|
|
31
32
|
export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
|
|
32
33
|
export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
|
|
33
34
|
export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
34
35
|
export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
|
|
35
36
|
export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
|
|
36
|
-
strategy: '
|
|
37
|
-
allowDohFallback:
|
|
37
|
+
strategy: 'prefer-udp' as const,
|
|
38
|
+
allowDohFallback: true,
|
|
38
39
|
timeoutMs: 5_000,
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -175,7 +176,25 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
175
176
|
};
|
|
176
177
|
}
|
|
177
178
|
if (result.status === 'missing') {
|
|
178
|
-
|
|
179
|
+
const evidence = result.attempts.map((attempt) => {
|
|
180
|
+
if (attempt.status === 'missing') {
|
|
181
|
+
return `${attempt.transport}:missing:${attempt.missingReason}`;
|
|
182
|
+
}
|
|
183
|
+
if (attempt.status === 'error') {
|
|
184
|
+
return `${attempt.transport}:error:${attempt.error.code}`;
|
|
185
|
+
}
|
|
186
|
+
return `${attempt.transport}:success`;
|
|
187
|
+
}).join(', ');
|
|
188
|
+
const corroboratedMissing = (
|
|
189
|
+
result.attempts.length === 2
|
|
190
|
+
&& result.attempts[0].transport === 'udp'
|
|
191
|
+
&& result.attempts[0].status === 'missing'
|
|
192
|
+
&& result.attempts[1].transport === 'doh'
|
|
193
|
+
&& result.attempts[1].status === 'missing'
|
|
194
|
+
);
|
|
195
|
+
return corroboratedMissing
|
|
196
|
+
? { status: 'missing', reason: `${result.missingReason}; attempts=${evidence}` }
|
|
197
|
+
: { status: 'error', reason: `unconfirmed ${result.missingReason}; attempts=${evidence || 'none'}` };
|
|
179
198
|
}
|
|
180
199
|
return {
|
|
181
200
|
status: 'error',
|
|
@@ -190,7 +209,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
190
209
|
return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${query.reason}` };
|
|
191
210
|
}
|
|
192
211
|
if (query.status === 'missing') {
|
|
193
|
-
return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
|
|
212
|
+
return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing (${query.reason})` };
|
|
194
213
|
}
|
|
195
214
|
const values = query.values;
|
|
196
215
|
if (intent.type === 'MX') {
|
|
@@ -255,7 +274,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
255
274
|
return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${query.reason}` };
|
|
256
275
|
}
|
|
257
276
|
if (query.status === 'missing') {
|
|
258
|
-
return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing` };
|
|
277
|
+
return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing (${query.reason})` };
|
|
259
278
|
}
|
|
260
279
|
return evaluateMailMxValues(intents, query.values, previousValues);
|
|
261
280
|
} catch (error: unknown) {
|
|
@@ -319,6 +338,12 @@ interface IPlannedDomain {
|
|
|
319
338
|
selectorCorrectSigningSupported: boolean;
|
|
320
339
|
}
|
|
321
340
|
|
|
341
|
+
interface IZoneReconciliationResult {
|
|
342
|
+
errors: Map<string, IEmailDomainOperationError>;
|
|
343
|
+
provenProviderZoneIds: Set<string>;
|
|
344
|
+
readyZoneIds: Set<string>;
|
|
345
|
+
}
|
|
346
|
+
|
|
322
347
|
/**
|
|
323
348
|
* Sole owner of managed mail DNS. API, topology, timer, and startup events only
|
|
324
349
|
* mark this globally serialized reconciler dirty.
|
|
@@ -520,7 +545,7 @@ export class MailDnsSync {
|
|
|
520
545
|
if (outcome) Object.assign(intent, outcome);
|
|
521
546
|
if (intent.error) errors.push(intent.error);
|
|
522
547
|
}
|
|
523
|
-
await this.validateAndActivate(plan, errors, zoneResult
|
|
548
|
+
await this.validateAndActivate(plan, errors, zoneResult, zones);
|
|
524
549
|
}
|
|
525
550
|
|
|
526
551
|
const cleanupProof = await this.refreshProviderZonesForCleanup(
|
|
@@ -900,12 +925,10 @@ export class MailDnsSync {
|
|
|
900
925
|
private async reconcileZones(
|
|
901
926
|
intents: IEmailDnsRecordIntent[],
|
|
902
927
|
zones: DomainDoc[],
|
|
903
|
-
): Promise<{
|
|
904
|
-
errors: Map<string, IEmailDomainOperationError>;
|
|
905
|
-
provenProviderZoneIds: Set<string>;
|
|
906
|
-
}> {
|
|
928
|
+
): Promise<IZoneReconciliationResult> {
|
|
907
929
|
const errors = new Map<string, IEmailDomainOperationError>();
|
|
908
930
|
const provenProviderZoneIds = new Set<string>();
|
|
931
|
+
const readyZoneIds = new Set<string>();
|
|
909
932
|
const byZone = new Map<string, IEmailDnsRecordIntent[]>();
|
|
910
933
|
for (const intent of intents) {
|
|
911
934
|
if (!intent.domainId || intent.error) continue;
|
|
@@ -921,6 +944,14 @@ export class MailDnsSync {
|
|
|
921
944
|
const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
|
|
922
945
|
if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
|
|
923
946
|
provenProviderZoneIds.add(zone.id);
|
|
947
|
+
readyZoneIds.add(zone.id);
|
|
948
|
+
}
|
|
949
|
+
if (
|
|
950
|
+
zone.source === 'dcrouter'
|
|
951
|
+
&& this.dcRouterRef.dnsServer
|
|
952
|
+
&& this.dcRouterRef.dnsManager?.dnsServer === this.dcRouterRef.dnsServer
|
|
953
|
+
) {
|
|
954
|
+
readyZoneIds.add(zone.id);
|
|
924
955
|
}
|
|
925
956
|
for (const intent of this.sortIntents(zoneIntents)) {
|
|
926
957
|
await this.reconcileIntent(intent, zoneIntents);
|
|
@@ -939,7 +970,7 @@ export class MailDnsSync {
|
|
|
939
970
|
}
|
|
940
971
|
}
|
|
941
972
|
}
|
|
942
|
-
return { errors, provenProviderZoneIds };
|
|
973
|
+
return { errors, provenProviderZoneIds, readyZoneIds };
|
|
943
974
|
}
|
|
944
975
|
|
|
945
976
|
private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
|
|
@@ -1104,10 +1135,29 @@ export class MailDnsSync {
|
|
|
1104
1135
|
private async validateAndActivate(
|
|
1105
1136
|
plan: IPlannedDomain,
|
|
1106
1137
|
errors: IEmailDomainOperationError[],
|
|
1107
|
-
|
|
1138
|
+
zoneResult: IZoneReconciliationResult,
|
|
1139
|
+
zones: DomainDoc[],
|
|
1108
1140
|
): Promise<void> {
|
|
1141
|
+
for (const intent of plan.intents) {
|
|
1142
|
+
if (intent.error || zoneResult.readyZoneIds.has(intent.domainId)) continue;
|
|
1143
|
+
const zone = zones.find((candidate) => candidate.id === intent.domainId);
|
|
1144
|
+
const providerManaged = zone?.source === 'provider';
|
|
1145
|
+
const outcome: IEmailDomainOperationError = {
|
|
1146
|
+
code: providerManaged ? 'DNS_PROVIDER_ZONE_UNPROVEN' : 'DNS_SERVER_NOT_READY',
|
|
1147
|
+
message: providerManaged
|
|
1148
|
+
? `DNS provider zone ${zone?.name || intent.domainId} was not proven by this reconciliation pass`
|
|
1149
|
+
: `Authoritative DNS runtime is not ready for ${zone?.name || intent.domainId}`,
|
|
1150
|
+
retryable: true,
|
|
1151
|
+
};
|
|
1152
|
+
intent.status = 'pending';
|
|
1153
|
+
intent.verificationOutcome = 'lookup-error';
|
|
1154
|
+
intent.error = { ...outcome, recordKey: intent.key };
|
|
1155
|
+
if (!errors.some((error) => error.code === outcome.code)) errors.push(outcome);
|
|
1156
|
+
}
|
|
1109
1157
|
const verifiable = plan.intents.filter((intent) => (
|
|
1110
|
-
!intent.error
|
|
1158
|
+
!intent.error
|
|
1159
|
+
&& !zoneResult.errors.has(intent.domainId)
|
|
1160
|
+
&& zoneResult.readyZoneIds.has(intent.domainId)
|
|
1111
1161
|
));
|
|
1112
1162
|
const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
|
|
1113
1163
|
const outcome = result.outcome || (result.valid ? 'valid' : 'propagation');
|
|
@@ -1265,13 +1315,13 @@ export class MailDnsSync {
|
|
|
1265
1315
|
&& plan.revision.mxSlots.length > 0;
|
|
1266
1316
|
plan.doc.dnsStatus = {
|
|
1267
1317
|
mx: mailPublicationReady
|
|
1268
|
-
?
|
|
1318
|
+
? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
|
|
1269
1319
|
: 'unchecked',
|
|
1270
1320
|
spf: mailPublicationReady
|
|
1271
|
-
?
|
|
1321
|
+
? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
|
|
1272
1322
|
: 'unchecked',
|
|
1273
|
-
dkim:
|
|
1274
|
-
dmarc:
|
|
1323
|
+
dkim: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
|
|
1324
|
+
dmarc: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
|
|
1275
1325
|
lastCheckedAt: now,
|
|
1276
1326
|
};
|
|
1277
1327
|
plan.doc.updatedAt = now;
|
|
@@ -1307,26 +1357,6 @@ export class MailDnsSync {
|
|
|
1307
1357
|
doc.dkim.lastRotatedAt = now;
|
|
1308
1358
|
}
|
|
1309
1359
|
|
|
1310
|
-
private statusForIntent(
|
|
1311
|
-
intents: IEmailDnsRecordIntent[],
|
|
1312
|
-
predicate: (intent: IEmailDnsRecordIntent) => boolean,
|
|
1313
|
-
): 'valid' | 'missing' | 'invalid' | 'unchecked' {
|
|
1314
|
-
const selected = intents.filter(predicate);
|
|
1315
|
-
if (selected.length === 0) return 'missing';
|
|
1316
|
-
if (selected.every((intent) => intent.status === 'valid')) return 'valid';
|
|
1317
|
-
if (selected.some((intent) => (
|
|
1318
|
-
intent.verificationOutcome === 'duplicate'
|
|
1319
|
-
|| intent.verificationOutcome === 'conflict'
|
|
1320
|
-
|| intent.verificationOutcome === 'invalid'
|
|
1321
|
-
|| intent.status === 'conflict'
|
|
1322
|
-
|| intent.status === 'failed'
|
|
1323
|
-
))) return 'invalid';
|
|
1324
|
-
if (selected.some((intent) => intent.verificationOutcome === 'lookup-error')) {
|
|
1325
|
-
return 'unchecked';
|
|
1326
|
-
}
|
|
1327
|
-
return 'missing';
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
1360
|
private async refreshProviderZonesForCleanup(
|
|
1331
1361
|
docs: EmailDomainDoc[],
|
|
1332
1362
|
zones: DomainDoc[],
|
package/ts/email/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './classes.email-domain.manager.js';
|
|
|
3
3
|
export * from './classes.email-route-builder.js';
|
|
4
4
|
export * from './classes.mail-dns-sync.js';
|
|
5
5
|
export * from './mail-dns-txt-policy.js';
|
|
6
|
+
export * from './mail-dns-status.js';
|
|
6
7
|
export * from './reconcile-mail-dns-singleton.js';
|
|
7
8
|
export * from './classes.mail-egress-coordinator.js';
|
|
8
9
|
export * from './classes.mail-edge-eligibility.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IEmailDnsRecordIntent,
|
|
3
|
+
TDnsRecordStatus,
|
|
4
|
+
} from '../../ts_interfaces/data/email-domain.js';
|
|
5
|
+
|
|
6
|
+
/** Project one durable reconciliation intent into the operator-facing DNS status. */
|
|
7
|
+
export function projectMailDnsIntentStatus(
|
|
8
|
+
intent: IEmailDnsRecordIntent | undefined,
|
|
9
|
+
): TDnsRecordStatus {
|
|
10
|
+
if (!intent) return 'unchecked';
|
|
11
|
+
if (
|
|
12
|
+
intent.verificationOutcome === 'duplicate'
|
|
13
|
+
|| intent.verificationOutcome === 'conflict'
|
|
14
|
+
|| intent.verificationOutcome === 'invalid'
|
|
15
|
+
|| intent.status === 'conflict'
|
|
16
|
+
|| intent.status === 'failed'
|
|
17
|
+
) return 'invalid';
|
|
18
|
+
if (intent.verificationOutcome === 'missing') return 'missing';
|
|
19
|
+
if (intent.verificationOutcome === 'valid' && intent.status === 'valid') return 'valid';
|
|
20
|
+
return 'unchecked';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Aggregate matching intents with deterministic operator-facing precedence. */
|
|
24
|
+
export function projectMailDnsIntentAggregateStatus(
|
|
25
|
+
intents: IEmailDnsRecordIntent[],
|
|
26
|
+
predicate: (intent: IEmailDnsRecordIntent) => boolean,
|
|
27
|
+
): TDnsRecordStatus {
|
|
28
|
+
const selected = intents.filter(predicate);
|
|
29
|
+
if (selected.length === 0) return 'unchecked';
|
|
30
|
+
const statuses = selected.map(projectMailDnsIntentStatus);
|
|
31
|
+
if (statuses.includes('invalid')) return 'invalid';
|
|
32
|
+
if (statuses.includes('missing')) return 'missing';
|
|
33
|
+
if (statuses.includes('unchecked')) return 'unchecked';
|
|
34
|
+
return 'valid';
|
|
35
|
+
}
|