@serve.zone/dcrouter 15.11.0 → 16.0.1
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 +34 -34
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/classes.dcrouter.d.ts +10 -3
- package/dist_ts/classes.dcrouter.js +39 -43
- package/dist_ts/db/documents/classes.cached.email.d.ts +32 -0
- package/dist_ts/db/documents/classes.cached.email.js +67 -3
- package/dist_ts/db/documents/classes.email-domain.doc.d.ts +2 -1
- package/dist_ts/db/documents/classes.email-domain.doc.js +8 -2
- package/dist_ts/db/documents/classes.email-server-settings.doc.js +2 -2
- package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +3 -0
- package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +20 -2
- package/dist_ts/dns/classes.dns-server-runtime.js +3 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -1
- package/dist_ts/email/classes.accepted-email-spool.js +9 -1
- package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
- package/dist_ts/email/classes.email-domain.manager.js +105 -18
- package/dist_ts/email/classes.email-route-builder.js +5 -2
- package/dist_ts/email/classes.email-settings.manager.js +3 -3
- package/dist_ts/email/classes.mail-dns-sync.d.ts +27 -0
- package/dist_ts/email/classes.mail-dns-sync.js +196 -0
- package/dist_ts/email/classes.mail-egress-coordinator.d.ts +37 -0
- package/dist_ts/email/classes.mail-egress-coordinator.js +113 -0
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/opsserver/handlers/config.handler.js +4 -2
- package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
- package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
- package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
- package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
- package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
- package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
- package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
- package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
- package/dist_ts/remoteingress/index.d.ts +1 -0
- package/dist_ts/remoteingress/index.js +2 -1
- package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
- package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
- package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
- package/dist_ts_interfaces/data/remoteingress.js +5 -2
- package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
- package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
- package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
- package/dist_ts_migrations/index.js +91 -9
- package/dist_ts_web/00_commitinfo_data.js +2 -2
- package/package.json +4 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +41 -48
- package/ts/db/documents/classes.cached.email.ts +72 -1
- package/ts/db/documents/classes.email-domain.doc.ts +4 -0
- package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
- package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
- package/ts/dns/classes.dns-server-runtime.ts +2 -0
- package/ts/email/classes.accepted-email-spool.ts +12 -1
- package/ts/email/classes.email-domain.manager.ts +123 -18
- package/ts/email/classes.email-route-builder.ts +4 -1
- package/ts/email/classes.email-settings.manager.ts +2 -2
- package/ts/email/classes.mail-dns-sync.ts +218 -0
- package/ts/email/classes.mail-egress-coordinator.ts +125 -0
- package/ts/email/index.ts +2 -0
- package/ts/opsserver/handlers/config.handler.ts +3 -1
- package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
- package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
- package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
- package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
- package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
- package/ts/remoteingress/edge-derivation-routes.ts +29 -0
- package/ts/remoteingress/index.ts +1 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -5,7 +5,8 @@ import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
|
5
5
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
6
6
|
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
|
7
7
|
import type { DnsManager } from '../dns/manager.dns.js';
|
|
8
|
-
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
8
|
+
import type { IEmailDomain, IEmailDnsRecord, IEmailDomainRemoteIngress, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
9
|
+
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
9
10
|
import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -18,6 +19,7 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
|
18
19
|
export class EmailDomainManager {
|
|
19
20
|
private dcRouter: any; // DcRouter — avoids circular import
|
|
20
21
|
private baseEmailDomains: IEmailDomainConfig[] = [];
|
|
22
|
+
private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
|
|
21
23
|
|
|
22
24
|
constructor(dcRouterRef: any) {
|
|
23
25
|
this.dcRouter = dcRouterRef;
|
|
@@ -45,7 +47,10 @@ export class EmailDomainManager {
|
|
|
45
47
|
await this.syncManagedDomainsToRuntime();
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
public async stop(): Promise<void> {
|
|
50
|
+
public async stop(): Promise<void> {
|
|
51
|
+
this.dnsClient?.destroy();
|
|
52
|
+
this.dnsClient = undefined;
|
|
53
|
+
}
|
|
49
54
|
|
|
50
55
|
// ---------------------------------------------------------------------------
|
|
51
56
|
// CRUD
|
|
@@ -93,6 +98,7 @@ export class EmailDomainManager {
|
|
|
93
98
|
dkimKeySize?: number;
|
|
94
99
|
rotateKeys?: boolean;
|
|
95
100
|
rotationIntervalDays?: number;
|
|
101
|
+
remoteIngress?: IEmailDomainRemoteIngress;
|
|
96
102
|
}): Promise<IEmailDomain> {
|
|
97
103
|
// Resolve the linked DNS domain
|
|
98
104
|
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
|
@@ -150,10 +156,12 @@ export class EmailDomainManager {
|
|
|
150
156
|
dkim: 'unchecked',
|
|
151
157
|
dmarc: 'unchecked',
|
|
152
158
|
};
|
|
159
|
+
doc.remoteIngress = this.normalizeRemoteIngressPin(opts.remoteIngress, domainName);
|
|
153
160
|
doc.createdAt = now;
|
|
154
161
|
doc.updatedAt = now;
|
|
155
162
|
await doc.save();
|
|
156
163
|
await this.syncManagedDomainsToRuntime();
|
|
164
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain created: ${domainName}`);
|
|
157
165
|
|
|
158
166
|
logger.log('info', `Email domain created: ${domainName}`);
|
|
159
167
|
return this.docToInterface(doc);
|
|
@@ -165,6 +173,8 @@ export class EmailDomainManager {
|
|
|
165
173
|
rotateKeys?: boolean;
|
|
166
174
|
rotationIntervalDays?: number;
|
|
167
175
|
rateLimits?: IEmailDomain['rateLimits'];
|
|
176
|
+
/** null clears the pin. */
|
|
177
|
+
remoteIngress?: IEmailDomainRemoteIngress | null;
|
|
168
178
|
},
|
|
169
179
|
): Promise<void> {
|
|
170
180
|
const doc = await EmailDomainDoc.findById(id);
|
|
@@ -173,9 +183,43 @@ export class EmailDomainManager {
|
|
|
173
183
|
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
|
174
184
|
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
|
175
185
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
|
186
|
+
if (changes.remoteIngress !== undefined) {
|
|
187
|
+
doc.remoteIngress = changes.remoteIngress === null
|
|
188
|
+
? undefined
|
|
189
|
+
: this.normalizeRemoteIngressPin(changes.remoteIngress, doc.domain);
|
|
190
|
+
}
|
|
176
191
|
doc.updatedAt = new Date().toISOString();
|
|
177
192
|
await doc.save();
|
|
178
193
|
await this.syncManagedDomainsToRuntime();
|
|
194
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain updated: ${doc.domain}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalize a per-domain edge pin. Pins may reference edges that are not
|
|
199
|
+
* registered yet — that only warns, so pins can precede edge registration.
|
|
200
|
+
*/
|
|
201
|
+
private normalizeRemoteIngressPin(
|
|
202
|
+
pin: IEmailDomainRemoteIngress | undefined,
|
|
203
|
+
domainName: string,
|
|
204
|
+
): IEmailDomainRemoteIngress | undefined {
|
|
205
|
+
const edgeFilter = [...new Set((pin?.edgeFilter || []).map((entry) => `${entry}`.trim()).filter(Boolean))];
|
|
206
|
+
if (edgeFilter.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const manager = this.dcRouter.remoteIngressManager;
|
|
210
|
+
if (manager && manager.resolveEdgesByFilter(edgeFilter).length === 0) {
|
|
211
|
+
logger.log('warn', `Email domain ${domainName}: edge pin [${edgeFilter.join(', ')}] matches no registered enabled edge yet`);
|
|
212
|
+
}
|
|
213
|
+
return { edgeFilter };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Enabled edges pinned for this domain (empty when unpinned). */
|
|
217
|
+
private resolvePinnedEdges(doc: EmailDomainDoc): IRemoteIngress[] {
|
|
218
|
+
const edgeFilter = doc.remoteIngress?.edgeFilter;
|
|
219
|
+
if (!edgeFilter?.length) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
return this.dcRouter.remoteIngressManager?.resolveEdgesByFilter(edgeFilter) || [];
|
|
179
223
|
}
|
|
180
224
|
|
|
181
225
|
public async deleteEmailDomain(id: string): Promise<void> {
|
|
@@ -183,6 +227,7 @@ export class EmailDomainManager {
|
|
|
183
227
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
184
228
|
await doc.delete();
|
|
185
229
|
await this.syncManagedDomainsToRuntime();
|
|
230
|
+
this.dcRouter.mailDnsSync?.requestSync(`email domain deleted: ${doc.domain}`);
|
|
186
231
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
|
187
232
|
}
|
|
188
233
|
|
|
@@ -199,7 +244,10 @@ export class EmailDomainManager {
|
|
|
199
244
|
|
|
200
245
|
const domain = doc.domain;
|
|
201
246
|
const selector = doc.dkim.selector;
|
|
202
|
-
|
|
247
|
+
// Pinned domains get their own MX host whose A records are the pinned edges'
|
|
248
|
+
// public IPs; unpinned domains use the shared mail hostname.
|
|
249
|
+
const pinnedEdges = this.resolvePinnedEdges(doc);
|
|
250
|
+
const hostname = pinnedEdges.length > 0 ? `mx.${domain}` : this.emailHostname;
|
|
203
251
|
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
|
204
252
|
|
|
205
253
|
if (this.dkimCreator) {
|
|
@@ -211,13 +259,24 @@ export class EmailDomainManager {
|
|
|
211
259
|
}
|
|
212
260
|
}
|
|
213
261
|
|
|
214
|
-
|
|
262
|
+
const records = buildEmailDnsRecords({
|
|
215
263
|
domain,
|
|
216
264
|
hostname,
|
|
217
265
|
selector,
|
|
218
266
|
dkimValue,
|
|
219
267
|
statuses: doc.dnsStatus,
|
|
220
268
|
});
|
|
269
|
+
|
|
270
|
+
for (const edge of pinnedEdges) {
|
|
271
|
+
if (edge.publicIp) {
|
|
272
|
+
records.push({ type: 'A', name: hostname, value: edge.publicIp, status: doc.dnsStatus.mx });
|
|
273
|
+
}
|
|
274
|
+
if (edge.publicIpV6) {
|
|
275
|
+
records.push({ type: 'AAAA', name: hostname, value: edge.publicIpV6, status: doc.dnsStatus.mx });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return records;
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
// ---------------------------------------------------------------------------
|
|
@@ -280,7 +339,7 @@ export class EmailDomainManager {
|
|
|
280
339
|
|
|
281
340
|
const domain = doc.domain;
|
|
282
341
|
const selector = doc.dkim.selector;
|
|
283
|
-
const
|
|
342
|
+
const dnsClient = this.getDnsClient();
|
|
284
343
|
|
|
285
344
|
// MX check
|
|
286
345
|
const requiredRecords = await this.getRequiredDnsRecords(id);
|
|
@@ -290,16 +349,26 @@ export class EmailDomainManager {
|
|
|
290
349
|
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
|
291
350
|
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
|
292
351
|
|
|
293
|
-
doc.dnsStatus.mx = await this.checkMx(
|
|
352
|
+
doc.dnsStatus.mx = await this.checkMx(dnsClient, domain, mxRecord?.value);
|
|
353
|
+
|
|
354
|
+
// Pinned domains: the MX host's A records must cover the pinned edges' IPs.
|
|
355
|
+
const requiredARecords = requiredRecords.filter((record) => record.type === 'A');
|
|
356
|
+
if (doc.dnsStatus.mx === 'valid' && requiredARecords.length > 0) {
|
|
357
|
+
const mxHostname = mxRecord?.value?.split(/\s+/)[1] || `mx.${domain}`;
|
|
358
|
+
const aStatus = await this.checkARecords(dnsClient, mxHostname, requiredARecords.map((record) => record.value));
|
|
359
|
+
if (aStatus !== 'valid') {
|
|
360
|
+
doc.dnsStatus.mx = aStatus;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
294
363
|
|
|
295
364
|
// SPF check
|
|
296
|
-
doc.dnsStatus.spf = await this.checkTxtRecord(
|
|
365
|
+
doc.dnsStatus.spf = await this.checkTxtRecord(dnsClient, domain, spfRecord?.value);
|
|
297
366
|
|
|
298
367
|
// DKIM check
|
|
299
|
-
doc.dnsStatus.dkim = await this.checkTxtRecord(
|
|
368
|
+
doc.dnsStatus.dkim = await this.checkTxtRecord(dnsClient, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
|
300
369
|
|
|
301
370
|
// DMARC check
|
|
302
|
-
doc.dnsStatus.dmarc = await this.checkTxtRecord(
|
|
371
|
+
doc.dnsStatus.dmarc = await this.checkTxtRecord(dnsClient, `_dmarc.${domain}`, dmarcRecord?.value);
|
|
303
372
|
|
|
304
373
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
|
305
374
|
doc.updatedAt = new Date().toISOString();
|
|
@@ -315,41 +384,72 @@ export class EmailDomainManager {
|
|
|
315
384
|
return record.value.trim() === required.value.trim();
|
|
316
385
|
}
|
|
317
386
|
|
|
387
|
+
/** Lazy shared smartdns client; the Rust bridge spawns on first non-system lookup. */
|
|
388
|
+
private getDnsClient(): plugins.smartdns.dnsClientMod.Smartdns {
|
|
389
|
+
if (!this.dnsClient) {
|
|
390
|
+
this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
|
|
391
|
+
}
|
|
392
|
+
return this.dnsClient;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 'pref exchange' normalized for comparison: numeric pref, lowercased exchange, no trailing dot. */
|
|
396
|
+
private normalizeMxValue(value: string): string {
|
|
397
|
+
const [pref, exchange = ''] = value.trim().split(/\s+/);
|
|
398
|
+
return `${Number(pref)} ${exchange.toLowerCase().replace(/\.$/, '')}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
318
401
|
private async checkMx(
|
|
319
|
-
|
|
402
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
320
403
|
domain: string,
|
|
321
404
|
expectedValue?: string,
|
|
322
405
|
): Promise<TDnsRecordStatus> {
|
|
323
406
|
try {
|
|
324
|
-
const records = await
|
|
407
|
+
const records = await dnsClient.getRecords(domain, 'MX');
|
|
325
408
|
if (!records || records.length === 0) {
|
|
326
409
|
return 'missing';
|
|
327
410
|
}
|
|
328
411
|
if (!expectedValue) {
|
|
329
412
|
return 'valid';
|
|
330
413
|
}
|
|
331
|
-
const
|
|
414
|
+
const expected = this.normalizeMxValue(expectedValue);
|
|
415
|
+
const found = records.some((record) => this.normalizeMxValue(record.value) === expected);
|
|
332
416
|
return found ? 'valid' : 'invalid';
|
|
333
417
|
} catch {
|
|
334
418
|
return 'missing';
|
|
335
419
|
}
|
|
336
420
|
}
|
|
337
421
|
|
|
422
|
+
private async checkARecords(
|
|
423
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
424
|
+
name: string,
|
|
425
|
+
expectedIps: string[],
|
|
426
|
+
): Promise<TDnsRecordStatus> {
|
|
427
|
+
try {
|
|
428
|
+
const records = await dnsClient.getRecordsA(name);
|
|
429
|
+
if (!records || records.length === 0) {
|
|
430
|
+
return 'missing';
|
|
431
|
+
}
|
|
432
|
+
const resolved = new Set(records.map((record) => record.value));
|
|
433
|
+
return expectedIps.every((ip) => resolved.has(ip)) ? 'valid' : 'invalid';
|
|
434
|
+
} catch {
|
|
435
|
+
return 'missing';
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
338
439
|
private async checkTxtRecord(
|
|
339
|
-
|
|
440
|
+
dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
|
|
340
441
|
name: string,
|
|
341
442
|
expectedValue?: string,
|
|
342
443
|
): Promise<TDnsRecordStatus> {
|
|
343
444
|
try {
|
|
344
|
-
const records = await
|
|
345
|
-
|
|
346
|
-
if (flat.length === 0) {
|
|
445
|
+
const records = await dnsClient.getRecordsTxt(name);
|
|
446
|
+
if (!records || records.length === 0) {
|
|
347
447
|
return 'missing';
|
|
348
448
|
}
|
|
349
449
|
if (!expectedValue) {
|
|
350
450
|
return 'valid';
|
|
351
451
|
}
|
|
352
|
-
const found =
|
|
452
|
+
const found = records.some((record) => record.value.trim() === expectedValue.trim());
|
|
353
453
|
return found ? 'valid' : 'invalid';
|
|
354
454
|
} catch {
|
|
355
455
|
return 'missing';
|
|
@@ -368,6 +468,7 @@ export class EmailDomainManager {
|
|
|
368
468
|
subdomain: doc.subdomain,
|
|
369
469
|
dkim: doc.dkim,
|
|
370
470
|
rateLimits: doc.rateLimits,
|
|
471
|
+
remoteIngress: doc.remoteIngress,
|
|
371
472
|
dnsStatus: doc.dnsStatus,
|
|
372
473
|
createdAt: doc.createdAt,
|
|
373
474
|
updatedAt: doc.updatedAt,
|
|
@@ -400,7 +501,11 @@ export class EmailDomainManager {
|
|
|
400
501
|
|
|
401
502
|
managedConfigs.push({
|
|
402
503
|
domain: doc.domain,
|
|
403
|
-
|
|
504
|
+
// Always external-dns: DcRouter owns the DNS records for managed domains
|
|
505
|
+
// (email-domain-manager DnsRecordDocs). internal-dns would make smartmta
|
|
506
|
+
// self-register a conflicting MX (exchange = the domain itself) plus
|
|
507
|
+
// duplicate SPF/DKIM TXT handlers on the shared DNS server.
|
|
508
|
+
dnsMode: 'external-dns',
|
|
404
509
|
dkim: {
|
|
405
510
|
selector: doc.dkim.selector,
|
|
406
511
|
keySize: doc.dkim.keySize,
|
|
@@ -3,6 +3,7 @@ import { logger } from '../logger.js';
|
|
|
3
3
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
|
4
4
|
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|
5
5
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
|
+
import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
7
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
7
8
|
|
|
8
9
|
type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
|
|
@@ -99,7 +100,9 @@ export class EmailRouteBuilder {
|
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
if (this.dcRouterRef.isRemoteIngressHubEnabled()) {
|
|
102
|
-
|
|
103
|
+
// Only mail-tagged edges bind SMTP ports — other edges may sit on hosts
|
|
104
|
+
// where 25/465/587 belong to a different mail system.
|
|
105
|
+
routeConfig.remoteIngress = { enabled: true, edgeFilter: [REMOTE_INGRESS_MAIL_TAG] };
|
|
103
106
|
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
|
|
104
107
|
if (inboundProxyProtocol) {
|
|
105
108
|
routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
|
|
@@ -22,7 +22,7 @@ function hasOwn(objectArg: object, keyArg: string): boolean {
|
|
|
22
22
|
export class EmailSettingsManager {
|
|
23
23
|
private cachedEmailConfig?: IUnifiedEmailServerOptions;
|
|
24
24
|
private cachedEmailPortConfig?: IEmailPortConfig;
|
|
25
|
-
private outboundMode: TEmailOutboundMode = '
|
|
25
|
+
private outboundMode: TEmailOutboundMode = 'remoteIngress';
|
|
26
26
|
private enabled = false;
|
|
27
27
|
private updatedAt = 0;
|
|
28
28
|
private updatedBy = 'default';
|
|
@@ -140,7 +140,7 @@ export class EmailSettingsManager {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
private normalizeOutboundMode(mode: TEmailOutboundMode | undefined): TEmailOutboundMode {
|
|
143
|
-
if (!mode) return '
|
|
143
|
+
if (!mode) return 'remoteIngress';
|
|
144
144
|
if (mode !== 'direct' && mode !== 'remoteIngress') {
|
|
145
145
|
throw new Error('Invalid email outbound mode');
|
|
146
146
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { logger } from '../logger.js';
|
|
2
|
+
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
|
3
|
+
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
4
|
+
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
5
|
+
import type { DcRouter } from '../classes.dcrouter.js';
|
|
6
|
+
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
7
|
+
|
|
8
|
+
export const MAIL_DNS_SYNC_CREATED_BY = 'mail-edge-sync';
|
|
9
|
+
|
|
10
|
+
interface IDesiredMailDnsRecord {
|
|
11
|
+
name: string;
|
|
12
|
+
type: 'A' | 'AAAA';
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reconciles the mail-facing A/AAAA records that make edges — never the hub —
|
|
18
|
+
* the public identity of the mail platform:
|
|
19
|
+
* (a) shared mail hostname → publicIp/publicIpV6 of every enabled mail edge,
|
|
20
|
+
* (b) each edge's mailHostname → that edge's IPs (FCrDNS for outbound EHLO),
|
|
21
|
+
* (c) mx.<domain> → the pinned edges' IPs for every pinned email domain.
|
|
22
|
+
* Records are DB-backed DnsRecordDoc rows owned via createdBy 'mail-edge-sync';
|
|
23
|
+
* provider-hosted zones (e.g. Cloudflare) are written through DnsManager's
|
|
24
|
+
* provider path, dcrouter-authoritative zones serve immediately.
|
|
25
|
+
*/
|
|
26
|
+
export class MailDnsSync {
|
|
27
|
+
private syncInFlight?: Promise<void>;
|
|
28
|
+
private resyncQueued = false;
|
|
29
|
+
|
|
30
|
+
constructor(private dcRouterRef: DcRouter) {}
|
|
31
|
+
|
|
32
|
+
/** Fire-and-forget trigger for CRUD hooks; failures are logged, not thrown. */
|
|
33
|
+
public requestSync(reason: string): void {
|
|
34
|
+
this.sync(reason).catch((err: unknown) => {
|
|
35
|
+
logger.log('error', `MailDnsSync failed (${reason}): ${(err as Error).message}`);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Coalescing entry point: concurrent triggers fold into one follow-up pass. */
|
|
40
|
+
public async sync(reason: string): Promise<void> {
|
|
41
|
+
if (this.syncInFlight) {
|
|
42
|
+
this.resyncQueued = true;
|
|
43
|
+
return this.syncInFlight;
|
|
44
|
+
}
|
|
45
|
+
this.syncInFlight = this.reconcile(reason).finally(() => {
|
|
46
|
+
this.syncInFlight = undefined;
|
|
47
|
+
if (this.resyncQueued) {
|
|
48
|
+
this.resyncQueued = false;
|
|
49
|
+
this.requestSync(`${reason} (coalesced)`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return this.syncInFlight;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async reconcile(reason: string): Promise<void> {
|
|
56
|
+
if (this.dcRouterRef.options.dbConfig?.enabled === false) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const dnsManager = this.dcRouterRef.dnsManager;
|
|
60
|
+
const remoteIngressManager = this.dcRouterRef.remoteIngressManager;
|
|
61
|
+
if (!dnsManager || !remoteIngressManager) {
|
|
62
|
+
// Without both managers the edge registry / record store is not
|
|
63
|
+
// authoritative — skip entirely rather than delete managed records.
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const desired = await this.computeDesiredRecords();
|
|
68
|
+
const zones = await DomainDoc.findAll();
|
|
69
|
+
|
|
70
|
+
// Place each desired record in its zone (longest-suffix match). Records
|
|
71
|
+
// without a managed zone are reported — the operator must onboard the zone.
|
|
72
|
+
const placed = new Map<string, { domainId: string; record: IDesiredMailDnsRecord }>();
|
|
73
|
+
const unplacedNames = new Set<string>();
|
|
74
|
+
for (const record of desired) {
|
|
75
|
+
const zone = this.findZoneForName(record.name, zones);
|
|
76
|
+
if (!zone) {
|
|
77
|
+
unplacedNames.add(record.name);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
placed.set(`${zone.id}|${record.name}|${record.type}|${record.value}`, {
|
|
81
|
+
domainId: zone.id,
|
|
82
|
+
record,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const name of unplacedNames) {
|
|
86
|
+
logger.log('warn', `MailDnsSync: no managed DNS zone covers ${name} — record skipped`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const existing = (await DnsRecordDoc.findAll())
|
|
90
|
+
.filter((record) => record.createdBy === MAIL_DNS_SYNC_CREATED_BY);
|
|
91
|
+
|
|
92
|
+
let created = 0;
|
|
93
|
+
let deleted = 0;
|
|
94
|
+
|
|
95
|
+
for (const record of existing) {
|
|
96
|
+
const key = `${record.domainId}|${record.name}|${record.type}|${record.value}`;
|
|
97
|
+
if (placed.has(key)) {
|
|
98
|
+
placed.delete(key); // already present — nothing to create
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const result = await dnsManager.deleteRecord(record.id);
|
|
102
|
+
if (result.success) {
|
|
103
|
+
deleted++;
|
|
104
|
+
} else {
|
|
105
|
+
logger.log('warn', `MailDnsSync: failed to delete stale ${record.type} ${record.name} → ${record.value}: ${result.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const { domainId, record } of placed.values()) {
|
|
110
|
+
const result = await dnsManager.createRecord({
|
|
111
|
+
domainId,
|
|
112
|
+
name: record.name,
|
|
113
|
+
type: record.type,
|
|
114
|
+
value: record.value,
|
|
115
|
+
// Short TTL: edge IPs are burnable identities and failover heals via DNS.
|
|
116
|
+
ttl: 300,
|
|
117
|
+
createdBy: MAIL_DNS_SYNC_CREATED_BY,
|
|
118
|
+
});
|
|
119
|
+
if (result.success) {
|
|
120
|
+
created++;
|
|
121
|
+
} else {
|
|
122
|
+
logger.log('warn', `MailDnsSync: failed to create ${record.type} ${record.name} → ${record.value}: ${result.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (created > 0 || deleted > 0) {
|
|
127
|
+
logger.log('info', `MailDnsSync (${reason}): ${created} record(s) created, ${deleted} deleted`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async computeDesiredRecords(): Promise<IDesiredMailDnsRecord[]> {
|
|
132
|
+
const remoteIngressManager = this.dcRouterRef.remoteIngressManager!;
|
|
133
|
+
const desired: IDesiredMailDnsRecord[] = [];
|
|
134
|
+
|
|
135
|
+
const pushEdgeIps = (name: string | undefined, edge: IRemoteIngress) => {
|
|
136
|
+
if (!name) return;
|
|
137
|
+
const normalizedName = name.toLowerCase();
|
|
138
|
+
if (edge.publicIp) {
|
|
139
|
+
desired.push({ name: normalizedName, type: 'A', value: edge.publicIp });
|
|
140
|
+
}
|
|
141
|
+
if (edge.publicIpV6) {
|
|
142
|
+
desired.push({ name: normalizedName, type: 'AAAA', value: edge.publicIpV6 });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// (a) shared mail hostname → every enabled mail edge
|
|
147
|
+
const sharedHostname = this.getSharedMailHostname();
|
|
148
|
+
const mailEdges = remoteIngressManager.getMailEdges();
|
|
149
|
+
for (const edge of mailEdges) {
|
|
150
|
+
pushEdgeIps(sharedHostname, edge);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// (b) per-edge FCrDNS hostname → that edge only
|
|
154
|
+
for (const edge of remoteIngressManager.getAllEdges()) {
|
|
155
|
+
if (!edge.enabled) continue;
|
|
156
|
+
pushEdgeIps(edge.mailHostname, edge);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// (c) mx.<domain> → pinned edges for every pinned email domain
|
|
160
|
+
const emailDomainDocs = await EmailDomainDoc.findAll();
|
|
161
|
+
for (const doc of emailDomainDocs) {
|
|
162
|
+
const edgeFilter = doc.remoteIngress?.edgeFilter;
|
|
163
|
+
if (!edgeFilter?.length) continue;
|
|
164
|
+
const pinnedEdges = remoteIngressManager.resolveEdgesByFilter(edgeFilter);
|
|
165
|
+
if (pinnedEdges.length === 0) {
|
|
166
|
+
logger.log('warn', `MailDnsSync: email domain ${doc.domain} pin [${edgeFilter.join(', ')}] matches no enabled edge — mx.${doc.domain} not published`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const edge of pinnedEdges) {
|
|
170
|
+
pushEdgeIps(`mx.${doc.domain}`, edge);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Hard hub-hiding guard: a hub IP must never become a mail-facing answer.
|
|
175
|
+
const hubIps = new Set(
|
|
176
|
+
[this.dcRouterRef.options.publicIp, this.dcRouterRef.detectedPublicIp].filter(Boolean),
|
|
177
|
+
);
|
|
178
|
+
const guarded = desired.filter((record) => {
|
|
179
|
+
if (hubIps.has(record.value)) {
|
|
180
|
+
logger.log('error', `MailDnsSync: refusing to publish ${record.type} ${record.name} → ${record.value} — value is the hub public IP`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Dedupe (several edges may share a tag and a pin may overlap the mail pool)
|
|
187
|
+
const seen = new Set<string>();
|
|
188
|
+
return guarded.filter((record) => {
|
|
189
|
+
const key = `${record.name}|${record.type}|${record.value}`;
|
|
190
|
+
if (seen.has(key)) return false;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private getSharedMailHostname(): string | undefined {
|
|
197
|
+
const hostname = this.dcRouterRef.options.emailConfig?.hostname
|
|
198
|
+
|| this.dcRouterRef.options.tls?.domain;
|
|
199
|
+
if (!hostname || hostname === 'localhost') {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
return hostname;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Longest-suffix zone match: 'mx.gated.one' → zone 'gated.one'. */
|
|
206
|
+
private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
|
|
207
|
+
let best: DomainDoc | undefined;
|
|
208
|
+
for (const zone of zones) {
|
|
209
|
+
const zoneName = zone.name.toLowerCase();
|
|
210
|
+
if (name === zoneName || name.endsWith(`.${zoneName}`)) {
|
|
211
|
+
if (!best || zoneName.length > best.name.length) {
|
|
212
|
+
best = zone;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return best;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IOutboundConnectionProxy,
|
|
3
|
+
IOutboundConnectionProxyContext,
|
|
4
|
+
} from '@push.rocks/smartmta';
|
|
5
|
+
import { logger } from '../logger.js';
|
|
6
|
+
import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
|
|
7
|
+
import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js';
|
|
8
|
+
import type { DcRouter } from '../classes.dcrouter.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Routes outbound SMTP deliveries over RemoteIngress edges: resolves the
|
|
12
|
+
* outbound mode per attempt (no silent direct fallback), maps sender-domain
|
|
13
|
+
* pins to an edgeFilter, opens the per-delivery QUIC egress proxy, and
|
|
14
|
+
* presents the selected edge's FCrDNS hostname as the EHLO identity.
|
|
15
|
+
*/
|
|
16
|
+
export class MailEgressCoordinator {
|
|
17
|
+
constructor(private dcRouterRef: DcRouter) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* smartmta connectionProxyProvider. Wired unconditionally so the outbound
|
|
21
|
+
* mode is resolved per delivery attempt: a failed/late settings manager
|
|
22
|
+
* defers mail loudly instead of silently degrading to direct-from-hub
|
|
23
|
+
* egress, and mode changes take effect without an email server restart.
|
|
24
|
+
*/
|
|
25
|
+
public async provideConnectionProxy(
|
|
26
|
+
context: IOutboundConnectionProxyContext,
|
|
27
|
+
): Promise<IOutboundConnectionProxy | null> {
|
|
28
|
+
const mode = this.resolveOutboundModeStrict();
|
|
29
|
+
if (mode === 'direct') {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return await this.createRemoteIngressConnectionProxy(context);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the effective email outbound mode for a delivery attempt.
|
|
37
|
+
* Throws when the settings manager is unavailable — deferring the delivery —
|
|
38
|
+
* because guessing here is what silently degraded outbound to direct egress.
|
|
39
|
+
*/
|
|
40
|
+
public resolveOutboundModeStrict(): TEmailOutboundMode {
|
|
41
|
+
if (this.dcRouterRef.emailSettingsManager) {
|
|
42
|
+
return this.dcRouterRef.emailSettingsManager.getOutboundMode();
|
|
43
|
+
}
|
|
44
|
+
if (this.dcRouterRef.options.emailOutboundMode) {
|
|
45
|
+
return this.dcRouterRef.options.emailOutboundMode;
|
|
46
|
+
}
|
|
47
|
+
throw new Error('EmailSettingsManager unavailable: refusing to resolve email outbound mode implicitly');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
|
|
52
|
+
*/
|
|
53
|
+
public getOutboundEgressStatus(): IEmailOutboundEgressStatus {
|
|
54
|
+
const tunnelManager = this.dcRouterRef.tunnelManager;
|
|
55
|
+
if (!tunnelManager) {
|
|
56
|
+
return { ready: false, eligibleEdgeCount: 0, reason: 'RemoteIngress hub is not running' };
|
|
57
|
+
}
|
|
58
|
+
return tunnelManager.getEgressReadiness([REMOTE_INGRESS_MAIL_TAG]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Post-start preflight: with remoteIngress outbound (the default), mail
|
|
63
|
+
* defers until a QUIC mail egress edge connects. Surface that loudly
|
|
64
|
+
* instead of silently queueing.
|
|
65
|
+
*/
|
|
66
|
+
public logEgressPreflight(): void {
|
|
67
|
+
try {
|
|
68
|
+
if (this.resolveOutboundModeStrict() === 'remoteIngress') {
|
|
69
|
+
const egressStatus = this.getOutboundEgressStatus();
|
|
70
|
+
if (!egressStatus.ready) {
|
|
71
|
+
logger.log('warn', `Outbound mail egress is NOT ready (${egressStatus.reason}); outbound mail will defer until a QUIC mail egress edge connects`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error: unknown) {
|
|
75
|
+
logger.log('error', `Outbound mail mode could not be resolved: ${(error as Error).message}; outbound deliveries will defer until EmailSettingsManager is available`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async createRemoteIngressConnectionProxy(
|
|
80
|
+
context: IOutboundConnectionProxyContext,
|
|
81
|
+
): Promise<IOutboundConnectionProxy> {
|
|
82
|
+
const tunnelManager = this.dcRouterRef.tunnelManager;
|
|
83
|
+
if (!tunnelManager) {
|
|
84
|
+
throw new Error('RemoteIngress outbound SMTP egress is enabled, but the hub is not running');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Per-domain pinning: a sender domain with a configured edgeFilter uses only
|
|
88
|
+
// matching edges; everything else uses the mail-tagged edge pool.
|
|
89
|
+
let edgeFilter: string[] = [REMOTE_INGRESS_MAIL_TAG];
|
|
90
|
+
if (context.senderDomain && this.dcRouterRef.emailDomainManager) {
|
|
91
|
+
const domain = await this.dcRouterRef.emailDomainManager.getByDomain(context.senderDomain);
|
|
92
|
+
if (domain?.remoteIngress?.edgeFilter?.length) {
|
|
93
|
+
edgeFilter = domain.remoteIngress.edgeFilter;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000;
|
|
98
|
+
const proxy = await tunnelManager.startEgressTcpProxy({
|
|
99
|
+
logicalHost: context.host,
|
|
100
|
+
port: context.port,
|
|
101
|
+
serverFirst: !context.secure,
|
|
102
|
+
connectTimeoutMs: connectionTimeoutMs,
|
|
103
|
+
acceptTimeoutMs: connectionTimeoutMs,
|
|
104
|
+
edgeFilter,
|
|
105
|
+
});
|
|
106
|
+
let closed = false;
|
|
107
|
+
return {
|
|
108
|
+
connectHost: proxy.listenHost,
|
|
109
|
+
connectPort: proxy.listenPort,
|
|
110
|
+
poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`,
|
|
111
|
+
ehloHostname: this.dcRouterRef.remoteIngressManager?.getEdge(proxy.edgeId)?.mailHostname,
|
|
112
|
+
close: async () => {
|
|
113
|
+
if (closed) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
closed = true;
|
|
117
|
+
try {
|
|
118
|
+
await tunnelManager.stopEgressTcpProxy(proxy.proxyId);
|
|
119
|
+
} catch (err: unknown) {
|
|
120
|
+
logger.log('warn', `Failed to stop RemoteIngress egress proxy ${proxy.proxyId}: ${(err as Error).message}`);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
package/ts/email/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from './classes.accepted-email-spool.js';
|
|
2
2
|
export * from './classes.email-domain.manager.js';
|
|
3
3
|
export * from './classes.email-route-builder.js';
|
|
4
|
+
export * from './classes.mail-dns-sync.js';
|
|
5
|
+
export * from './classes.mail-egress-coordinator.js';
|
|
4
6
|
export * from './classes.email-settings.manager.js';
|
|
5
7
|
export * from './classes.smartmta-storage-manager.js';
|
|
6
8
|
export * from './classes.workapp-mail-manager.js';
|