@serve.zone/dcrouter 13.18.0 → 13.19.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/dist_serve/bundle.js +532 -531
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +50 -39
- package/dist_ts/config/classes.route-config-manager.d.ts +14 -5
- package/dist_ts/config/classes.route-config-manager.js +121 -44
- 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.js +9 -28
- 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 +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/handlers/route-management.handler.js +5 -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 +124 -36
- package/package.json +2 -3
- package/readme.md +190 -1543
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +61 -47
- package/ts/config/classes.route-config-manager.ts +148 -50
- package/ts/db/documents/classes.route.doc.ts +7 -0
- package/ts/email/classes.email-domain.manager.ts +8 -28
- package/ts/email/email-dns-records.ts +53 -0
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/handlers/route-management.handler.ts +4 -6
- 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 +136 -44
- package/ts_web/readme.md +41 -242
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -30,7 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
|
|
30
30
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
|
31
31
|
import { DnsManager } from './dns/manager.dns.js';
|
|
32
32
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
|
33
|
-
import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js';
|
|
33
|
+
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
|
34
|
+
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
|
34
35
|
|
|
35
36
|
export interface IDcRouterOptions {
|
|
36
37
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
|
@@ -314,7 +315,8 @@ export class DcRouter {
|
|
|
314
315
|
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
|
315
316
|
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
316
317
|
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
317
|
-
|
|
318
|
+
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
319
|
+
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
|
|
318
320
|
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
319
321
|
|
|
320
322
|
// Environment access
|
|
@@ -588,13 +590,15 @@ export class DcRouter {
|
|
|
588
590
|
this.tunnelManager.syncAllowedEdges();
|
|
589
591
|
}
|
|
590
592
|
},
|
|
591
|
-
|
|
593
|
+
undefined,
|
|
594
|
+
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
|
592
595
|
);
|
|
593
596
|
this.apiTokenManager = new ApiTokenManager();
|
|
594
597
|
await this.apiTokenManager.initialize();
|
|
595
598
|
await this.routeConfigManager.initialize(
|
|
596
599
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
597
600
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
601
|
+
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
598
602
|
);
|
|
599
603
|
await this.targetProfileManager.normalizeAllRouteRefs();
|
|
600
604
|
|
|
@@ -912,10 +916,12 @@ export class DcRouter {
|
|
|
912
916
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
|
913
917
|
}
|
|
914
918
|
|
|
919
|
+
this.seedDnsRoutes = [];
|
|
915
920
|
this.runtimeDnsRoutes = [];
|
|
916
921
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
|
917
|
-
this.
|
|
918
|
-
|
|
922
|
+
this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
|
|
923
|
+
this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
|
|
924
|
+
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
|
|
919
925
|
}
|
|
920
926
|
|
|
921
927
|
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
|
@@ -1338,19 +1344,20 @@ export class DcRouter {
|
|
|
1338
1344
|
/**
|
|
1339
1345
|
* Generate SmartProxy routes for DNS configuration
|
|
1340
1346
|
*/
|
|
1341
|
-
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
|
|
1347
|
+
private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
|
|
1342
1348
|
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
|
|
1343
1349
|
return [];
|
|
1344
1350
|
}
|
|
1345
|
-
|
|
1351
|
+
|
|
1352
|
+
const includeSocketHandler = options?.includeSocketHandler !== false;
|
|
1346
1353
|
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
|
1347
|
-
|
|
1354
|
+
|
|
1348
1355
|
// Create routes for DNS-over-HTTPS paths
|
|
1349
1356
|
const dohPaths = ['/dns-query', '/resolve'];
|
|
1350
|
-
|
|
1357
|
+
|
|
1351
1358
|
// Use the first nameserver domain for DoH routes
|
|
1352
1359
|
const primaryNameserver = this.options.dnsNsDomains[0];
|
|
1353
|
-
|
|
1360
|
+
|
|
1354
1361
|
for (const path of dohPaths) {
|
|
1355
1362
|
const dohRoute: plugins.smartproxy.IRouteConfig = {
|
|
1356
1363
|
name: `dns-over-https-${path.replace('/', '')}`,
|
|
@@ -1359,18 +1366,42 @@ export class DcRouter {
|
|
|
1359
1366
|
domains: [primaryNameserver],
|
|
1360
1367
|
path: path
|
|
1361
1368
|
},
|
|
1362
|
-
action:
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1369
|
+
action: includeSocketHandler
|
|
1370
|
+
? {
|
|
1371
|
+
type: 'socket-handler' as any,
|
|
1372
|
+
socketHandler: this.createDnsSocketHandler()
|
|
1373
|
+
} as any
|
|
1374
|
+
: {
|
|
1375
|
+
type: 'socket-handler' as any,
|
|
1376
|
+
} as any
|
|
1366
1377
|
};
|
|
1367
|
-
|
|
1378
|
+
|
|
1368
1379
|
dnsRoutes.push(dohRoute);
|
|
1369
1380
|
}
|
|
1370
|
-
|
|
1381
|
+
|
|
1371
1382
|
return dnsRoutes;
|
|
1372
1383
|
}
|
|
1373
1384
|
|
|
1385
|
+
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
|
|
1386
|
+
const routeName = storedRoute.route.name || '';
|
|
1387
|
+
const isDohRoute = storedRoute.origin === 'dns'
|
|
1388
|
+
&& storedRoute.route.action?.type === 'socket-handler'
|
|
1389
|
+
&& routeName.startsWith('dns-over-https-');
|
|
1390
|
+
|
|
1391
|
+
if (!isDohRoute) {
|
|
1392
|
+
return undefined;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
return {
|
|
1396
|
+
...storedRoute.route,
|
|
1397
|
+
action: {
|
|
1398
|
+
...storedRoute.route.action,
|
|
1399
|
+
type: 'socket-handler' as any,
|
|
1400
|
+
socketHandler: this.createDnsSocketHandler(),
|
|
1401
|
+
} as any,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1374
1405
|
/**
|
|
1375
1406
|
* Check if a domain matches a pattern (including wildcard support)
|
|
1376
1407
|
* @param domain The domain to check
|
|
@@ -1939,37 +1970,20 @@ export class DcRouter {
|
|
|
1939
1970
|
for (const domainConfig of internalDnsDomains) {
|
|
1940
1971
|
const domain = domainConfig.domain;
|
|
1941
1972
|
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
type: 'TXT',
|
|
1957
|
-
value: spfRecord,
|
|
1958
|
-
ttl
|
|
1959
|
-
});
|
|
1960
|
-
|
|
1961
|
-
// DMARC record - using sensible defaults
|
|
1962
|
-
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
|
|
1963
|
-
const dmarcEmail = `dmarc@${domain}`;
|
|
1964
|
-
records.push({
|
|
1965
|
-
name: `_dmarc.${domain}`,
|
|
1966
|
-
type: 'TXT',
|
|
1967
|
-
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
|
|
1968
|
-
ttl
|
|
1969
|
-
});
|
|
1970
|
-
|
|
1971
|
-
// Note: DKIM records will be generated later when DKIM keys are available
|
|
1972
|
-
// They require the DKIMCreator which is part of the email server
|
|
1973
|
+
const requiredRecords = buildEmailDnsRecords({
|
|
1974
|
+
domain,
|
|
1975
|
+
hostname: this.options.emailConfig.hostname,
|
|
1976
|
+
mxPriority: domainConfig.dns?.internal?.mxPriority,
|
|
1977
|
+
}).filter((record) => !record.name.includes('._domainkey.'));
|
|
1978
|
+
|
|
1979
|
+
for (const record of requiredRecords) {
|
|
1980
|
+
records.push({
|
|
1981
|
+
name: record.name,
|
|
1982
|
+
type: record.type,
|
|
1983
|
+
value: record.value,
|
|
1984
|
+
ttl,
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1973
1987
|
}
|
|
1974
1988
|
|
|
1975
1989
|
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
|
|
@@ -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,
|
|
@@ -122,11 +133,11 @@ export class RouteConfigManager {
|
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
// Resolve references if metadata has refs and resolver is available
|
|
125
|
-
let resolvedMetadata = metadata;
|
|
126
|
-
if (
|
|
127
|
-
const resolved = this.referenceResolver.resolveRoute(route,
|
|
136
|
+
let resolvedMetadata = this.normalizeRouteMetadata(metadata);
|
|
137
|
+
if (resolvedMetadata && this.referenceResolver) {
|
|
138
|
+
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
|
|
128
139
|
route = resolved.route;
|
|
129
|
-
resolvedMetadata = resolved.metadata;
|
|
140
|
+
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
const stored: IRoute = {
|
|
@@ -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
|
|
@@ -175,33 +198,46 @@ export class RouteConfigManager {
|
|
|
175
198
|
stored.enabled = patch.enabled;
|
|
176
199
|
}
|
|
177
200
|
if (patch.metadata !== undefined) {
|
|
178
|
-
stored.metadata = {
|
|
201
|
+
stored.metadata = this.normalizeRouteMetadata({
|
|
202
|
+
...stored.metadata,
|
|
203
|
+
...patch.metadata,
|
|
204
|
+
});
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
// Re-resolve if metadata refs exist and resolver is available
|
|
182
208
|
if (stored.metadata && this.referenceResolver) {
|
|
183
209
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
|
184
210
|
stored.route = resolved.route;
|
|
185
|
-
stored.metadata = resolved.metadata;
|
|
211
|
+
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
|
186
212
|
}
|
|
187
213
|
|
|
188
214
|
stored.updatedAt = Date.now();
|
|
189
215
|
|
|
190
216
|
await this.persistRoute(stored);
|
|
191
217
|
await this.applyRoutes();
|
|
192
|
-
return true;
|
|
218
|
+
return { success: true };
|
|
193
219
|
}
|
|
194
220
|
|
|
195
|
-
public async deleteRoute(id: string): Promise<
|
|
196
|
-
|
|
221
|
+
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
|
222
|
+
const stored = this.routes.get(id);
|
|
223
|
+
if (!stored) {
|
|
224
|
+
return { success: false, message: 'Route not found' };
|
|
225
|
+
}
|
|
226
|
+
if (stored.origin !== 'api') {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
message: 'System routes are managed by the system and cannot be deleted',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
197
233
|
this.routes.delete(id);
|
|
198
234
|
const doc = await RouteDoc.findById(id);
|
|
199
235
|
if (doc) await doc.delete();
|
|
200
236
|
await this.applyRoutes();
|
|
201
|
-
return true;
|
|
237
|
+
return { success: true };
|
|
202
238
|
}
|
|
203
239
|
|
|
204
|
-
public async toggleRoute(id: string, enabled: boolean): Promise<
|
|
240
|
+
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
|
205
241
|
return this.updateRoute(id, { enabled });
|
|
206
242
|
}
|
|
207
243
|
|
|
@@ -217,29 +253,28 @@ export class RouteConfigManager {
|
|
|
217
253
|
seedRoutes: IDcRouterRouteConfig[],
|
|
218
254
|
origin: 'config' | 'email' | 'dns',
|
|
219
255
|
): Promise<void> {
|
|
220
|
-
|
|
221
|
-
|
|
256
|
+
const seedSystemKeys = new Set<string>();
|
|
222
257
|
const seedNames = new Set<string>();
|
|
223
258
|
let seeded = 0;
|
|
224
259
|
let updated = 0;
|
|
225
260
|
|
|
226
261
|
for (const route of seedRoutes) {
|
|
227
262
|
const name = route.name || '';
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
existingId = id;
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
263
|
+
if (name) {
|
|
264
|
+
seedNames.add(name);
|
|
265
|
+
}
|
|
266
|
+
const systemKey = this.buildSystemRouteKey(origin, route);
|
|
267
|
+
if (systemKey) {
|
|
268
|
+
seedSystemKeys.add(systemKey);
|
|
237
269
|
}
|
|
238
270
|
|
|
271
|
+
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
|
|
272
|
+
|
|
239
273
|
if (existingId) {
|
|
240
274
|
// Update route config but preserve enabled state
|
|
241
275
|
const existing = this.routes.get(existingId)!;
|
|
242
276
|
existing.route = route;
|
|
277
|
+
existing.systemKey = systemKey;
|
|
243
278
|
existing.updatedAt = Date.now();
|
|
244
279
|
await this.persistRoute(existing);
|
|
245
280
|
updated++;
|
|
@@ -255,6 +290,7 @@ export class RouteConfigManager {
|
|
|
255
290
|
updatedAt: now,
|
|
256
291
|
createdBy: 'system',
|
|
257
292
|
origin,
|
|
293
|
+
systemKey,
|
|
258
294
|
};
|
|
259
295
|
this.routes.set(id, newRoute);
|
|
260
296
|
await this.persistRoute(newRoute);
|
|
@@ -265,7 +301,12 @@ export class RouteConfigManager {
|
|
|
265
301
|
// Delete stale routes: same origin but name not in current seed set
|
|
266
302
|
const staleIds: string[] = [];
|
|
267
303
|
for (const [id, r] of this.routes) {
|
|
268
|
-
if (r.origin
|
|
304
|
+
if (r.origin !== origin) continue;
|
|
305
|
+
|
|
306
|
+
const routeName = r.route.name || '';
|
|
307
|
+
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
|
|
308
|
+
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
|
|
309
|
+
if (!matchesSeedSystemKey && !matchesSeedName) {
|
|
269
310
|
staleIds.push(id);
|
|
270
311
|
}
|
|
271
312
|
}
|
|
@@ -284,9 +325,39 @@ export class RouteConfigManager {
|
|
|
284
325
|
// Private: persistence
|
|
285
326
|
// =========================================================================
|
|
286
327
|
|
|
328
|
+
private buildSystemRouteKey(
|
|
329
|
+
origin: 'config' | 'email' | 'dns',
|
|
330
|
+
route: IDcRouterRouteConfig,
|
|
331
|
+
): string | undefined {
|
|
332
|
+
const name = route.name?.trim();
|
|
333
|
+
if (!name) return undefined;
|
|
334
|
+
return `${origin}:${name}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private findExistingSeedRouteId(
|
|
338
|
+
origin: 'config' | 'email' | 'dns',
|
|
339
|
+
route: IDcRouterRouteConfig,
|
|
340
|
+
systemKey?: string,
|
|
341
|
+
): string | undefined {
|
|
342
|
+
const routeName = route.name || '';
|
|
343
|
+
|
|
344
|
+
for (const [id, storedRoute] of this.routes) {
|
|
345
|
+
if (storedRoute.origin !== origin) continue;
|
|
346
|
+
|
|
347
|
+
if (systemKey && storedRoute.systemKey === systemKey) {
|
|
348
|
+
return id;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (storedRoute.route.name === routeName) {
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
287
359
|
private async loadRoutes(): Promise<void> {
|
|
288
360
|
const docs = await RouteDoc.findAll();
|
|
289
|
-
let prunedRuntimeRoutes = 0;
|
|
290
361
|
|
|
291
362
|
for (const doc of docs) {
|
|
292
363
|
if (!doc.id) continue;
|
|
@@ -299,27 +370,15 @@ export class RouteConfigManager {
|
|
|
299
370
|
updatedAt: doc.updatedAt,
|
|
300
371
|
createdBy: doc.createdBy,
|
|
301
372
|
origin: doc.origin || 'api',
|
|
302
|
-
|
|
373
|
+
systemKey: doc.systemKey,
|
|
374
|
+
metadata: this.normalizeRouteMetadata(doc.metadata),
|
|
303
375
|
};
|
|
304
376
|
|
|
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
377
|
this.routes.set(doc.id, storedRoute);
|
|
316
378
|
}
|
|
317
379
|
if (this.routes.size > 0) {
|
|
318
380
|
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
|
319
381
|
}
|
|
320
|
-
if (prunedRuntimeRoutes > 0) {
|
|
321
|
-
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
|
322
|
-
}
|
|
323
382
|
}
|
|
324
383
|
|
|
325
384
|
private async persistRoute(stored: IRoute): Promise<void> {
|
|
@@ -330,6 +389,7 @@ export class RouteConfigManager {
|
|
|
330
389
|
existingDoc.updatedAt = stored.updatedAt;
|
|
331
390
|
existingDoc.createdBy = stored.createdBy;
|
|
332
391
|
existingDoc.origin = stored.origin;
|
|
392
|
+
existingDoc.systemKey = stored.systemKey;
|
|
333
393
|
existingDoc.metadata = stored.metadata;
|
|
334
394
|
await existingDoc.save();
|
|
335
395
|
} else {
|
|
@@ -341,11 +401,52 @@ export class RouteConfigManager {
|
|
|
341
401
|
doc.updatedAt = stored.updatedAt;
|
|
342
402
|
doc.createdBy = stored.createdBy;
|
|
343
403
|
doc.origin = stored.origin;
|
|
404
|
+
doc.systemKey = stored.systemKey;
|
|
344
405
|
doc.metadata = stored.metadata;
|
|
345
406
|
await doc.save();
|
|
346
407
|
}
|
|
347
408
|
}
|
|
348
409
|
|
|
410
|
+
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
|
|
411
|
+
if (!metadata) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const normalizeString = (value?: string): string | undefined => {
|
|
416
|
+
if (typeof value !== 'string') {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const trimmed = value.trim();
|
|
420
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const normalized: IRouteMetadata = {
|
|
424
|
+
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
|
425
|
+
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
|
426
|
+
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
|
427
|
+
networkTargetName: normalizeString(metadata.networkTargetName),
|
|
428
|
+
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
|
429
|
+
? metadata.lastResolvedAt
|
|
430
|
+
: undefined,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (!normalized.sourceProfileRef) {
|
|
434
|
+
normalized.sourceProfileName = undefined;
|
|
435
|
+
}
|
|
436
|
+
if (!normalized.networkTargetRef) {
|
|
437
|
+
normalized.networkTargetName = undefined;
|
|
438
|
+
}
|
|
439
|
+
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
|
440
|
+
normalized.lastResolvedAt = undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (Object.values(normalized).every((value) => value === undefined)) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return normalized;
|
|
448
|
+
}
|
|
449
|
+
|
|
349
450
|
// =========================================================================
|
|
350
451
|
// Private: warnings
|
|
351
452
|
// =========================================================================
|
|
@@ -388,7 +489,7 @@ export class RouteConfigManager {
|
|
|
388
489
|
|
|
389
490
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
|
390
491
|
stored.route = resolved.route;
|
|
391
|
-
stored.metadata = resolved.metadata;
|
|
492
|
+
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
|
392
493
|
stored.updatedAt = Date.now();
|
|
393
494
|
await this.persistRoute(stored);
|
|
394
495
|
}
|
|
@@ -411,7 +512,7 @@ export class RouteConfigManager {
|
|
|
411
512
|
// Add all enabled routes with HTTP/3 and VPN augmentation
|
|
412
513
|
for (const route of this.routes.values()) {
|
|
413
514
|
if (route.enabled) {
|
|
414
|
-
enabledRoutes.push(this.
|
|
515
|
+
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
|
415
516
|
}
|
|
416
517
|
}
|
|
417
518
|
|
|
@@ -431,6 +532,11 @@ export class RouteConfigManager {
|
|
|
431
532
|
});
|
|
432
533
|
}
|
|
433
534
|
|
|
535
|
+
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
|
536
|
+
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
|
537
|
+
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
|
538
|
+
}
|
|
539
|
+
|
|
434
540
|
private prepareRouteForApply(
|
|
435
541
|
route: plugins.smartproxy.IRouteConfig,
|
|
436
542
|
routeId?: string,
|
|
@@ -465,12 +571,4 @@ export class RouteConfigManager {
|
|
|
465
571
|
},
|
|
466
572
|
};
|
|
467
573
|
}
|
|
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
574
|
}
|
|
@@ -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
|
}
|
|
@@ -6,6 +6,7 @@ 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
8
|
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
|
9
|
+
import { buildEmailDnsRecords } from './email-dns-records.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* EmailDomainManager — orchestrates email domain setup.
|
|
@@ -181,34 +182,13 @@ export class EmailDomainManager {
|
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
type: 'TXT',
|
|
193
|
-
name: domain,
|
|
194
|
-
value: 'v=spf1 a mx ~all',
|
|
195
|
-
status: doc.dnsStatus.spf,
|
|
196
|
-
},
|
|
197
|
-
{
|
|
198
|
-
type: 'TXT',
|
|
199
|
-
name: `${selector}._domainkey.${domain}`,
|
|
200
|
-
value: dkimValue,
|
|
201
|
-
status: doc.dnsStatus.dkim,
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
type: 'TXT',
|
|
205
|
-
name: `_dmarc.${domain}`,
|
|
206
|
-
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
|
|
207
|
-
status: doc.dnsStatus.dmarc,
|
|
208
|
-
},
|
|
209
|
-
];
|
|
210
|
-
|
|
211
|
-
return records;
|
|
185
|
+
return buildEmailDnsRecords({
|
|
186
|
+
domain,
|
|
187
|
+
hostname,
|
|
188
|
+
selector,
|
|
189
|
+
dkimValue,
|
|
190
|
+
statuses: doc.dnsStatus,
|
|
191
|
+
});
|
|
212
192
|
}
|
|
213
193
|
|
|
214
194
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IEmailDnsRecord,
|
|
3
|
+
TDnsRecordStatus,
|
|
4
|
+
} from '../../ts_interfaces/data/email-domain.js';
|
|
5
|
+
|
|
6
|
+
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
|
|
7
|
+
|
|
8
|
+
export interface IBuildEmailDnsRecordsOptions {
|
|
9
|
+
domain: string;
|
|
10
|
+
hostname: string;
|
|
11
|
+
selector?: string;
|
|
12
|
+
dkimValue?: string;
|
|
13
|
+
mxPriority?: number;
|
|
14
|
+
dmarcPolicy?: string;
|
|
15
|
+
dmarcRua?: string;
|
|
16
|
+
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
|
|
20
|
+
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
|
|
21
|
+
const selector = options.selector || 'default';
|
|
22
|
+
const records: IEmailDnsRecord[] = [
|
|
23
|
+
{
|
|
24
|
+
type: 'MX',
|
|
25
|
+
name: options.domain,
|
|
26
|
+
value: `${options.mxPriority ?? 10} ${options.hostname}`,
|
|
27
|
+
status: statusFor('mx'),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'TXT',
|
|
31
|
+
name: options.domain,
|
|
32
|
+
value: 'v=spf1 a mx ~all',
|
|
33
|
+
status: statusFor('spf'),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'TXT',
|
|
37
|
+
name: `_dmarc.${options.domain}`,
|
|
38
|
+
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
|
|
39
|
+
status: statusFor('dmarc'),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (options.dkimValue) {
|
|
44
|
+
records.splice(2, 0, {
|
|
45
|
+
type: 'TXT',
|
|
46
|
+
name: `${selector}._domainkey.${options.domain}`,
|
|
47
|
+
value: options.dkimValue,
|
|
48
|
+
status: statusFor('dkim'),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return records;
|
|
53
|
+
}
|
package/ts/email/index.ts
CHANGED