@serve.zone/dcrouter 32.6.3 → 32.7.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/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +14 -20
- package/dist_ts/classes.dcrouter.js +48 -85
- package/dist_ts/db/documents/classes.email-server-settings.doc.d.ts +3 -1
- package/dist_ts/db/documents/classes.email-server-settings.doc.js +9 -2
- package/dist_ts/email/classes.email-route-builder.d.ts +8 -0
- package/dist_ts/email/classes.email-route-builder.js +20 -13
- package/dist_ts/email/classes.email-settings.manager.d.ts +5 -1
- package/dist_ts/email/classes.email-settings.manager.js +34 -1
- package/dist_ts/email/classes.email-tls-manager.d.ts +99 -0
- package/dist_ts/email/classes.email-tls-manager.js +321 -0
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/handlers/email-settings.handler.js +5 -1
- package/dist_ts_interfaces/data/email-settings.d.ts +43 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +8 -8
- package/readme.md +7 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +50 -93
- package/ts/db/documents/classes.email-server-settings.doc.ts +5 -1
- package/ts/email/classes.email-route-builder.ts +22 -13
- package/ts/email/classes.email-settings.manager.ts +42 -0
- package/ts/email/classes.email-tls-manager.ts +398 -0
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/handlers/email-settings.handler.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import type { UnifiedEmailServer } from '@push.rocks/smartmta';
|
|
2
|
+
import * as plugins from '../plugins.js';
|
|
3
|
+
import { logger } from '../logger.js';
|
|
4
|
+
import { ProxyCertDoc } from '../db/index.js';
|
|
5
|
+
import type { IDcRouterEmailTlsConfig } from '../classes.dcrouter.js';
|
|
6
|
+
import type {
|
|
7
|
+
IEmailTlsCertificateStatus,
|
|
8
|
+
TEmailTlsSource,
|
|
9
|
+
} from '../../ts_interfaces/data/email-settings.js';
|
|
10
|
+
|
|
11
|
+
/** PEM material for the SMTP listener and where it was read from. */
|
|
12
|
+
export interface IEmailTlsMaterial {
|
|
13
|
+
certPem: string;
|
|
14
|
+
keyPem: string;
|
|
15
|
+
origin: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The part of the SMTP listener that takes replacement TLS material. */
|
|
19
|
+
export type TEmailTlsListener = Pick<UnifiedEmailServer, 'updateOptions'>;
|
|
20
|
+
|
|
21
|
+
export interface IEmailTlsRequest {
|
|
22
|
+
source: TEmailTlsSource;
|
|
23
|
+
tls: IDcRouterEmailTlsConfig | undefined;
|
|
24
|
+
/** Mail hostname plus every enabled mail-edge hostname. */
|
|
25
|
+
requiredHostnames: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A stored certificate that covers at least one required hostname. */
|
|
29
|
+
export interface IStoredEmailCertificate {
|
|
30
|
+
domain: string;
|
|
31
|
+
certPem: string;
|
|
32
|
+
keyPem: string;
|
|
33
|
+
certificate: plugins.crypto.X509Certificate;
|
|
34
|
+
names: string[];
|
|
35
|
+
uncoveredHostnames: string[];
|
|
36
|
+
currentlyValid: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface IServedEmailTls {
|
|
40
|
+
source: TEmailTlsSource;
|
|
41
|
+
origin: string | null;
|
|
42
|
+
/** ProxyCertDoc key of the served stored certificate. */
|
|
43
|
+
storedDomain: string | null;
|
|
44
|
+
certificate: plugins.crypto.X509Certificate | null;
|
|
45
|
+
names: string[];
|
|
46
|
+
/** Problems found when the material was read (parse failure, key mismatch, nothing found). */
|
|
47
|
+
materialProblems: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether `emailConfig.tls` carries operator-managed certificate material. */
|
|
51
|
+
export const hasConfiguredEmailTlsMaterial = (tlsArg: IDcRouterEmailTlsConfig | undefined): boolean =>
|
|
52
|
+
Boolean(tlsArg?.certPem || tlsArg?.keyPem || tlsArg?.certPath || tlsArg?.keyPath);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Source for settings persisted without one: the material they served before
|
|
56
|
+
* the source existed — configured material when present, otherwise the
|
|
57
|
+
* managed certificate.
|
|
58
|
+
*/
|
|
59
|
+
export const deriveEmailTlsSource = (tlsArg: IDcRouterEmailTlsConfig | undefined): TEmailTlsSource =>
|
|
60
|
+
hasConfiguredEmailTlsMaterial(tlsArg) ? 'configuredMaterial' : 'managedCertificate';
|
|
61
|
+
|
|
62
|
+
/** The `tls` block without certificate material; undefined when nothing else remains. */
|
|
63
|
+
export const withoutConfiguredEmailTlsMaterial = (
|
|
64
|
+
tlsArg: IDcRouterEmailTlsConfig | undefined,
|
|
65
|
+
): IDcRouterEmailTlsConfig | undefined => {
|
|
66
|
+
if (!tlsArg) return undefined;
|
|
67
|
+
const { certPem, keyPem, certPath, keyPath, ...remaining } = tlsArg;
|
|
68
|
+
return Object.keys(remaining).length > 0 ? remaining : undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The `tls` block handed to SmartMTA: configured options plus the resolved PEM material. */
|
|
72
|
+
export const composeEmailServerTls = (
|
|
73
|
+
tlsArg: IDcRouterEmailTlsConfig | undefined,
|
|
74
|
+
materialArg: IEmailTlsMaterial | undefined,
|
|
75
|
+
): IDcRouterEmailTlsConfig | undefined => {
|
|
76
|
+
const options = withoutConfiguredEmailTlsMaterial(tlsArg);
|
|
77
|
+
if (!materialArg) return options;
|
|
78
|
+
return { ...options, certPem: materialArg.certPem, keyPem: materialArg.keyPem };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const describeError = (errorArg: unknown): string =>
|
|
82
|
+
errorArg instanceof Error ? errorArg.message : String(errorArg);
|
|
83
|
+
|
|
84
|
+
const normalizeHostname = (hostnameArg: string): string =>
|
|
85
|
+
hostnameArg.trim().toLowerCase().replace(/\.$/, '');
|
|
86
|
+
|
|
87
|
+
/** DNS subject alternative names of a certificate, lowercased. */
|
|
88
|
+
export const getCertificateDnsNames = (certificateArg: plugins.crypto.X509Certificate): string[] =>
|
|
89
|
+
(certificateArg.subjectAltName || '')
|
|
90
|
+
.split(',')
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter((entry) => entry.startsWith('DNS:'))
|
|
93
|
+
.map((entry) => normalizeHostname(entry.slice('DNS:'.length).replace(/^"|"$/g, '')));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* RFC 6125 name matching: exact, or a `*.` wildcard standing for exactly one
|
|
97
|
+
* left-most label. Implemented here because X509Certificate.checkHost does not
|
|
98
|
+
* match wildcards on every runtime dcrouter ships on.
|
|
99
|
+
*/
|
|
100
|
+
export const certificateNameCovers = (nameArg: string, hostnameArg: string): boolean => {
|
|
101
|
+
const hostname = normalizeHostname(hostnameArg);
|
|
102
|
+
if (nameArg === hostname) return true;
|
|
103
|
+
if (!nameArg.startsWith('*.')) return false;
|
|
104
|
+
const firstDot = hostname.indexOf('.');
|
|
105
|
+
return firstDot > 0 && hostname.slice(firstDot + 1) === nameArg.slice(2);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const findUncoveredHostnames = (namesArg: string[], hostnamesArg: string[]): string[] =>
|
|
109
|
+
hostnamesArg.filter((hostname) => !namesArg.some((name) => certificateNameCovers(name, hostname)));
|
|
110
|
+
|
|
111
|
+
const parseCertificate = (certPemArg: string): plugins.crypto.X509Certificate => new plugins.crypto.X509Certificate(certPemArg);
|
|
112
|
+
|
|
113
|
+
const isCurrentlyValid = (certificateArg: plugins.crypto.X509Certificate, nowArg: number): boolean =>
|
|
114
|
+
certificateArg.validFromDate.getTime() <= nowArg && nowArg < certificateArg.validToDate.getTime();
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Owns the SMTP listener's certificate: resolves it from the selected source
|
|
118
|
+
* on every listener start and relevant renewal, and reports what is served.
|
|
119
|
+
*
|
|
120
|
+
* Resolved material is held here and never written back into the email
|
|
121
|
+
* configuration, so every resolution reads the source of truth again.
|
|
122
|
+
*/
|
|
123
|
+
export class EmailTlsManager {
|
|
124
|
+
private served: IServedEmailTls | undefined;
|
|
125
|
+
|
|
126
|
+
/** Resolve the listener material from the selected source and record it as served. */
|
|
127
|
+
public async resolve(requestArg: IEmailTlsRequest): Promise<IEmailTlsMaterial | undefined> {
|
|
128
|
+
const material = requestArg.source === 'configuredMaterial'
|
|
129
|
+
? await this.resolveConfiguredMaterial(requestArg.tls)
|
|
130
|
+
: await this.resolveManagedCertificate(requestArg.requiredHostnames);
|
|
131
|
+
|
|
132
|
+
const status = this.getStatus(requestArg.requiredHostnames);
|
|
133
|
+
if (status && status.problems.length > 0) {
|
|
134
|
+
logger.log(
|
|
135
|
+
'error',
|
|
136
|
+
`SMTP TLS certificate problem (source=${status.source}, origin=${status.origin ?? 'none'}): ${status.problems.join('; ')}`,
|
|
137
|
+
);
|
|
138
|
+
} else if (status) {
|
|
139
|
+
logger.log(
|
|
140
|
+
'info',
|
|
141
|
+
`SMTP TLS certificate: source=${status.source}, origin=${status.origin}, notAfter=${status.notAfter === null ? 'unknown' : new Date(status.notAfter).toISOString()}, names=[${status.names.join(', ')}]`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return material;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Push an issued or renewed certificate to the running listener when it can
|
|
149
|
+
* change what the listener should serve. Returns the applied material.
|
|
150
|
+
*/
|
|
151
|
+
public async applyIssuance(
|
|
152
|
+
domainArg: string,
|
|
153
|
+
listenerArg: TEmailTlsListener,
|
|
154
|
+
requestArg: IEmailTlsRequest,
|
|
155
|
+
): Promise<IEmailTlsMaterial | undefined> {
|
|
156
|
+
if (!(await this.isRelevantIssuance(domainArg, requestArg.requiredHostnames))) return undefined;
|
|
157
|
+
const material = await this.resolve(requestArg);
|
|
158
|
+
if (!material) return undefined;
|
|
159
|
+
listenerArg.updateOptions({ tls: composeEmailServerTls(requestArg.tls, material) });
|
|
160
|
+
return material;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Forget the served certificate (the listener is stopped). */
|
|
164
|
+
public clear(): void {
|
|
165
|
+
this.served = undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether an issued or renewed certificate can change what the listener
|
|
170
|
+
* should serve. Only the managed source follows issuance; the stored
|
|
171
|
+
* certificate is keyed by the requested route domain, so a wildcard or
|
|
172
|
+
* sibling issuance is matched by the names it covers, not by its key.
|
|
173
|
+
*/
|
|
174
|
+
public async isRelevantIssuance(domainArg: string, requiredHostnamesArg: string[]): Promise<boolean> {
|
|
175
|
+
const served = this.served;
|
|
176
|
+
if (!served || served.source !== 'managedCertificate') return false;
|
|
177
|
+
const domain = normalizeHostname(domainArg);
|
|
178
|
+
if (served.storedDomain !== null && normalizeHostname(served.storedDomain) === domain) return true;
|
|
179
|
+
if (requiredHostnamesArg.some((hostname) => normalizeHostname(hostname) === domain)) return true;
|
|
180
|
+
const stored = await ProxyCertDoc.findByDomain(domainArg);
|
|
181
|
+
if (!stored?.publicKey) return false;
|
|
182
|
+
try {
|
|
183
|
+
const names = getCertificateDnsNames(parseCertificate(stored.publicKey));
|
|
184
|
+
return findUncoveredHostnames(names, requiredHostnamesArg).length < requiredHostnamesArg.length;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The best stored certificate for the required hostnames: currently valid
|
|
192
|
+
* and covering every name first, then valid, then the most names covered,
|
|
193
|
+
* then the latest expiry. Undefined when none covers any required hostname.
|
|
194
|
+
*/
|
|
195
|
+
public async selectStoredCertificate(
|
|
196
|
+
requiredHostnamesArg: string[],
|
|
197
|
+
nowArg = Date.now(),
|
|
198
|
+
): Promise<IStoredEmailCertificate | undefined> {
|
|
199
|
+
const candidates: IStoredEmailCertificate[] = [];
|
|
200
|
+
for (const doc of await ProxyCertDoc.findAll()) {
|
|
201
|
+
if (!doc.publicKey || !doc.privateKey) continue;
|
|
202
|
+
let certificate: plugins.crypto.X509Certificate;
|
|
203
|
+
try {
|
|
204
|
+
certificate = parseCertificate(doc.publicKey);
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const names = getCertificateDnsNames(certificate);
|
|
209
|
+
const uncoveredHostnames = findUncoveredHostnames(names, requiredHostnamesArg);
|
|
210
|
+
if (uncoveredHostnames.length === requiredHostnamesArg.length) continue;
|
|
211
|
+
candidates.push({
|
|
212
|
+
domain: doc.domain,
|
|
213
|
+
certPem: doc.publicKey,
|
|
214
|
+
keyPem: doc.privateKey,
|
|
215
|
+
certificate,
|
|
216
|
+
names,
|
|
217
|
+
uncoveredHostnames,
|
|
218
|
+
currentlyValid: isCurrentlyValid(certificate, nowArg),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const rank = (candidateArg: IStoredEmailCertificate): number[] => [
|
|
222
|
+
candidateArg.currentlyValid && candidateArg.uncoveredHostnames.length === 0 ? 1 : 0,
|
|
223
|
+
candidateArg.currentlyValid ? 1 : 0,
|
|
224
|
+
-candidateArg.uncoveredHostnames.length,
|
|
225
|
+
candidateArg.certificate.validToDate.getTime(),
|
|
226
|
+
];
|
|
227
|
+
candidates.sort((left, right) => {
|
|
228
|
+
const leftRank = rank(left);
|
|
229
|
+
const rightRank = rank(right);
|
|
230
|
+
for (let index = 0; index < leftRank.length; index++) {
|
|
231
|
+
if (leftRank[index] !== rightRank[index]) return rightRank[index] - leftRank[index];
|
|
232
|
+
}
|
|
233
|
+
return left.domain.localeCompare(right.domain);
|
|
234
|
+
});
|
|
235
|
+
return candidates[0];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Gate for switching to the managed source: refuses unless a currently
|
|
240
|
+
* valid stored certificate covers every required hostname.
|
|
241
|
+
*/
|
|
242
|
+
public async assertManagedCertificateAvailable(requiredHostnamesArg: string[]): Promise<IStoredEmailCertificate> {
|
|
243
|
+
const stored = await this.selectStoredCertificate(requiredHostnamesArg);
|
|
244
|
+
if (stored?.currentlyValid && stored.uncoveredHostnames.length === 0) return stored;
|
|
245
|
+
const closest = !stored
|
|
246
|
+
? ''
|
|
247
|
+
: stored.currentlyValid
|
|
248
|
+
? ` The closest stored certificate '${stored.domain}' does not cover ${stored.uncoveredHostnames.join(', ')}.`
|
|
249
|
+
: ` The closest stored certificate '${stored.domain}' is outside its validity period (notAfter ${stored.certificate.validToDate.toISOString()}).`;
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Refusing the managedCertificate email TLS source: no currently valid stored certificate covers ${requiredHostnamesArg.join(', ')}.${closest}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The served certificate checked against the required hostnames now, so
|
|
257
|
+
* expiry while running shows up without a new resolution.
|
|
258
|
+
*/
|
|
259
|
+
public getStatus(requiredHostnamesArg: string[], nowArg = Date.now()): IEmailTlsCertificateStatus | undefined {
|
|
260
|
+
const served = this.served;
|
|
261
|
+
if (!served) return undefined;
|
|
262
|
+
const problems = [...served.materialProblems];
|
|
263
|
+
const certificate = served.certificate;
|
|
264
|
+
if (certificate) {
|
|
265
|
+
const notBefore = certificate.validFromDate.getTime();
|
|
266
|
+
const notAfter = certificate.validToDate.getTime();
|
|
267
|
+
if (nowArg >= notAfter) {
|
|
268
|
+
problems.push(`certificate expired at ${new Date(notAfter).toISOString()}`);
|
|
269
|
+
} else if (nowArg < notBefore) {
|
|
270
|
+
problems.push(`certificate is not valid before ${new Date(notBefore).toISOString()}`);
|
|
271
|
+
}
|
|
272
|
+
const uncovered = findUncoveredHostnames(served.names, requiredHostnamesArg);
|
|
273
|
+
if (uncovered.length > 0) {
|
|
274
|
+
problems.push(`certificate does not cover ${uncovered.join(', ')}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
source: served.source,
|
|
279
|
+
origin: served.origin,
|
|
280
|
+
names: [...served.names],
|
|
281
|
+
notBefore: certificate ? certificate.validFromDate.getTime() : null,
|
|
282
|
+
notAfter: certificate ? certificate.validToDate.getTime() : null,
|
|
283
|
+
requiredHostnames: [...requiredHostnamesArg],
|
|
284
|
+
problems,
|
|
285
|
+
checkedAt: nowArg,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Operator-managed material. Paths that cannot be read FAIL listener
|
|
291
|
+
* startup: continuing without TLS is what once produced a cleartext
|
|
292
|
+
* submission port, so a broken explicit configuration must be impossible
|
|
293
|
+
* to miss.
|
|
294
|
+
*/
|
|
295
|
+
private async resolveConfiguredMaterial(
|
|
296
|
+
tlsArg: IDcRouterEmailTlsConfig | undefined,
|
|
297
|
+
): Promise<IEmailTlsMaterial | undefined> {
|
|
298
|
+
let material: IEmailTlsMaterial | undefined;
|
|
299
|
+
if (tlsArg?.certPem && tlsArg.keyPem) {
|
|
300
|
+
material = { certPem: tlsArg.certPem, keyPem: tlsArg.keyPem, origin: 'inline PEM' };
|
|
301
|
+
} else if (tlsArg?.certPath || tlsArg?.keyPath) {
|
|
302
|
+
if (!tlsArg.certPath || !tlsArg.keyPath) {
|
|
303
|
+
throw new Error('emailConfig.tls requires both certPath and keyPath when either is configured');
|
|
304
|
+
}
|
|
305
|
+
let certPem: string;
|
|
306
|
+
let keyPem: string;
|
|
307
|
+
try {
|
|
308
|
+
certPem = await plugins.fs.promises.readFile(tlsArg.certPath, 'utf8');
|
|
309
|
+
keyPem = await plugins.fs.promises.readFile(tlsArg.keyPath, 'utf8');
|
|
310
|
+
} catch (error: unknown) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Unable to read the configured SMTP TLS material (certPath=${tlsArg.certPath}, keyPath=${tlsArg.keyPath}): ${describeError(error)}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (!certPem.trim() || !keyPem.trim()) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`The configured SMTP TLS material is empty (certPath=${tlsArg.certPath}, keyPath=${tlsArg.keyPath})`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
material = { certPem, keyPem, origin: `configured paths (${tlsArg.certPath})` };
|
|
321
|
+
} else if (tlsArg?.certPem || tlsArg?.keyPem) {
|
|
322
|
+
throw new Error('emailConfig.tls requires both certPem and keyPem when either is configured');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!material) {
|
|
326
|
+
this.served = {
|
|
327
|
+
source: 'configuredMaterial',
|
|
328
|
+
origin: null,
|
|
329
|
+
storedDomain: null,
|
|
330
|
+
certificate: null,
|
|
331
|
+
names: [],
|
|
332
|
+
materialProblems: ['the configuredMaterial source is selected but emailConfig.tls carries no certificate material'],
|
|
333
|
+
};
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const materialProblems: string[] = [];
|
|
338
|
+
let certificate: plugins.crypto.X509Certificate | null = null;
|
|
339
|
+
try {
|
|
340
|
+
certificate = parseCertificate(material.certPem);
|
|
341
|
+
} catch (error: unknown) {
|
|
342
|
+
materialProblems.push(`certificate cannot be parsed: ${describeError(error)}`);
|
|
343
|
+
}
|
|
344
|
+
if (certificate) {
|
|
345
|
+
materialProblems.push(...this.checkKeyPair(certificate, material.keyPem));
|
|
346
|
+
}
|
|
347
|
+
this.served = {
|
|
348
|
+
source: 'configuredMaterial',
|
|
349
|
+
origin: material.origin,
|
|
350
|
+
storedDomain: null,
|
|
351
|
+
certificate,
|
|
352
|
+
names: certificate ? getCertificateDnsNames(certificate) : [],
|
|
353
|
+
materialProblems,
|
|
354
|
+
};
|
|
355
|
+
return material;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async resolveManagedCertificate(requiredHostnamesArg: string[]): Promise<IEmailTlsMaterial | undefined> {
|
|
359
|
+
let stored: IStoredEmailCertificate | undefined;
|
|
360
|
+
let lookupProblem: string | undefined;
|
|
361
|
+
try {
|
|
362
|
+
stored = await this.selectStoredCertificate(requiredHostnamesArg);
|
|
363
|
+
} catch (error: unknown) {
|
|
364
|
+
lookupProblem = `stored certificates cannot be read: ${describeError(error)}`;
|
|
365
|
+
}
|
|
366
|
+
if (!stored) {
|
|
367
|
+
this.served = {
|
|
368
|
+
source: 'managedCertificate',
|
|
369
|
+
origin: null,
|
|
370
|
+
storedDomain: null,
|
|
371
|
+
certificate: null,
|
|
372
|
+
names: [],
|
|
373
|
+
materialProblems: [lookupProblem ?? `no stored certificate covers ${requiredHostnamesArg.join(', ')}`],
|
|
374
|
+
};
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
const origin = `stored certificate '${stored.domain}'`;
|
|
378
|
+
this.served = {
|
|
379
|
+
source: 'managedCertificate',
|
|
380
|
+
origin,
|
|
381
|
+
storedDomain: stored.domain,
|
|
382
|
+
certificate: stored.certificate,
|
|
383
|
+
names: stored.names,
|
|
384
|
+
materialProblems: this.checkKeyPair(stored.certificate, stored.keyPem),
|
|
385
|
+
};
|
|
386
|
+
return { certPem: stored.certPem, keyPem: stored.keyPem, origin };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private checkKeyPair(certificateArg: plugins.crypto.X509Certificate, keyPemArg: string): string[] {
|
|
390
|
+
try {
|
|
391
|
+
return certificateArg.checkPrivateKey(plugins.crypto.createPrivateKey(keyPemArg))
|
|
392
|
+
? []
|
|
393
|
+
: ['private key does not match the certificate'];
|
|
394
|
+
} catch (error: unknown) {
|
|
395
|
+
return [`private key cannot be parsed: ${describeError(error)}`];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
package/ts/email/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './reconcile-mail-dns-singleton.js';
|
|
|
8
8
|
export * from './classes.mail-egress-coordinator.js';
|
|
9
9
|
export * from './classes.mail-edge-eligibility.js';
|
|
10
10
|
export * from './classes.email-settings.manager.js';
|
|
11
|
+
export * from './classes.email-tls-manager.js';
|
|
11
12
|
export * from './classes.gateway-mail-domain.store.js';
|
|
12
13
|
export * from './classes.coremail-gateway.manager.js';
|
|
13
14
|
export * from './classes.coremail-gateway.store.js';
|
|
@@ -58,10 +58,12 @@ export class EmailSettingsHandler {
|
|
|
58
58
|
const smtpListenerActive = dcRouter.emailServer
|
|
59
59
|
? dcRouter.emailServer.isSmtpListenerActive()
|
|
60
60
|
: undefined;
|
|
61
|
+
const tlsCertificate = dcRouter.getEmailTlsCertificateStatus();
|
|
61
62
|
const manager = dcRouter.emailSettingsManager;
|
|
62
63
|
if (manager) {
|
|
63
64
|
return {
|
|
64
65
|
...manager.getPublicSettings(),
|
|
66
|
+
...(tlsCertificate ? { tlsCertificate } : {}),
|
|
65
67
|
outboundEgress,
|
|
66
68
|
mailerBridgeState,
|
|
67
69
|
smtpAccountCount,
|
|
@@ -82,6 +84,8 @@ export class EmailSettingsHandler {
|
|
|
82
84
|
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
|
|
83
85
|
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
|
|
84
86
|
maxMessageSize: emailConfig?.maxMessageSize ?? null,
|
|
87
|
+
tlsSource: dcRouter.getEmailTlsSource(),
|
|
88
|
+
...(tlsCertificate ? { tlsCertificate } : {}),
|
|
85
89
|
domainCount: emailConfig?.domains?.length || 0,
|
|
86
90
|
routeCount: emailConfig?.routes?.length || 0,
|
|
87
91
|
authUserCount: emailConfig?.auth?.users?.length || 0,
|