@serve.zone/dcrouter 13.17.9 → 13.19.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/dist_serve/bundle.js +6 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +9 -5
- package/dist_ts/classes.dcrouter.js +152 -120
- package/dist_ts/config/classes.route-config-manager.d.ts +13 -5
- package/dist_ts/config/classes.route-config-manager.js +76 -36
- package/dist_ts/db/documents/classes.route.doc.d.ts +2 -0
- package/dist_ts/db/documents/classes.route.doc.js +11 -2
- package/dist_ts/email/classes.email-domain.manager.d.ts +7 -0
- package/dist_ts/email/classes.email-domain.manager.js +118 -55
- package/dist_ts/email/classes.smartmta-storage-manager.d.ts +13 -0
- package/dist_ts/email/classes.smartmta-storage-manager.js +101 -0
- package/dist_ts/email/email-dns-records.d.ts +14 -0
- package/dist_ts/email/email-dns-records.js +34 -0
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.js +6 -15
- package/dist_ts/opsserver/handlers/route-management.handler.js +5 -7
- package/dist_ts/opsserver/handlers/stats.handler.js +41 -7
- package/dist_ts_interfaces/data/route-management.d.ts +2 -0
- package/dist_ts_migrations/index.js +25 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.js +13 -4
- package/dist_ts_web/elements/network/ops-view-routes.d.ts +2 -0
- package/dist_ts_web/elements/network/ops-view-routes.js +44 -21
- package/package.json +2 -2
- package/readme.md +190 -1543
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +190 -138
- package/ts/config/classes.route-config-manager.ts +97 -42
- package/ts/db/documents/classes.route.doc.ts +7 -0
- package/ts/email/classes.email-domain.manager.ts +136 -51
- package/ts/email/classes.smartmta-storage-manager.ts +108 -0
- package/ts/email/email-dns-records.ts +53 -0
- package/ts/email/index.ts +2 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +5 -19
- package/ts/opsserver/handlers/route-management.handler.ts +4 -6
- package/ts/opsserver/handlers/stats.handler.ts +43 -7
- package/ts_apiclient/readme.md +69 -195
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +16 -4
- package/ts_web/elements/network/ops-view-routes.ts +47 -29
- package/ts_web/readme.md +41 -242
|
@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
|
|
|
14
14
|
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
|
15
15
|
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
|
16
16
|
|
|
17
|
+
export interface IRouteMutationResult {
|
|
18
|
+
success: boolean;
|
|
19
|
+
message?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/**
|
|
18
23
|
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
|
19
24
|
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
|
@@ -56,6 +61,7 @@ export class RouteConfigManager {
|
|
|
56
61
|
private referenceResolver?: ReferenceResolver,
|
|
57
62
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
|
58
63
|
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
|
64
|
+
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
|
59
65
|
) {}
|
|
60
66
|
|
|
61
67
|
/** Expose routes map for reference resolution lookups. */
|
|
@@ -63,6 +69,10 @@ export class RouteConfigManager {
|
|
|
63
69
|
return this.routes;
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
public getRoute(id: string): IRoute | undefined {
|
|
73
|
+
return this.routes.get(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
/**
|
|
67
77
|
* Load persisted routes, seed serializable config/email/dns routes,
|
|
68
78
|
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
|
@@ -94,6 +104,7 @@ export class RouteConfigManager {
|
|
|
94
104
|
id: route.id,
|
|
95
105
|
enabled: route.enabled,
|
|
96
106
|
origin: route.origin,
|
|
107
|
+
systemKey: route.systemKey,
|
|
97
108
|
createdAt: route.createdAt,
|
|
98
109
|
updatedAt: route.updatedAt,
|
|
99
110
|
metadata: route.metadata,
|
|
@@ -153,9 +164,21 @@ export class RouteConfigManager {
|
|
|
153
164
|
enabled?: boolean;
|
|
154
165
|
metadata?: Partial<IRouteMetadata>;
|
|
155
166
|
},
|
|
156
|
-
): Promise<
|
|
167
|
+
): Promise<IRouteMutationResult> {
|
|
157
168
|
const stored = this.routes.get(id);
|
|
158
|
-
if (!stored)
|
|
169
|
+
if (!stored) {
|
|
170
|
+
return { success: false, message: 'Route not found' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isToggleOnlyPatch = patch.enabled !== undefined
|
|
174
|
+
&& patch.route === undefined
|
|
175
|
+
&& patch.metadata === undefined;
|
|
176
|
+
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
|
177
|
+
return {
|
|
178
|
+
success: false,
|
|
179
|
+
message: 'System routes are managed by the system and can only be toggled',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
159
182
|
|
|
160
183
|
if (patch.route) {
|
|
161
184
|
const mergedAction = patch.route.action
|
|
@@ -189,19 +212,29 @@ export class RouteConfigManager {
|
|
|
189
212
|
|
|
190
213
|
await this.persistRoute(stored);
|
|
191
214
|
await this.applyRoutes();
|
|
192
|
-
return true;
|
|
215
|
+
return { success: true };
|
|
193
216
|
}
|
|
194
217
|
|
|
195
|
-
public async deleteRoute(id: string): Promise<
|
|
196
|
-
|
|
218
|
+
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
|
219
|
+
const stored = this.routes.get(id);
|
|
220
|
+
if (!stored) {
|
|
221
|
+
return { success: false, message: 'Route not found' };
|
|
222
|
+
}
|
|
223
|
+
if (stored.origin !== 'api') {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
message: 'System routes are managed by the system and cannot be deleted',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
197
230
|
this.routes.delete(id);
|
|
198
231
|
const doc = await RouteDoc.findById(id);
|
|
199
232
|
if (doc) await doc.delete();
|
|
200
233
|
await this.applyRoutes();
|
|
201
|
-
return true;
|
|
234
|
+
return { success: true };
|
|
202
235
|
}
|
|
203
236
|
|
|
204
|
-
public async toggleRoute(id: string, enabled: boolean): Promise<
|
|
237
|
+
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
|
205
238
|
return this.updateRoute(id, { enabled });
|
|
206
239
|
}
|
|
207
240
|
|
|
@@ -217,29 +250,28 @@ export class RouteConfigManager {
|
|
|
217
250
|
seedRoutes: IDcRouterRouteConfig[],
|
|
218
251
|
origin: 'config' | 'email' | 'dns',
|
|
219
252
|
): Promise<void> {
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
const seedSystemKeys = new Set<string>();
|
|
222
254
|
const seedNames = new Set<string>();
|
|
223
255
|
let seeded = 0;
|
|
224
256
|
let updated = 0;
|
|
225
257
|
|
|
226
258
|
for (const route of seedRoutes) {
|
|
227
259
|
const name = route.name || '';
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
existingId = id;
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
260
|
+
if (name) {
|
|
261
|
+
seedNames.add(name);
|
|
262
|
+
}
|
|
263
|
+
const systemKey = this.buildSystemRouteKey(origin, route);
|
|
264
|
+
if (systemKey) {
|
|
265
|
+
seedSystemKeys.add(systemKey);
|
|
237
266
|
}
|
|
238
267
|
|
|
268
|
+
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
|
|
269
|
+
|
|
239
270
|
if (existingId) {
|
|
240
271
|
// Update route config but preserve enabled state
|
|
241
272
|
const existing = this.routes.get(existingId)!;
|
|
242
273
|
existing.route = route;
|
|
274
|
+
existing.systemKey = systemKey;
|
|
243
275
|
existing.updatedAt = Date.now();
|
|
244
276
|
await this.persistRoute(existing);
|
|
245
277
|
updated++;
|
|
@@ -255,6 +287,7 @@ export class RouteConfigManager {
|
|
|
255
287
|
updatedAt: now,
|
|
256
288
|
createdBy: 'system',
|
|
257
289
|
origin,
|
|
290
|
+
systemKey,
|
|
258
291
|
};
|
|
259
292
|
this.routes.set(id, newRoute);
|
|
260
293
|
await this.persistRoute(newRoute);
|
|
@@ -265,7 +298,12 @@ export class RouteConfigManager {
|
|
|
265
298
|
// Delete stale routes: same origin but name not in current seed set
|
|
266
299
|
const staleIds: string[] = [];
|
|
267
300
|
for (const [id, r] of this.routes) {
|
|
268
|
-
if (r.origin
|
|
301
|
+
if (r.origin !== origin) continue;
|
|
302
|
+
|
|
303
|
+
const routeName = r.route.name || '';
|
|
304
|
+
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
|
|
305
|
+
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
|
|
306
|
+
if (!matchesSeedSystemKey && !matchesSeedName) {
|
|
269
307
|
staleIds.push(id);
|
|
270
308
|
}
|
|
271
309
|
}
|
|
@@ -284,9 +322,39 @@ export class RouteConfigManager {
|
|
|
284
322
|
// Private: persistence
|
|
285
323
|
// =========================================================================
|
|
286
324
|
|
|
325
|
+
private buildSystemRouteKey(
|
|
326
|
+
origin: 'config' | 'email' | 'dns',
|
|
327
|
+
route: IDcRouterRouteConfig,
|
|
328
|
+
): string | undefined {
|
|
329
|
+
const name = route.name?.trim();
|
|
330
|
+
if (!name) return undefined;
|
|
331
|
+
return `${origin}:${name}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private findExistingSeedRouteId(
|
|
335
|
+
origin: 'config' | 'email' | 'dns',
|
|
336
|
+
route: IDcRouterRouteConfig,
|
|
337
|
+
systemKey?: string,
|
|
338
|
+
): string | undefined {
|
|
339
|
+
const routeName = route.name || '';
|
|
340
|
+
|
|
341
|
+
for (const [id, storedRoute] of this.routes) {
|
|
342
|
+
if (storedRoute.origin !== origin) continue;
|
|
343
|
+
|
|
344
|
+
if (systemKey && storedRoute.systemKey === systemKey) {
|
|
345
|
+
return id;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (storedRoute.route.name === routeName) {
|
|
349
|
+
return id;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
287
356
|
private async loadRoutes(): Promise<void> {
|
|
288
357
|
const docs = await RouteDoc.findAll();
|
|
289
|
-
let prunedRuntimeRoutes = 0;
|
|
290
358
|
|
|
291
359
|
for (const doc of docs) {
|
|
292
360
|
if (!doc.id) continue;
|
|
@@ -299,27 +367,15 @@ export class RouteConfigManager {
|
|
|
299
367
|
updatedAt: doc.updatedAt,
|
|
300
368
|
createdBy: doc.createdBy,
|
|
301
369
|
origin: doc.origin || 'api',
|
|
370
|
+
systemKey: doc.systemKey,
|
|
302
371
|
metadata: doc.metadata,
|
|
303
372
|
};
|
|
304
373
|
|
|
305
|
-
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
|
306
|
-
await doc.delete();
|
|
307
|
-
prunedRuntimeRoutes++;
|
|
308
|
-
logger.log(
|
|
309
|
-
'warn',
|
|
310
|
-
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
|
311
|
-
);
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
374
|
this.routes.set(doc.id, storedRoute);
|
|
316
375
|
}
|
|
317
376
|
if (this.routes.size > 0) {
|
|
318
377
|
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
|
319
378
|
}
|
|
320
|
-
if (prunedRuntimeRoutes > 0) {
|
|
321
|
-
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
|
322
|
-
}
|
|
323
379
|
}
|
|
324
380
|
|
|
325
381
|
private async persistRoute(stored: IRoute): Promise<void> {
|
|
@@ -330,6 +386,7 @@ export class RouteConfigManager {
|
|
|
330
386
|
existingDoc.updatedAt = stored.updatedAt;
|
|
331
387
|
existingDoc.createdBy = stored.createdBy;
|
|
332
388
|
existingDoc.origin = stored.origin;
|
|
389
|
+
existingDoc.systemKey = stored.systemKey;
|
|
333
390
|
existingDoc.metadata = stored.metadata;
|
|
334
391
|
await existingDoc.save();
|
|
335
392
|
} else {
|
|
@@ -341,6 +398,7 @@ export class RouteConfigManager {
|
|
|
341
398
|
doc.updatedAt = stored.updatedAt;
|
|
342
399
|
doc.createdBy = stored.createdBy;
|
|
343
400
|
doc.origin = stored.origin;
|
|
401
|
+
doc.systemKey = stored.systemKey;
|
|
344
402
|
doc.metadata = stored.metadata;
|
|
345
403
|
await doc.save();
|
|
346
404
|
}
|
|
@@ -411,7 +469,7 @@ export class RouteConfigManager {
|
|
|
411
469
|
// Add all enabled routes with HTTP/3 and VPN augmentation
|
|
412
470
|
for (const route of this.routes.values()) {
|
|
413
471
|
if (route.enabled) {
|
|
414
|
-
enabledRoutes.push(this.
|
|
472
|
+
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
|
415
473
|
}
|
|
416
474
|
}
|
|
417
475
|
|
|
@@ -431,6 +489,11 @@ export class RouteConfigManager {
|
|
|
431
489
|
});
|
|
432
490
|
}
|
|
433
491
|
|
|
492
|
+
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
|
493
|
+
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
|
494
|
+
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
|
495
|
+
}
|
|
496
|
+
|
|
434
497
|
private prepareRouteForApply(
|
|
435
498
|
route: plugins.smartproxy.IRouteConfig,
|
|
436
499
|
routeId?: string,
|
|
@@ -465,12 +528,4 @@ export class RouteConfigManager {
|
|
|
465
528
|
},
|
|
466
529
|
};
|
|
467
530
|
}
|
|
468
|
-
|
|
469
|
-
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
|
|
470
|
-
const routeName = storedRoute.route.name || '';
|
|
471
|
-
const actionType = storedRoute.route.action?.type;
|
|
472
|
-
|
|
473
|
-
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|
|
474
|
-
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
|
|
475
|
-
}
|
|
476
531
|
}
|
|
@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
|
|
|
29
29
|
@plugins.smartdata.svDb()
|
|
30
30
|
public origin!: 'config' | 'email' | 'dns' | 'api';
|
|
31
31
|
|
|
32
|
+
@plugins.smartdata.svDb()
|
|
33
|
+
public systemKey?: string;
|
|
34
|
+
|
|
32
35
|
@plugins.smartdata.svDb()
|
|
33
36
|
public metadata?: IRouteMetadata;
|
|
34
37
|
|
|
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
|
|
|
51
54
|
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
|
52
55
|
return await RouteDoc.getInstances({ origin });
|
|
53
56
|
}
|
|
57
|
+
|
|
58
|
+
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
|
|
59
|
+
return await RouteDoc.getInstance({ systemKey });
|
|
60
|
+
}
|
|
54
61
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
+
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
|
2
3
|
import { logger } from '../logger.js';
|
|
3
4
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
4
5
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
5
6
|
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
|
6
7
|
import type { DnsManager } from '../dns/manager.dns.js';
|
|
7
8
|
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
9
|
+
import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* EmailDomainManager — orchestrates email domain setup.
|
|
@@ -15,9 +17,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
|
|
|
15
17
|
*/
|
|
16
18
|
export class EmailDomainManager {
|
|
17
19
|
private dcRouter: any; // DcRouter — avoids circular import
|
|
20
|
+
private readonly baseEmailDomains: IEmailDomainConfig[];
|
|
18
21
|
|
|
19
22
|
constructor(dcRouterRef: any) {
|
|
20
23
|
this.dcRouter = dcRouterRef;
|
|
24
|
+
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
|
25
|
+
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
private get dnsManager(): DnsManager | undefined {
|
|
@@ -32,6 +37,12 @@ export class EmailDomainManager {
|
|
|
32
37
|
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
public async start(): Promise<void> {
|
|
41
|
+
await this.syncManagedDomainsToRuntime();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async stop(): Promise<void> {}
|
|
45
|
+
|
|
35
46
|
// ---------------------------------------------------------------------------
|
|
36
47
|
// CRUD
|
|
37
48
|
// ---------------------------------------------------------------------------
|
|
@@ -64,6 +75,9 @@ export class EmailDomainManager {
|
|
|
64
75
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
|
65
76
|
|
|
66
77
|
// Check for duplicates
|
|
78
|
+
if (this.isDomainAlreadyConfigured(domainName)) {
|
|
79
|
+
throw new Error(`Email domain already configured for ${domainName}`);
|
|
80
|
+
}
|
|
67
81
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
|
68
82
|
if (existing) {
|
|
69
83
|
throw new Error(`Email domain already exists for ${domainName}`);
|
|
@@ -77,8 +91,8 @@ export class EmailDomainManager {
|
|
|
77
91
|
let publicKey: string | undefined;
|
|
78
92
|
if (this.dkimCreator) {
|
|
79
93
|
try {
|
|
80
|
-
await this.dkimCreator.
|
|
81
|
-
const dnsRecord = await this.dkimCreator.
|
|
94
|
+
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
|
95
|
+
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
|
82
96
|
// Extract public key from the DNS record value
|
|
83
97
|
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
|
84
98
|
publicKey = match ? match[1] : undefined;
|
|
@@ -110,6 +124,7 @@ export class EmailDomainManager {
|
|
|
110
124
|
doc.createdAt = now;
|
|
111
125
|
doc.updatedAt = now;
|
|
112
126
|
await doc.save();
|
|
127
|
+
await this.syncManagedDomainsToRuntime();
|
|
113
128
|
|
|
114
129
|
logger.log('info', `Email domain created: ${domainName}`);
|
|
115
130
|
return this.docToInterface(doc);
|
|
@@ -131,12 +146,14 @@ export class EmailDomainManager {
|
|
|
131
146
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
|
132
147
|
doc.updatedAt = new Date().toISOString();
|
|
133
148
|
await doc.save();
|
|
149
|
+
await this.syncManagedDomainsToRuntime();
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
public async deleteEmailDomain(id: string): Promise<void> {
|
|
137
153
|
const doc = await EmailDomainDoc.findById(id);
|
|
138
154
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
139
155
|
await doc.delete();
|
|
156
|
+
await this.syncManagedDomainsToRuntime();
|
|
140
157
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
|
141
158
|
}
|
|
142
159
|
|
|
@@ -153,37 +170,25 @@ export class EmailDomainManager {
|
|
|
153
170
|
|
|
154
171
|
const domain = doc.domain;
|
|
155
172
|
const selector = doc.dkim.selector;
|
|
156
|
-
const publicKey = doc.dkim.publicKey || '';
|
|
157
173
|
const hostname = this.emailHostname;
|
|
174
|
+
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
|
175
|
+
|
|
176
|
+
if (this.dkimCreator) {
|
|
177
|
+
try {
|
|
178
|
+
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
|
179
|
+
dkimValue = dnsRecord.value;
|
|
180
|
+
} catch (err: unknown) {
|
|
181
|
+
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
158
184
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
{
|
|
167
|
-
type: 'TXT',
|
|
168
|
-
name: domain,
|
|
169
|
-
value: 'v=spf1 a mx ~all',
|
|
170
|
-
status: doc.dnsStatus.spf,
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
type: 'TXT',
|
|
174
|
-
name: `${selector}._domainkey.${domain}`,
|
|
175
|
-
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
|
176
|
-
status: doc.dnsStatus.dkim,
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
type: 'TXT',
|
|
180
|
-
name: `_dmarc.${domain}`,
|
|
181
|
-
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
|
|
182
|
-
status: doc.dnsStatus.dmarc,
|
|
183
|
-
},
|
|
184
|
-
];
|
|
185
|
-
|
|
186
|
-
return records;
|
|
185
|
+
return buildEmailDnsRecords({
|
|
186
|
+
domain,
|
|
187
|
+
hostname,
|
|
188
|
+
selector,
|
|
189
|
+
dkimValue,
|
|
190
|
+
statuses: doc.dnsStatus,
|
|
191
|
+
});
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
// ---------------------------------------------------------------------------
|
|
@@ -207,17 +212,7 @@ export class EmailDomainManager {
|
|
|
207
212
|
|
|
208
213
|
for (const required of requiredRecords) {
|
|
209
214
|
// Check if a matching record already exists
|
|
210
|
-
const exists = existingRecords.some((r) =>
|
|
211
|
-
if (required.type === 'MX') {
|
|
212
|
-
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
|
213
|
-
}
|
|
214
|
-
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
|
215
|
-
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
|
216
|
-
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
|
217
|
-
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
|
218
|
-
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
|
219
|
-
return false;
|
|
220
|
-
});
|
|
215
|
+
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
|
221
216
|
|
|
222
217
|
if (!exists) {
|
|
223
218
|
try {
|
|
@@ -259,16 +254,23 @@ export class EmailDomainManager {
|
|
|
259
254
|
const resolver = new plugins.dns.promises.Resolver();
|
|
260
255
|
|
|
261
256
|
// MX check
|
|
262
|
-
|
|
257
|
+
const requiredRecords = await this.getRequiredDnsRecords(id);
|
|
258
|
+
|
|
259
|
+
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
|
260
|
+
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
|
261
|
+
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
|
262
|
+
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
|
263
|
+
|
|
264
|
+
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
|
263
265
|
|
|
264
266
|
// SPF check
|
|
265
|
-
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain,
|
|
267
|
+
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
|
266
268
|
|
|
267
269
|
// DKIM check
|
|
268
|
-
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`,
|
|
270
|
+
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
|
269
271
|
|
|
270
272
|
// DMARC check
|
|
271
|
-
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`,
|
|
273
|
+
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
|
272
274
|
|
|
273
275
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
|
274
276
|
doc.updatedAt = new Date().toISOString();
|
|
@@ -277,10 +279,28 @@ export class EmailDomainManager {
|
|
|
277
279
|
return this.getRequiredDnsRecords(id);
|
|
278
280
|
}
|
|
279
281
|
|
|
280
|
-
private
|
|
282
|
+
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
|
283
|
+
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
return record.value.trim() === required.value.trim();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async checkMx(
|
|
290
|
+
resolver: plugins.dns.promises.Resolver,
|
|
291
|
+
domain: string,
|
|
292
|
+
expectedValue?: string,
|
|
293
|
+
): Promise<TDnsRecordStatus> {
|
|
281
294
|
try {
|
|
282
295
|
const records = await resolver.resolveMx(domain);
|
|
283
|
-
|
|
296
|
+
if (!records || records.length === 0) {
|
|
297
|
+
return 'missing';
|
|
298
|
+
}
|
|
299
|
+
if (!expectedValue) {
|
|
300
|
+
return 'valid';
|
|
301
|
+
}
|
|
302
|
+
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
|
303
|
+
return found ? 'valid' : 'invalid';
|
|
284
304
|
} catch {
|
|
285
305
|
return 'missing';
|
|
286
306
|
}
|
|
@@ -289,13 +309,19 @@ export class EmailDomainManager {
|
|
|
289
309
|
private async checkTxtRecord(
|
|
290
310
|
resolver: plugins.dns.promises.Resolver,
|
|
291
311
|
name: string,
|
|
292
|
-
|
|
312
|
+
expectedValue?: string,
|
|
293
313
|
): Promise<TDnsRecordStatus> {
|
|
294
314
|
try {
|
|
295
315
|
const records = await resolver.resolveTxt(name);
|
|
296
316
|
const flat = records.map((r) => r.join(''));
|
|
297
|
-
|
|
298
|
-
|
|
317
|
+
if (flat.length === 0) {
|
|
318
|
+
return 'missing';
|
|
319
|
+
}
|
|
320
|
+
if (!expectedValue) {
|
|
321
|
+
return 'valid';
|
|
322
|
+
}
|
|
323
|
+
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
|
324
|
+
return found ? 'valid' : 'invalid';
|
|
299
325
|
} catch {
|
|
300
326
|
return 'missing';
|
|
301
327
|
}
|
|
@@ -318,4 +344,63 @@ export class EmailDomainManager {
|
|
|
318
344
|
updatedAt: doc.updatedAt,
|
|
319
345
|
};
|
|
320
346
|
}
|
|
347
|
+
|
|
348
|
+
private isDomainAlreadyConfigured(domainName: string): boolean {
|
|
349
|
+
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
|
350
|
+
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
|
351
|
+
return configuredDomains.includes(domainName.toLowerCase());
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
|
355
|
+
const docs = await EmailDomainDoc.findAll();
|
|
356
|
+
const managedConfigs: IEmailDomainConfig[] = [];
|
|
357
|
+
|
|
358
|
+
for (const doc of docs) {
|
|
359
|
+
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
|
360
|
+
if (!linkedDomain) {
|
|
361
|
+
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
managedConfigs.push({
|
|
366
|
+
domain: doc.domain,
|
|
367
|
+
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
|
368
|
+
dkim: {
|
|
369
|
+
selector: doc.dkim.selector,
|
|
370
|
+
keySize: doc.dkim.keySize,
|
|
371
|
+
rotateKeys: doc.dkim.rotateKeys,
|
|
372
|
+
rotationInterval: doc.dkim.rotationIntervalDays,
|
|
373
|
+
},
|
|
374
|
+
rateLimits: doc.rateLimits,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return managedConfigs;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async syncManagedDomainsToRuntime(): Promise<void> {
|
|
382
|
+
if (!this.dcRouter.options?.emailConfig) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
|
387
|
+
for (const domainConfig of this.baseEmailDomains) {
|
|
388
|
+
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
|
392
|
+
const key = managedConfig.domain.toLowerCase();
|
|
393
|
+
if (mergedDomains.has(key)) {
|
|
394
|
+
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
mergedDomains.set(key, managedConfig);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const domains = Array.from(mergedDomains.values());
|
|
401
|
+
this.dcRouter.options.emailConfig.domains = domains;
|
|
402
|
+
if (this.dcRouter.emailServer) {
|
|
403
|
+
this.dcRouter.emailServer.updateOptions({ domains });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
321
406
|
}
|