@serve.zone/dcrouter 13.25.0 → 13.27.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 +4 -2
- package/dist_ts/config/classes.api-token-manager.js +68 -6
- 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 +73 -4
- 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,12 +2,15 @@ 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,
|
|
8
9
|
} from '../../ts_interfaces/data/route-management.js';
|
|
9
10
|
|
|
10
11
|
const TOKEN_PREFIX_STR = 'dcr_';
|
|
12
|
+
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
|
|
13
|
+
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
|
|
11
14
|
|
|
12
15
|
export class ApiTokenManager {
|
|
13
16
|
private tokens = new Map<string, IStoredApiToken>();
|
|
@@ -16,6 +19,7 @@ export class ApiTokenManager {
|
|
|
16
19
|
|
|
17
20
|
public async initialize(): Promise<void> {
|
|
18
21
|
await this.loadTokens();
|
|
22
|
+
await this.ensureEnvAdminToken();
|
|
19
23
|
if (this.tokens.size > 0) {
|
|
20
24
|
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
|
21
25
|
}
|
|
@@ -33,13 +37,14 @@ export class ApiTokenManager {
|
|
|
33
37
|
scopes: TApiTokenScope[],
|
|
34
38
|
expiresInDays: number | null,
|
|
35
39
|
createdBy: string,
|
|
40
|
+
policy?: IApiTokenPolicy,
|
|
36
41
|
): Promise<{ id: string; rawToken: string }> {
|
|
37
42
|
const id = plugins.uuid.v4();
|
|
38
43
|
const randomBytes = plugins.crypto.randomBytes(32);
|
|
39
44
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
|
40
45
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
|
41
46
|
|
|
42
|
-
const tokenHash =
|
|
47
|
+
const tokenHash = this.hashToken(rawToken);
|
|
43
48
|
|
|
44
49
|
const now = Date.now();
|
|
45
50
|
const stored: IStoredApiToken = {
|
|
@@ -47,6 +52,7 @@ export class ApiTokenManager {
|
|
|
47
52
|
name,
|
|
48
53
|
tokenHash,
|
|
49
54
|
scopes,
|
|
55
|
+
policy,
|
|
50
56
|
createdAt: now,
|
|
51
57
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
|
52
58
|
lastUsedAt: null,
|
|
@@ -67,7 +73,7 @@ export class ApiTokenManager {
|
|
|
67
73
|
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
|
68
74
|
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
|
69
75
|
|
|
70
|
-
const hash =
|
|
76
|
+
const hash = this.hashToken(rawToken);
|
|
71
77
|
|
|
72
78
|
for (const stored of this.tokens.values()) {
|
|
73
79
|
if (stored.tokenHash === hash) {
|
|
@@ -87,7 +93,31 @@ export class ApiTokenManager {
|
|
|
87
93
|
* Check if a token has a specific scope.
|
|
88
94
|
*/
|
|
89
95
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
|
90
|
-
|
|
96
|
+
if (token.policy?.role === 'admin') return true;
|
|
97
|
+
|
|
98
|
+
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
|
|
99
|
+
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
|
|
100
|
+
'gateway-clients:read',
|
|
101
|
+
'gateway-clients:write',
|
|
102
|
+
'workhosters:read',
|
|
103
|
+
'workhosters:write',
|
|
104
|
+
]);
|
|
105
|
+
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
|
|
110
|
+
|
|
111
|
+
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
|
112
|
+
if (scopes.has(scope)) return true;
|
|
113
|
+
|
|
114
|
+
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
|
115
|
+
'gateway-clients:read': ['workhosters:read'],
|
|
116
|
+
'gateway-clients:write': ['workhosters:write'],
|
|
117
|
+
'workhosters:read': ['gateway-clients:read'],
|
|
118
|
+
'workhosters:write': ['gateway-clients:write'],
|
|
119
|
+
};
|
|
120
|
+
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
/**
|
|
@@ -100,6 +130,7 @@ export class ApiTokenManager {
|
|
|
100
130
|
id: stored.id,
|
|
101
131
|
name: stored.name,
|
|
102
132
|
scopes: stored.scopes,
|
|
133
|
+
policy: stored.policy,
|
|
103
134
|
createdAt: stored.createdAt,
|
|
104
135
|
expiresAt: stored.expiresAt,
|
|
105
136
|
lastUsedAt: stored.lastUsedAt,
|
|
@@ -134,7 +165,7 @@ export class ApiTokenManager {
|
|
|
134
165
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
|
135
166
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
|
136
167
|
|
|
137
|
-
stored.tokenHash =
|
|
168
|
+
stored.tokenHash = this.hashToken(rawToken);
|
|
138
169
|
await this.persistToken(stored);
|
|
139
170
|
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
|
140
171
|
return { id, rawToken };
|
|
@@ -165,6 +196,7 @@ export class ApiTokenManager {
|
|
|
165
196
|
name: doc.name,
|
|
166
197
|
tokenHash: doc.tokenHash,
|
|
167
198
|
scopes: doc.scopes,
|
|
199
|
+
policy: doc.policy,
|
|
168
200
|
createdAt: doc.createdAt,
|
|
169
201
|
expiresAt: doc.expiresAt,
|
|
170
202
|
lastUsedAt: doc.lastUsedAt,
|
|
@@ -175,12 +207,48 @@ export class ApiTokenManager {
|
|
|
175
207
|
}
|
|
176
208
|
}
|
|
177
209
|
|
|
210
|
+
private async ensureEnvAdminToken(): Promise<void> {
|
|
211
|
+
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
|
|
212
|
+
if (!rawToken) return;
|
|
213
|
+
|
|
214
|
+
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
|
|
215
|
+
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
|
|
216
|
+
}
|
|
217
|
+
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
|
|
218
|
+
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
|
|
223
|
+
const stored: IStoredApiToken = {
|
|
224
|
+
id: ENV_ADMIN_TOKEN_ID,
|
|
225
|
+
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
|
|
226
|
+
tokenHash: this.hashToken(rawToken),
|
|
227
|
+
scopes: ['*'],
|
|
228
|
+
policy: { role: 'admin' },
|
|
229
|
+
createdAt: existing?.createdAt || now,
|
|
230
|
+
expiresAt: null,
|
|
231
|
+
lastUsedAt: existing?.lastUsedAt || null,
|
|
232
|
+
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
|
|
233
|
+
enabled: true,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
this.tokens.set(stored.id, stored);
|
|
237
|
+
await this.persistToken(stored);
|
|
238
|
+
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private hashToken(rawToken: string): string {
|
|
242
|
+
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
243
|
+
}
|
|
244
|
+
|
|
178
245
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
|
179
246
|
const existing = await ApiTokenDoc.findById(stored.id);
|
|
180
247
|
if (existing) {
|
|
181
248
|
existing.name = stored.name;
|
|
182
249
|
existing.tokenHash = stored.tokenHash;
|
|
183
250
|
existing.scopes = stored.scopes;
|
|
251
|
+
existing.policy = stored.policy;
|
|
184
252
|
existing.createdAt = stored.createdAt;
|
|
185
253
|
existing.expiresAt = stored.expiresAt;
|
|
186
254
|
existing.lastUsedAt = stored.lastUsedAt;
|
|
@@ -193,6 +261,7 @@ export class ApiTokenManager {
|
|
|
193
261
|
doc.name = stored.name;
|
|
194
262
|
doc.tokenHash = stored.tokenHash;
|
|
195
263
|
doc.scopes = stored.scopes;
|
|
264
|
+
doc.policy = stored.policy;
|
|
196
265
|
doc.createdAt = stored.createdAt;
|
|
197
266
|
doc.expiresAt = stored.expiresAt;
|
|
198
267
|
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