@serve.zone/dcrouter 13.25.0 → 13.26.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/.smartconfig.json +3 -11
- package/dist_serve/bundle.js +4046 -3552
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -1
- package/dist_ts/classes.dcrouter.js +6 -4
- package/dist_ts/config/classes.api-token-manager.d.ts +2 -2
- package/dist_ts/config/classes.api-token-manager.js +31 -3
- package/dist_ts/config/classes.route-config-manager.d.ts +1 -0
- package/dist_ts/config/classes.route-config-manager.js +38 -1
- package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
- package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
- package/dist_ts/email/classes.email-domain.manager.d.ts +4 -1
- package/dist_ts/email/classes.email-domain.manager.js +30 -1
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +35 -0
- package/dist_ts/email/classes.workapp-mail-manager.js +273 -0
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
- package/dist_ts/opsserver/classes.opsserver.js +3 -1
- package/dist_ts/opsserver/handlers/admin.handler.js +9 -4
- package/dist_ts/opsserver/handlers/api-token.handler.js +2 -2
- package/dist_ts/opsserver/handlers/certificate.handler.d.ts +4 -0
- package/dist_ts/opsserver/handlers/certificate.handler.js +41 -11
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts/opsserver/handlers/workhoster.handler.d.ts +26 -0
- package/dist_ts/opsserver/handlers/workhoster.handler.js +402 -0
- package/dist_ts_apiclient/classes.dcrouterapiclient.d.ts +2 -0
- package/dist_ts_apiclient/classes.dcrouterapiclient.js +4 -1
- package/dist_ts_apiclient/classes.workhoster.d.ts +14 -0
- package/dist_ts_apiclient/classes.workhoster.js +29 -0
- package/dist_ts_apiclient/index.d.ts +1 -0
- package/dist_ts_apiclient/index.js +2 -1
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/route-management.d.ts +38 -1
- package/dist_ts_interfaces/data/workhoster.d.ts +131 -0
- package/dist_ts_interfaces/data/workhoster.js +2 -0
- package/dist_ts_interfaces/requests/api-tokens.d.ts +2 -1
- package/dist_ts_interfaces/requests/certificate.d.ts +12 -6
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_interfaces/requests/workhoster.d.ts +98 -0
- package/dist_ts_interfaces/requests/workhoster.js +2 -0
- package/dist_ts_migrations/index.js +8 -2
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +1 -1
- package/dist_ts_web/appstate.js +3 -2
- package/dist_ts_web/elements/access/ops-view-apitokens.d.ts +1 -0
- package/dist_ts_web/elements/access/ops-view-apitokens.js +58 -3
- package/package.json +24 -23
- package/readme.md +108 -128
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +5 -3
- package/ts/config/classes.api-token-manager.ts +32 -1
- package/ts/config/classes.route-config-manager.ts +37 -0
- package/ts/db/documents/classes.api-token.doc.ts +4 -1
- package/ts/email/classes.email-domain.manager.ts +33 -1
- package/ts/email/classes.workapp-mail-manager.ts +343 -0
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/classes.opsserver.ts +3 -1
- package/ts/opsserver/handlers/admin.handler.ts +11 -4
- package/ts/opsserver/handlers/api-token.handler.ts +1 -0
- package/ts/opsserver/handlers/certificate.handler.ts +45 -12
- package/ts/opsserver/handlers/index.ts +2 -1
- package/ts/opsserver/handlers/workhoster.handler.ts +490 -0
- package/ts/readme.md +2 -2
- package/ts_apiclient/classes.dcrouterapiclient.ts +3 -0
- package/ts_apiclient/classes.workhoster.ts +49 -0
- package/ts_apiclient/index.ts +1 -0
- package/ts_apiclient/readme.md +54 -44
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +7 -1
- package/ts_web/elements/access/ops-view-apitokens.ts +58 -3
- package/ts_web/readme.md +36 -19
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
|
|
|
31
31
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
|
32
32
|
import { DnsManager } from './dns/manager.dns.js';
|
|
33
33
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
|
34
|
-
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
|
34
|
+
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
|
35
35
|
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
|
36
36
|
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
|
37
37
|
|
|
@@ -285,6 +285,7 @@ export class DcRouter {
|
|
|
285
285
|
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
|
286
286
|
public acmeConfigManager?: AcmeConfigManager;
|
|
287
287
|
public emailDomainManager?: EmailDomainManager;
|
|
288
|
+
public workAppMailManager: WorkAppMailManager;
|
|
288
289
|
public securityPolicyManager?: SecurityPolicyManager;
|
|
289
290
|
|
|
290
291
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
|
@@ -339,6 +340,7 @@ export class DcRouter {
|
|
|
339
340
|
this.storageManager = new SmartMtaStorageManager(
|
|
340
341
|
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
|
341
342
|
);
|
|
343
|
+
this.workAppMailManager = new WorkAppMailManager(this);
|
|
342
344
|
|
|
343
345
|
// Initialize service manager and register all services
|
|
344
346
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
|
@@ -1630,7 +1632,7 @@ export class DcRouter {
|
|
|
1630
1632
|
}
|
|
1631
1633
|
|
|
1632
1634
|
// Create config with mapped ports
|
|
1633
|
-
const emailConfig: IUnifiedEmailServerOptions = {
|
|
1635
|
+
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
|
1634
1636
|
...this.options.emailConfig,
|
|
1635
1637
|
domains: transformedDomains,
|
|
1636
1638
|
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
|
@@ -1640,7 +1642,7 @@ export class DcRouter {
|
|
|
1640
1642
|
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
|
1641
1643
|
...this.options.emailConfig.queue,
|
|
1642
1644
|
},
|
|
1643
|
-
};
|
|
1645
|
+
});
|
|
1644
1646
|
|
|
1645
1647
|
// Create unified email server
|
|
1646
1648
|
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
|
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
|
|
2
2
|
import { logger } from '../logger.js';
|
|
3
3
|
import { ApiTokenDoc } from '../db/index.js';
|
|
4
4
|
import type {
|
|
5
|
+
IApiTokenPolicy,
|
|
5
6
|
IStoredApiToken,
|
|
6
7
|
IApiTokenInfo,
|
|
7
8
|
TApiTokenScope,
|
|
@@ -33,6 +34,7 @@ export class ApiTokenManager {
|
|
|
33
34
|
scopes: TApiTokenScope[],
|
|
34
35
|
expiresInDays: number | null,
|
|
35
36
|
createdBy: string,
|
|
37
|
+
policy?: IApiTokenPolicy,
|
|
36
38
|
): Promise<{ id: string; rawToken: string }> {
|
|
37
39
|
const id = plugins.uuid.v4();
|
|
38
40
|
const randomBytes = plugins.crypto.randomBytes(32);
|
|
@@ -47,6 +49,7 @@ export class ApiTokenManager {
|
|
|
47
49
|
name,
|
|
48
50
|
tokenHash,
|
|
49
51
|
scopes,
|
|
52
|
+
policy,
|
|
50
53
|
createdAt: now,
|
|
51
54
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
|
52
55
|
lastUsedAt: null,
|
|
@@ -87,7 +90,31 @@ export class ApiTokenManager {
|
|
|
87
90
|
* Check if a token has a specific scope.
|
|
88
91
|
*/
|
|
89
92
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
|
90
|
-
|
|
93
|
+
if (token.policy?.role === 'admin') return true;
|
|
94
|
+
|
|
95
|
+
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
|
|
96
|
+
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
|
|
97
|
+
'gateway-clients:read',
|
|
98
|
+
'gateway-clients:write',
|
|
99
|
+
'workhosters:read',
|
|
100
|
+
'workhosters:write',
|
|
101
|
+
]);
|
|
102
|
+
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
|
|
107
|
+
|
|
108
|
+
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
|
109
|
+
if (scopes.has(scope)) return true;
|
|
110
|
+
|
|
111
|
+
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
|
112
|
+
'gateway-clients:read': ['workhosters:read'],
|
|
113
|
+
'gateway-clients:write': ['workhosters:write'],
|
|
114
|
+
'workhosters:read': ['gateway-clients:read'],
|
|
115
|
+
'workhosters:write': ['gateway-clients:write'],
|
|
116
|
+
};
|
|
117
|
+
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
|
91
118
|
}
|
|
92
119
|
|
|
93
120
|
/**
|
|
@@ -100,6 +127,7 @@ export class ApiTokenManager {
|
|
|
100
127
|
id: stored.id,
|
|
101
128
|
name: stored.name,
|
|
102
129
|
scopes: stored.scopes,
|
|
130
|
+
policy: stored.policy,
|
|
103
131
|
createdAt: stored.createdAt,
|
|
104
132
|
expiresAt: stored.expiresAt,
|
|
105
133
|
lastUsedAt: stored.lastUsedAt,
|
|
@@ -165,6 +193,7 @@ export class ApiTokenManager {
|
|
|
165
193
|
name: doc.name,
|
|
166
194
|
tokenHash: doc.tokenHash,
|
|
167
195
|
scopes: doc.scopes,
|
|
196
|
+
policy: doc.policy,
|
|
168
197
|
createdAt: doc.createdAt,
|
|
169
198
|
expiresAt: doc.expiresAt,
|
|
170
199
|
lastUsedAt: doc.lastUsedAt,
|
|
@@ -181,6 +210,7 @@ export class ApiTokenManager {
|
|
|
181
210
|
existing.name = stored.name;
|
|
182
211
|
existing.tokenHash = stored.tokenHash;
|
|
183
212
|
existing.scopes = stored.scopes;
|
|
213
|
+
existing.policy = stored.policy;
|
|
184
214
|
existing.createdAt = stored.createdAt;
|
|
185
215
|
existing.expiresAt = stored.expiresAt;
|
|
186
216
|
existing.lastUsedAt = stored.lastUsedAt;
|
|
@@ -193,6 +223,7 @@ export class ApiTokenManager {
|
|
|
193
223
|
doc.name = stored.name;
|
|
194
224
|
doc.tokenHash = stored.tokenHash;
|
|
195
225
|
doc.scopes = stored.scopes;
|
|
226
|
+
doc.policy = stored.policy;
|
|
196
227
|
doc.createdAt = stored.createdAt;
|
|
197
228
|
doc.expiresAt = stored.expiresAt;
|
|
198
229
|
doc.lastUsedAt = stored.lastUsedAt;
|
|
@@ -256,6 +256,15 @@ export class RouteConfigManager {
|
|
|
256
256
|
return this.updateRoute(id, { enabled });
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
|
|
260
|
+
for (const route of this.routes.values()) {
|
|
261
|
+
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
|
|
262
|
+
return route;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
259
268
|
// =========================================================================
|
|
260
269
|
// Private: seed routes from constructor config
|
|
261
270
|
// =========================================================================
|
|
@@ -443,6 +452,20 @@ export class RouteConfigManager {
|
|
|
443
452
|
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
|
444
453
|
? metadata.lastResolvedAt
|
|
445
454
|
: undefined,
|
|
455
|
+
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
|
456
|
+
? metadata.ownerType
|
|
457
|
+
: undefined,
|
|
458
|
+
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
|
459
|
+
? metadata.gatewayClientType
|
|
460
|
+
: metadata.workHosterType,
|
|
461
|
+
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
|
462
|
+
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
|
463
|
+
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
|
464
|
+
? metadata.workHosterType
|
|
465
|
+
: metadata.gatewayClientType,
|
|
466
|
+
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
|
467
|
+
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
|
468
|
+
externalKey: normalizeString(metadata.externalKey),
|
|
446
469
|
};
|
|
447
470
|
|
|
448
471
|
if (!normalized.sourceProfileRef) {
|
|
@@ -454,6 +477,20 @@ export class RouteConfigManager {
|
|
|
454
477
|
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
|
455
478
|
normalized.lastResolvedAt = undefined;
|
|
456
479
|
}
|
|
480
|
+
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
|
481
|
+
normalized.gatewayClientType = undefined;
|
|
482
|
+
normalized.gatewayClientId = undefined;
|
|
483
|
+
normalized.gatewayClientAppId = undefined;
|
|
484
|
+
normalized.workHosterType = undefined;
|
|
485
|
+
normalized.workHosterId = undefined;
|
|
486
|
+
normalized.workAppId = undefined;
|
|
487
|
+
normalized.externalKey = undefined;
|
|
488
|
+
} else {
|
|
489
|
+
normalized.ownerType = 'gatewayClient';
|
|
490
|
+
normalized.workHosterType = normalized.gatewayClientType;
|
|
491
|
+
normalized.workHosterId = normalized.gatewayClientId;
|
|
492
|
+
normalized.workAppId = normalized.gatewayClientAppId;
|
|
493
|
+
}
|
|
457
494
|
|
|
458
495
|
if (Object.values(normalized).every((value) => value === undefined)) {
|
|
459
496
|
return undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
-
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
|
3
|
+
import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
|
4
4
|
|
|
5
5
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
6
6
|
|
|
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
|
|
|
19
19
|
@plugins.smartdata.svDb()
|
|
20
20
|
public scopes!: TApiTokenScope[];
|
|
21
21
|
|
|
22
|
+
@plugins.smartdata.svDb()
|
|
23
|
+
public policy?: IApiTokenPolicy;
|
|
24
|
+
|
|
22
25
|
@plugins.smartdata.svDb()
|
|
23
26
|
public createdAt!: number;
|
|
24
27
|
|
|
@@ -57,6 +57,31 @@ export class EmailDomainManager {
|
|
|
57
57
|
return doc ? this.docToInterface(doc) : null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
|
|
61
|
+
const doc = await EmailDomainDoc.findByDomain(domainName);
|
|
62
|
+
return doc ? this.docToInterface(doc) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
|
|
66
|
+
const normalizedDomain = domainName.trim().toLowerCase();
|
|
67
|
+
const existing = await this.getByDomain(normalizedDomain);
|
|
68
|
+
if (existing) return existing;
|
|
69
|
+
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
|
|
70
|
+
|
|
71
|
+
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
|
|
72
|
+
if (!linkedDomain) {
|
|
73
|
+
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const subdomain = normalizedDomain === linkedDomain.name
|
|
77
|
+
? undefined
|
|
78
|
+
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
|
|
79
|
+
return await this.createEmailDomain({
|
|
80
|
+
linkedDomainId: linkedDomain.id,
|
|
81
|
+
subdomain,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
public async createEmailDomain(opts: {
|
|
61
86
|
linkedDomainId: string;
|
|
62
87
|
subdomain?: string;
|
|
@@ -351,6 +376,13 @@ export class EmailDomainManager {
|
|
|
351
376
|
return configuredDomains.includes(domainName.toLowerCase());
|
|
352
377
|
}
|
|
353
378
|
|
|
379
|
+
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
|
|
380
|
+
const domains = await DomainDoc.findAll();
|
|
381
|
+
return domains
|
|
382
|
+
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
|
|
383
|
+
.sort((a, b) => b.name.length - a.name.length)[0] || null;
|
|
384
|
+
}
|
|
385
|
+
|
|
354
386
|
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
|
355
387
|
const docs = await EmailDomainDoc.findAll();
|
|
356
388
|
const managedConfigs: IEmailDomainConfig[] = [];
|
|
@@ -378,7 +410,7 @@ export class EmailDomainManager {
|
|
|
378
410
|
return managedConfigs;
|
|
379
411
|
}
|
|
380
412
|
|
|
381
|
-
|
|
413
|
+
public async syncManagedDomainsToRuntime(): Promise<void> {
|
|
382
414
|
if (!this.dcRouter.options?.emailConfig) {
|
|
383
415
|
return;
|
|
384
416
|
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IEmailRoute,
|
|
3
|
+
IUnifiedEmailServerOptions,
|
|
4
|
+
} from '@push.rocks/smartmta';
|
|
5
|
+
import * as plugins from '../plugins.js';
|
|
6
|
+
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
7
|
+
|
|
8
|
+
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
|
|
9
|
+
|
|
10
|
+
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
|
|
11
|
+
smtpPassword: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface IStoredWorkAppMailState {
|
|
15
|
+
version: 1;
|
|
16
|
+
identities: IStoredWorkAppMailIdentity[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class WorkAppMailManager {
|
|
20
|
+
private readonly storageKey = '/workhosters/mail-identities.json';
|
|
21
|
+
|
|
22
|
+
constructor(private dcRouterRef: any) {}
|
|
23
|
+
|
|
24
|
+
public async listMailIdentities(
|
|
25
|
+
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
|
26
|
+
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
|
|
27
|
+
const identities = await this.readStoredIdentities();
|
|
28
|
+
return identities
|
|
29
|
+
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
|
|
30
|
+
.map((identity) => this.toPublicIdentity(identity));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async syncMailIdentity(
|
|
34
|
+
request: TSyncRequest,
|
|
35
|
+
createdBy: string,
|
|
36
|
+
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
|
37
|
+
if (!this.dcRouterRef.options.emailConfig) {
|
|
38
|
+
return { success: false, message: 'Email server is not configured' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ownership = this.normalizeOwnership(request.ownership);
|
|
42
|
+
const domain = this.normalizeDomain(request.domain);
|
|
43
|
+
const localPart = this.normalizeLocalPart(request.localPart);
|
|
44
|
+
const address = `${localPart}@${domain}`;
|
|
45
|
+
const externalKey = this.buildExternalKey(ownership, address);
|
|
46
|
+
const identities = await this.readStoredIdentities();
|
|
47
|
+
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
|
|
48
|
+
|
|
49
|
+
if (request.delete) {
|
|
50
|
+
if (existingIndex < 0) {
|
|
51
|
+
return { success: true, action: 'unchanged' };
|
|
52
|
+
}
|
|
53
|
+
const [deletedIdentity] = identities.splice(existingIndex, 1);
|
|
54
|
+
await this.writeStoredIdentities(identities);
|
|
55
|
+
await this.applyStoredIdentitiesToRuntime(identities);
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
action: 'deleted',
|
|
59
|
+
identity: this.toPublicIdentity(deletedIdentity),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.ensureEmailDomainConfigured(domain);
|
|
64
|
+
|
|
65
|
+
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const smtpPassword = existingIdentity && !request.resetSmtpPassword
|
|
68
|
+
? existingIdentity.smtpPassword
|
|
69
|
+
: this.generateSmtpPassword();
|
|
70
|
+
const identity: IStoredWorkAppMailIdentity = {
|
|
71
|
+
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
|
72
|
+
externalKey,
|
|
73
|
+
ownership,
|
|
74
|
+
address,
|
|
75
|
+
localPart,
|
|
76
|
+
domain,
|
|
77
|
+
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
|
|
78
|
+
displayName: request.displayName ?? existingIdentity?.displayName,
|
|
79
|
+
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
|
|
80
|
+
smtp: {
|
|
81
|
+
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
|
|
82
|
+
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
|
|
83
|
+
},
|
|
84
|
+
createdAt: existingIdentity?.createdAt || now,
|
|
85
|
+
updatedAt: now,
|
|
86
|
+
createdBy: existingIdentity?.createdBy || createdBy,
|
|
87
|
+
smtpPassword,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (existingIndex >= 0) {
|
|
91
|
+
identities[existingIndex] = identity;
|
|
92
|
+
} else {
|
|
93
|
+
identities.push(identity);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await this.writeStoredIdentities(identities);
|
|
97
|
+
await this.applyStoredIdentitiesToRuntime(identities);
|
|
98
|
+
|
|
99
|
+
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
|
|
100
|
+
success: true,
|
|
101
|
+
action: existingIndex >= 0 ? 'updated' : 'created',
|
|
102
|
+
identity: this.toPublicIdentity(identity),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (existingIndex < 0 || request.resetSmtpPassword) {
|
|
106
|
+
response.smtpCredentials = this.buildSmtpCredentials(identity);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
|
113
|
+
emailConfig: TConfig,
|
|
114
|
+
): Promise<TConfig> {
|
|
115
|
+
const identities = await this.readStoredIdentities();
|
|
116
|
+
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async applyStoredIdentitiesToRuntime(
|
|
120
|
+
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
|
123
|
+
if (!emailConfig) return;
|
|
124
|
+
|
|
125
|
+
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
|
|
126
|
+
emailConfig,
|
|
127
|
+
identities || await this.readStoredIdentities(),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
this.dcRouterRef.options.emailConfig = nextConfig;
|
|
131
|
+
if (this.dcRouterRef.emailServer) {
|
|
132
|
+
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
|
|
133
|
+
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
|
|
138
|
+
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
|
|
139
|
+
if (!storedData) return [];
|
|
140
|
+
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
|
|
141
|
+
return Array.isArray(parsed) ? parsed : parsed.identities || [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
|
|
145
|
+
const state: IStoredWorkAppMailState = {
|
|
146
|
+
version: 1,
|
|
147
|
+
identities,
|
|
148
|
+
};
|
|
149
|
+
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
|
153
|
+
emailConfig: TConfig,
|
|
154
|
+
identities: IStoredWorkAppMailIdentity[],
|
|
155
|
+
): TConfig {
|
|
156
|
+
const generatedRoutes = identities
|
|
157
|
+
.filter((identity) => identity.enabled && identity.inbound?.enabled)
|
|
158
|
+
.map((identity) => this.buildInboundRoute(identity));
|
|
159
|
+
const configuredRoutes = (emailConfig.routes || [])
|
|
160
|
+
.filter((route) => !this.isManagedMailRouteName(route.name));
|
|
161
|
+
const generatedUsers = identities
|
|
162
|
+
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
|
163
|
+
.map((identity) => ({
|
|
164
|
+
username: identity.smtp.username,
|
|
165
|
+
password: identity.smtpPassword,
|
|
166
|
+
}));
|
|
167
|
+
const configuredUsers = (emailConfig.auth?.users || [])
|
|
168
|
+
.filter((user) => !this.isManagedSmtpUsername(user.username));
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...emailConfig,
|
|
172
|
+
routes: [...configuredRoutes, ...generatedRoutes],
|
|
173
|
+
auth: {
|
|
174
|
+
...(emailConfig.auth || {}),
|
|
175
|
+
users: [...configuredUsers, ...generatedUsers],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
|
|
181
|
+
const inbound = identity.inbound!;
|
|
182
|
+
return {
|
|
183
|
+
name: this.buildRouteName(identity.externalKey),
|
|
184
|
+
priority: 1000,
|
|
185
|
+
match: {
|
|
186
|
+
recipients: identity.address,
|
|
187
|
+
},
|
|
188
|
+
action: {
|
|
189
|
+
type: 'forward',
|
|
190
|
+
forward: {
|
|
191
|
+
host: inbound.targetHost,
|
|
192
|
+
port: inbound.targetPort,
|
|
193
|
+
preserveHeaders: inbound.preserveHeaders ?? true,
|
|
194
|
+
addHeaders: {
|
|
195
|
+
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
|
|
196
|
+
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
|
|
197
|
+
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
|
|
198
|
+
...(inbound.addHeaders || {}),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
|
|
206
|
+
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
|
207
|
+
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const emailDomainManager = this.dcRouterRef.emailDomainManager;
|
|
212
|
+
if (!emailDomainManager) {
|
|
213
|
+
throw new Error(`Email domain is not configured: ${domain}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (await emailDomainManager.getByDomain(domain)) {
|
|
217
|
+
await emailDomainManager.syncManagedDomainsToRuntime();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await emailDomainManager.ensureEmailDomainForDomainName(domain);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private normalizeOwnership(
|
|
225
|
+
ownership: interfaces.data.IWorkAppMailOwnership,
|
|
226
|
+
): interfaces.data.IWorkAppMailOwnership {
|
|
227
|
+
const workHosterType = ownership.workHosterType;
|
|
228
|
+
const workHosterId = ownership.workHosterId?.trim();
|
|
229
|
+
const workAppId = ownership.workAppId?.trim();
|
|
230
|
+
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
|
|
231
|
+
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
|
|
232
|
+
}
|
|
233
|
+
if (!workHosterId) throw new Error('workHosterId is required');
|
|
234
|
+
if (!workAppId) throw new Error('workAppId is required');
|
|
235
|
+
return { workHosterType, workHosterId, workAppId };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private normalizeDomain(domain: string): string {
|
|
239
|
+
const normalized = domain?.trim().toLowerCase();
|
|
240
|
+
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
|
|
241
|
+
throw new Error(`Invalid email domain: ${domain}`);
|
|
242
|
+
}
|
|
243
|
+
return normalized;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private normalizeLocalPart(localPart: string): string {
|
|
247
|
+
const normalized = localPart?.trim().toLowerCase();
|
|
248
|
+
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
|
|
249
|
+
throw new Error(`Invalid email local part: ${localPart}`);
|
|
250
|
+
}
|
|
251
|
+
return normalized;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private normalizeInboundRoute(
|
|
255
|
+
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
|
256
|
+
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
|
257
|
+
if (!inbound) return undefined;
|
|
258
|
+
if (!inbound.enabled) {
|
|
259
|
+
return { ...inbound, enabled: false };
|
|
260
|
+
}
|
|
261
|
+
const targetHost = inbound.targetHost?.trim();
|
|
262
|
+
const targetPort = Number(inbound.targetPort);
|
|
263
|
+
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
|
|
264
|
+
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
265
|
+
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
...inbound,
|
|
269
|
+
targetHost,
|
|
270
|
+
targetPort,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private matchesOwnership(
|
|
275
|
+
ownership: interfaces.data.IWorkAppMailOwnership,
|
|
276
|
+
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
|
|
277
|
+
): boolean {
|
|
278
|
+
if (!filter) return true;
|
|
279
|
+
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
|
|
280
|
+
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
|
|
281
|
+
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private buildExternalKey(
|
|
286
|
+
ownership: interfaces.data.IWorkAppMailOwnership,
|
|
287
|
+
address: string,
|
|
288
|
+
): string {
|
|
289
|
+
return [
|
|
290
|
+
ownership.workHosterType,
|
|
291
|
+
ownership.workHosterId,
|
|
292
|
+
ownership.workAppId,
|
|
293
|
+
address,
|
|
294
|
+
].join(':');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private buildSmtpUsername(externalKey: string): string {
|
|
298
|
+
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private buildRouteName(externalKey: string): string {
|
|
302
|
+
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private hashExternalKey(externalKey: string): string {
|
|
306
|
+
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private generateSmtpPassword(): string {
|
|
310
|
+
return plugins.crypto.randomBytes(24).toString('base64url');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private isManagedMailRouteName(routeName: string): boolean {
|
|
314
|
+
return routeName.startsWith('workapp-mail-');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private isManagedSmtpUsername(username: string): boolean {
|
|
318
|
+
return username.startsWith('workapp-');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private buildSmtpCredentials(
|
|
322
|
+
identity: IStoredWorkAppMailIdentity,
|
|
323
|
+
): interfaces.data.IWorkAppMailCredentials {
|
|
324
|
+
return {
|
|
325
|
+
username: identity.smtp.username,
|
|
326
|
+
password: identity.smtpPassword,
|
|
327
|
+
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|
|
328
|
+
|| this.dcRouterRef.options.emailConfig?.hostname,
|
|
329
|
+
ports: {
|
|
330
|
+
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
|
|
331
|
+
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
|
|
332
|
+
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private toPublicIdentity(
|
|
338
|
+
identity: IStoredWorkAppMailIdentity,
|
|
339
|
+
): interfaces.data.IWorkAppMailIdentity {
|
|
340
|
+
const { smtpPassword, ...publicIdentity } = identity;
|
|
341
|
+
return publicIdentity;
|
|
342
|
+
}
|
|
343
|
+
}
|
package/ts/email/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ export class OpsServer {
|
|
|
38
38
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
|
39
39
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
|
40
40
|
private emailDomainHandler!: handlers.EmailDomainHandler;
|
|
41
|
+
private workHosterHandler!: handlers.WorkHosterHandler;
|
|
41
42
|
|
|
42
43
|
constructor(dcRouterRefArg: DcRouter) {
|
|
43
44
|
this.dcRouterRef = dcRouterRefArg;
|
|
@@ -106,6 +107,7 @@ export class OpsServer {
|
|
|
106
107
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
|
107
108
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
|
108
109
|
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
|
110
|
+
this.workHosterHandler = new handlers.WorkHosterHandler(this);
|
|
109
111
|
|
|
110
112
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
|
111
113
|
}
|
|
@@ -119,4 +121,4 @@ export class OpsServer {
|
|
|
119
121
|
await this.server.stop();
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
|
-
}
|
|
124
|
+
}
|
|
@@ -43,14 +43,21 @@ export class AdminHandler {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
private initializeDefaultUsers(): void {
|
|
46
|
-
|
|
46
|
+
const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
|
|
47
|
+
const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
|
48
|
+
const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
|
|
49
|
+
|
|
47
50
|
const adminId = plugins.uuid.v4();
|
|
48
51
|
this.users.set(adminId, {
|
|
49
52
|
id: adminId,
|
|
50
|
-
username
|
|
51
|
-
password
|
|
53
|
+
username,
|
|
54
|
+
password,
|
|
52
55
|
role: 'admin',
|
|
53
56
|
});
|
|
57
|
+
|
|
58
|
+
if (!configuredPassword) {
|
|
59
|
+
console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
@@ -249,4 +256,4 @@ export class AdminHandler {
|
|
|
249
256
|
name: 'adminIdentityGuard',
|
|
250
257
|
}
|
|
251
258
|
);
|
|
252
|
-
}
|
|
259
|
+
}
|