maven-proxy 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import { config } from "../src/config/config.js";
2
+ import {
3
+ getTrustStoreCommands,
4
+ initTrustStore,
5
+ mergeTrustStores,
6
+ } from "../src/cert/truststore-utils.js";
7
+
8
+ function printUsage() {
9
+ console.log("Usage:");
10
+ console.log(" node scripts/truststore.js print");
11
+ console.log(" node scripts/truststore.js init");
12
+ console.log(
13
+ " node scripts/truststore.js merge --source <path> --target <path> [--source-pass <pwd>] [--target-pass <pwd>] [--source-type <JKS|PKCS12>] [--target-type <JKS|PKCS12>] [--on-conflict <fail|overwrite>] [--dry-run]",
14
+ );
15
+ }
16
+
17
+ function parseCliOptions(args) {
18
+ const options = {};
19
+
20
+ for (let i = 0; i < args.length; i += 1) {
21
+ const token = args[i];
22
+ if (!token.startsWith("--")) {
23
+ continue;
24
+ }
25
+
26
+ const key = token.slice(2);
27
+
28
+ if (key === "dry-run") {
29
+ options[key] = true;
30
+ continue;
31
+ }
32
+
33
+ const value = args[i + 1];
34
+ if (!value || value.startsWith("--")) {
35
+ throw new Error(`Missing value for option: --${key}`);
36
+ }
37
+
38
+ options[key] = value;
39
+ i += 1;
40
+ }
41
+
42
+ return options;
43
+ }
44
+
45
+ const action = process.argv[2] || "print";
46
+
47
+ try {
48
+ if (action === "init") {
49
+ initTrustStore(config);
50
+ process.exit(0);
51
+ }
52
+
53
+ if (action === "merge") {
54
+ if (process.argv.slice(3).includes("--help")) {
55
+ printUsage();
56
+ process.exit(0);
57
+ }
58
+
59
+ const opts = parseCliOptions(process.argv.slice(3));
60
+ if (!opts.source || !opts.target) {
61
+ throw new Error("merge requires --source and --target");
62
+ }
63
+
64
+ const mergeResult = mergeTrustStores({
65
+ sourcePath: opts.source,
66
+ targetPath: opts.target,
67
+ sourcePassword: opts["source-pass"] || config.trustStorePassword,
68
+ targetPassword: opts["target-pass"] || config.trustStorePassword,
69
+ sourceType: (opts["source-type"] || "JKS").toUpperCase(),
70
+ targetType: (opts["target-type"] || "JKS").toUpperCase(),
71
+ onConflict: (opts["on-conflict"] || "fail").toLowerCase(),
72
+ dryRun: Boolean(opts["dry-run"]),
73
+ });
74
+
75
+ if (mergeResult?.dryRun) {
76
+ console.log("Dry run passed: merge validation completed, no changes were made.");
77
+ } else {
78
+ console.log("Trust stores merged successfully.");
79
+ }
80
+ process.exit(0);
81
+ }
82
+
83
+ if (action !== "print") {
84
+ printUsage();
85
+ throw new Error(`Unknown action: ${action}`);
86
+ }
87
+
88
+ const commands = getTrustStoreCommands(config);
89
+ console.log("Trust store commands:");
90
+ console.log(commands.copyCmd);
91
+ console.log(commands.importCmd);
92
+ console.log(commands.listCmd);
93
+ } catch (error) {
94
+ console.error(`[truststore] ${error.message}`);
95
+ process.exit(1);
96
+ }
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+
3
+ function sanitizeSegment(segment) {
4
+ return segment.replace(/[<>:\\"|?*]/g, "_");
5
+ }
6
+
7
+ function safeDecode(pathname) {
8
+ try {
9
+ return decodeURIComponent(pathname || "/");
10
+ } catch {
11
+ return pathname || "/";
12
+ }
13
+ }
14
+
15
+ export function getCacheFilePath(cacheDir, urlObj, options = {}) {
16
+ const ecosystem = sanitizeSegment(String(options.ecosystem || "generic").toLowerCase());
17
+ const includeHost = options.includeHost ?? ecosystem !== "maven";
18
+
19
+ const rawPathname = safeDecode(urlObj.pathname || "/");
20
+ const normalized = rawPathname.replace(/\\/g, "/");
21
+ const lowerNormalized = normalized.toLowerCase();
22
+ const parts = normalized.split("/").filter(Boolean);
23
+
24
+ if (parts.some((part) => part === "..")) {
25
+ throw new Error(`Invalid path traversal attempt: ${rawPathname}`);
26
+ }
27
+
28
+ if (parts.length === 0) {
29
+ parts.push("index");
30
+ }
31
+
32
+ const safeParts = parts.map((part) => sanitizeSegment(part));
33
+
34
+ if (includeHost) {
35
+ safeParts.unshift(sanitizeSegment(String(urlObj.hostname || "unknown").toLowerCase()));
36
+ }
37
+
38
+ const npmTarballPath = /\/-\/.+\.tgz$/i.test(lowerNormalized);
39
+ if (ecosystem === "npm" && !npmTarballPath) {
40
+ safeParts.push("__meta__.json");
41
+ }
42
+
43
+ if (urlObj.search && urlObj.search.length > 1) {
44
+ const lastIndex = safeParts.length - 1;
45
+ const encodedQuery = encodeURIComponent(urlObj.search.slice(1));
46
+ safeParts[lastIndex] = `${safeParts[lastIndex]}__q__${encodedQuery}`;
47
+ }
48
+
49
+ return path.join(cacheDir, ecosystem, ...safeParts);
50
+ }
@@ -0,0 +1,350 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import path from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { DownloadLogWriter } from "../common/download-log-writer.js";
7
+
8
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
9
+ const MAX_REDIRECTS = 5;
10
+
11
+ function pickClient(protocol) {
12
+ return protocol === "https:" ? https : http;
13
+ }
14
+
15
+ function stripHopByHopHeaders(headers = {}) {
16
+ const result = { ...headers };
17
+ const blocked = [
18
+ "connection",
19
+ "proxy-connection",
20
+ "keep-alive",
21
+ "transfer-encoding",
22
+ "upgrade",
23
+ "te",
24
+ "trailer",
25
+ "proxy-authenticate",
26
+ "proxy-authorization",
27
+ ];
28
+
29
+ for (const header of blocked) {
30
+ delete result[header];
31
+ delete result[header.toLowerCase()];
32
+ }
33
+
34
+ return result;
35
+ }
36
+
37
+ function requestRaw(urlObj, { method, headers, timeoutMs, getAgent }) {
38
+ const client = pickClient(urlObj.protocol);
39
+ const agent = getAgent ? getAgent(urlObj) : undefined;
40
+
41
+ return new Promise((resolve, reject) => {
42
+ const req = client.request(
43
+ {
44
+ protocol: urlObj.protocol,
45
+ hostname: urlObj.hostname,
46
+ port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
47
+ path: `${urlObj.pathname}${urlObj.search}`,
48
+ method,
49
+ headers,
50
+ agent,
51
+ },
52
+ (res) => resolve({ req, res }),
53
+ );
54
+
55
+ req.setTimeout(timeoutMs, () => {
56
+ req.destroy(new Error(`Request timeout after ${timeoutMs}ms: ${urlObj.href}`));
57
+ });
58
+
59
+ req.on("error", reject);
60
+ req.end();
61
+ });
62
+ }
63
+
64
+ async function requestWithRedirect(urlObj, options, redirectCount = 0) {
65
+ if (redirectCount > MAX_REDIRECTS) {
66
+ throw new Error(`Too many redirects while requesting ${urlObj.href}`);
67
+ }
68
+
69
+ const { res } = await requestRaw(urlObj, options);
70
+
71
+ if (REDIRECT_STATUS.has(res.statusCode) && res.headers.location) {
72
+ res.resume();
73
+ const nextUrl = new URL(res.headers.location, urlObj);
74
+ return requestWithRedirect(nextUrl, options, redirectCount + 1);
75
+ }
76
+
77
+ return { urlObj, res };
78
+ }
79
+
80
+ async function probe(urlObj, headers, timeoutMs, getAgent) {
81
+ try {
82
+ const { urlObj: finalUrl, res } = await requestWithRedirect(urlObj, {
83
+ method: "HEAD",
84
+ headers,
85
+ timeoutMs,
86
+ getAgent,
87
+ });
88
+
89
+ const contentLength = Number.parseInt(res.headers["content-length"], 10);
90
+ const acceptRanges = String(res.headers["accept-ranges"] || "").toLowerCase().includes("bytes");
91
+ const statusCode = res.statusCode || 0;
92
+ res.resume();
93
+
94
+ if (statusCode >= 400) {
95
+ return { finalUrl, contentLength: null, acceptRanges: false };
96
+ }
97
+
98
+ return {
99
+ finalUrl,
100
+ contentLength: Number.isFinite(contentLength) ? contentLength : null,
101
+ acceptRanges,
102
+ };
103
+ } catch {
104
+ return { finalUrl: urlObj, contentLength: null, acceptRanges: false };
105
+ }
106
+ }
107
+
108
+ async function downloadSingle(urlObj, tempPath, headers, timeoutMs, getAgent) {
109
+ const requestHeaders = {
110
+ ...stripHopByHopHeaders(headers),
111
+ "accept-encoding": "identity",
112
+ };
113
+
114
+ const { urlObj: finalUrl, res } = await requestWithRedirect(
115
+ urlObj,
116
+ {
117
+ method: "GET",
118
+ headers: requestHeaders,
119
+ timeoutMs,
120
+ getAgent,
121
+ },
122
+ 0,
123
+ );
124
+
125
+ const statusCode = res.statusCode || 0;
126
+ if (statusCode >= 400) {
127
+ const chunks = [];
128
+ for await (const chunk of res) {
129
+ chunks.push(chunk);
130
+ if (Buffer.concat(chunks).length > 2048) {
131
+ break;
132
+ }
133
+ }
134
+ const body = Buffer.concat(chunks).toString("utf8");
135
+ throw Object.assign(new Error(`Upstream GET failed (${statusCode}) ${finalUrl.href}`), {
136
+ statusCode,
137
+ upstreamBody: body,
138
+ });
139
+ }
140
+
141
+ if (statusCode < 200 || statusCode >= 300) {
142
+ throw new Error(`Unexpected status code ${statusCode} for ${finalUrl.href}`);
143
+ }
144
+
145
+ const contentLength = Number.parseInt(res.headers["content-length"], 10);
146
+
147
+ const stream = fs.createWriteStream(tempPath, { flags: "w" });
148
+ await pipeline(res, stream);
149
+
150
+ return {
151
+ finalUrl,
152
+ contentLength: Number.isFinite(contentLength) ? contentLength : null,
153
+ };
154
+ }
155
+
156
+ async function downloadRange(urlObj, tempPath, start, end, headers, timeoutMs, getAgent) {
157
+ const requestHeaders = {
158
+ ...stripHopByHopHeaders(headers),
159
+ "accept-encoding": "identity",
160
+ range: `bytes=${start}-${end}`,
161
+ };
162
+
163
+ const { res } = await requestRaw(urlObj, {
164
+ method: "GET",
165
+ headers: requestHeaders,
166
+ timeoutMs,
167
+ getAgent,
168
+ });
169
+
170
+ const statusCode = res.statusCode || 0;
171
+ if (statusCode !== 206) {
172
+ res.resume();
173
+ throw new Error(`Range request failed with status ${statusCode} (${start}-${end})`);
174
+ }
175
+
176
+ const writeStream = fs.createWriteStream(tempPath, {
177
+ flags: "r+",
178
+ start,
179
+ });
180
+
181
+ await pipeline(res, writeStream);
182
+ }
183
+
184
+ async function downloadMultiThread(urlObj, tempPath, headers, timeoutMs, contentLength, threadCount, getAgent) {
185
+ const handle = await fs.promises.open(tempPath, "w");
186
+ await handle.truncate(contentLength);
187
+ await handle.close();
188
+
189
+ const partSize = Math.ceil(contentLength / threadCount);
190
+ const tasks = [];
191
+
192
+ for (let index = 0; index < threadCount; index += 1) {
193
+ const start = index * partSize;
194
+ const end = Math.min(contentLength - 1, start + partSize - 1);
195
+
196
+ if (start > end) {
197
+ continue;
198
+ }
199
+
200
+ tasks.push(downloadRange(urlObj, tempPath, start, end, headers, timeoutMs, getAgent));
201
+ }
202
+
203
+ await Promise.all(tasks);
204
+ }
205
+
206
+ async function fileExists(filePath) {
207
+ try {
208
+ const stats = await fs.promises.stat(filePath);
209
+ return stats.isFile();
210
+ } catch (error) {
211
+ if (error.code === "ENOENT") {
212
+ return false;
213
+ }
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ async function verifyFileSize(filePath, expectedSize) {
219
+ if (!Number.isFinite(expectedSize)) {
220
+ return;
221
+ }
222
+
223
+ const stats = await fs.promises.stat(filePath);
224
+ if (stats.size !== expectedSize) {
225
+ throw new Error(`Integrity check failed: expected ${expectedSize} bytes, got ${stats.size}`);
226
+ }
227
+ }
228
+
229
+ async function removeIfExists(filePath) {
230
+ try {
231
+ await fs.promises.unlink(filePath);
232
+ } catch (error) {
233
+ if (error.code !== "ENOENT") {
234
+ throw error;
235
+ }
236
+ }
237
+ }
238
+
239
+ export class Downloader {
240
+ constructor(config, domainMatcher, upstreamProxyManager = null) {
241
+ this.config = config;
242
+ this.domainMatcher = domainMatcher;
243
+ this.upstreamProxyManager = upstreamProxyManager;
244
+ this.inflight = new Map();
245
+ this.downloadLogWriter = new DownloadLogWriter(config.downloadLogDir, config.logRetentionDays);
246
+ }
247
+
248
+ logDownload(event, urlObj, details = {}) {
249
+ const url = typeof urlObj === "string" ? urlObj : urlObj?.href;
250
+ const detailText = Object.entries(details)
251
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
252
+ .map(([key, value]) => `${key}=${value}`)
253
+ .join(" ");
254
+
255
+ console.log(`[downloader] ${event} url=${url}${detailText ? ` ${detailText}` : ""}`);
256
+ this.downloadLogWriter.write(event, url, details);
257
+ }
258
+
259
+ async ensureCached(urlObj, finalPath, requestHeaders = {}) {
260
+ if (await fileExists(finalPath)) {
261
+ return { cacheHit: true, finalPath };
262
+ }
263
+
264
+ const existing = this.inflight.get(finalPath);
265
+ if (existing) {
266
+ await existing;
267
+ return { cacheHit: true, finalPath };
268
+ }
269
+
270
+ const downloadPromise = this.#downloadAtomic(urlObj, finalPath, requestHeaders);
271
+ this.inflight.set(finalPath, downloadPromise);
272
+
273
+ try {
274
+ await downloadPromise;
275
+ } finally {
276
+ this.inflight.delete(finalPath);
277
+ }
278
+
279
+ return { cacheHit: false, finalPath };
280
+ }
281
+
282
+ async #downloadAtomic(urlObj, finalPath, requestHeaders) {
283
+ await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
284
+ const tempPath = `${finalPath}.temp`;
285
+ await removeIfExists(tempPath);
286
+
287
+ try {
288
+ const headers = stripHopByHopHeaders(requestHeaders);
289
+ const getAgent = this.upstreamProxyManager
290
+ ? (currentUrl) => this.upstreamProxyManager.getAgentForUrl(currentUrl)
291
+ : null;
292
+ const metadata = await probe(urlObj, headers, this.config.downloadTimeoutMs, getAgent);
293
+ const downloadUrl = metadata.finalUrl;
294
+ const hostname = downloadUrl.hostname;
295
+
296
+ this.logDownload("download start", downloadUrl, { host: hostname, targetPath: finalPath });
297
+
298
+ if (getAgent && this.upstreamProxyManager.hasProxyFor(downloadUrl.protocol, hostname)) {
299
+ this.logDownload("outbound via upstream proxy", downloadUrl, {
300
+ host: hostname,
301
+ protocol: downloadUrl.protocol,
302
+ });
303
+ }
304
+
305
+ const shouldUseMulti =
306
+ this.domainMatcher(hostname, this.config.multiThreadDomains) &&
307
+ Number.isFinite(metadata.contentLength) &&
308
+ metadata.contentLength >= this.config.multiThreadMinSizeBytes &&
309
+ metadata.acceptRanges;
310
+
311
+ if (shouldUseMulti) {
312
+ this.logDownload("multi-thread download enabled", downloadUrl, {
313
+ host: hostname,
314
+ size: metadata.contentLength,
315
+ threads: this.config.multiThreadCount,
316
+ });
317
+ try {
318
+ await downloadMultiThread(
319
+ downloadUrl,
320
+ tempPath,
321
+ headers,
322
+ this.config.downloadTimeoutMs,
323
+ metadata.contentLength,
324
+ this.config.multiThreadCount,
325
+ getAgent,
326
+ );
327
+ } catch (error) {
328
+ await removeIfExists(tempPath);
329
+ this.logDownload("multi-thread fallback to single-thread", downloadUrl, {
330
+ host: hostname,
331
+ reason: error.message,
332
+ });
333
+ await downloadSingle(downloadUrl, tempPath, headers, this.config.downloadTimeoutMs, getAgent);
334
+ }
335
+ } else {
336
+ this.logDownload("single-thread download", downloadUrl, { host: hostname });
337
+ const single = await downloadSingle(downloadUrl, tempPath, headers, this.config.downloadTimeoutMs, getAgent);
338
+ if (single.contentLength != null) {
339
+ metadata.contentLength = single.contentLength;
340
+ }
341
+ }
342
+
343
+ await verifyFileSize(tempPath, metadata.contentLength);
344
+ await fs.promises.rename(tempPath, finalPath);
345
+ } catch (error) {
346
+ await removeIfExists(tempPath);
347
+ throw error;
348
+ }
349
+ }
350
+ }
@@ -0,0 +1,194 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import forge from "node-forge";
4
+
5
+ const CERT_VALID_YEARS = 30;
6
+
7
+ function randomSerial() {
8
+ return forge.util.bytesToHex(forge.random.getBytesSync(9));
9
+ }
10
+
11
+ function sanitizeHost(hostname) {
12
+ return hostname.replace(/[^a-zA-Z0-9.-]/g, "_");
13
+ }
14
+
15
+ async function ensureDir(dirPath) {
16
+ await fs.promises.mkdir(dirPath, { recursive: true });
17
+ }
18
+
19
+ async function readIfExists(filePath) {
20
+ try {
21
+ return await fs.promises.readFile(filePath, "utf8");
22
+ } catch (error) {
23
+ if (error.code === "ENOENT") {
24
+ return null;
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ function createRootCertificate() {
31
+ const keys = forge.pki.rsa.generateKeyPair(2048);
32
+ const cert = forge.pki.createCertificate();
33
+
34
+ cert.publicKey = keys.publicKey;
35
+ cert.serialNumber = randomSerial();
36
+
37
+ const now = new Date();
38
+ const rootNotAfter = new Date(now);
39
+ rootNotAfter.setFullYear(rootNotAfter.getFullYear() + CERT_VALID_YEARS);
40
+ cert.validity.notBefore = new Date(now.getTime() - 24 * 60 * 60 * 1000);
41
+ cert.validity.notAfter = rootNotAfter;
42
+
43
+ const attrs = [
44
+ { name: "commonName", value: "Maven Proxy Root CA" },
45
+ { name: "organizationName", value: "maven-proxy" },
46
+ { shortName: "OU", value: "Development" },
47
+ ];
48
+
49
+ cert.setSubject(attrs);
50
+ cert.setIssuer(attrs);
51
+ cert.setExtensions([
52
+ { name: "basicConstraints", cA: true, critical: true },
53
+ {
54
+ name: "keyUsage",
55
+ keyCertSign: true,
56
+ cRLSign: true,
57
+ digitalSignature: true,
58
+ critical: true,
59
+ },
60
+ { name: "subjectKeyIdentifier" },
61
+ ]);
62
+
63
+ cert.sign(keys.privateKey, forge.md.sha256.create());
64
+
65
+ return {
66
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
67
+ certPem: forge.pki.certificateToPem(cert),
68
+ privateKey: keys.privateKey,
69
+ certificate: cert,
70
+ };
71
+ }
72
+
73
+ function createLeafCertificate(hostname, rootPrivateKey, rootCertificate) {
74
+ const keys = forge.pki.rsa.generateKeyPair(2048);
75
+ const cert = forge.pki.createCertificate();
76
+
77
+ cert.publicKey = keys.publicKey;
78
+ cert.serialNumber = randomSerial();
79
+
80
+ const now = new Date();
81
+ const leafNotAfter = new Date(now);
82
+ leafNotAfter.setFullYear(leafNotAfter.getFullYear() + CERT_VALID_YEARS);
83
+ cert.validity.notBefore = new Date(now.getTime() - 60 * 60 * 1000);
84
+ cert.validity.notAfter = leafNotAfter;
85
+
86
+ cert.setSubject([
87
+ { name: "commonName", value: hostname },
88
+ { name: "organizationName", value: "maven-proxy-leaf" },
89
+ ]);
90
+
91
+ cert.setIssuer(rootCertificate.subject.attributes);
92
+ cert.setExtensions([
93
+ { name: "basicConstraints", cA: false, critical: true },
94
+ {
95
+ name: "keyUsage",
96
+ digitalSignature: true,
97
+ keyEncipherment: true,
98
+ dataEncipherment: true,
99
+ critical: true,
100
+ },
101
+ { name: "extKeyUsage", serverAuth: true },
102
+ {
103
+ name: "subjectAltName",
104
+ altNames: [{ type: 2, value: hostname }],
105
+ },
106
+ { name: "subjectKeyIdentifier" },
107
+ ]);
108
+
109
+ cert.sign(rootPrivateKey, forge.md.sha256.create());
110
+
111
+ return {
112
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
113
+ certPem: forge.pki.certificateToPem(cert),
114
+ };
115
+ }
116
+
117
+ export class CertManager {
118
+ constructor(config) {
119
+ this.config = config;
120
+ this.rootPrivateKey = null;
121
+ this.rootCertificate = null;
122
+ this.leafCache = new Map();
123
+ this.leafPromiseCache = new Map();
124
+ }
125
+
126
+ async init() {
127
+ await ensureDir(path.dirname(this.config.rootCertPath));
128
+ await ensureDir(path.dirname(this.config.rootKeyPath));
129
+ await ensureDir(this.config.leafCertDir);
130
+
131
+ const certPem = await readIfExists(this.config.rootCertPath);
132
+ const keyPem = await readIfExists(this.config.rootKeyPath);
133
+
134
+ if (certPem && keyPem) {
135
+ this.rootCertificate = forge.pki.certificateFromPem(certPem);
136
+ this.rootPrivateKey = forge.pki.privateKeyFromPem(keyPem);
137
+ return;
138
+ }
139
+
140
+ const root = createRootCertificate();
141
+ this.rootCertificate = root.certificate;
142
+ this.rootPrivateKey = root.privateKey;
143
+
144
+ await fs.promises.writeFile(this.config.rootCertPath, root.certPem, "utf8");
145
+ await fs.promises.writeFile(this.config.rootKeyPath, root.keyPem, "utf8");
146
+ }
147
+
148
+ async getRootCertPem() {
149
+ return fs.promises.readFile(this.config.rootCertPath, "utf8");
150
+ }
151
+
152
+ async getOrCreateLeaf(hostname) {
153
+ const normalizedHost = String(hostname).toLowerCase();
154
+
155
+ if (this.leafCache.has(normalizedHost)) {
156
+ return this.leafCache.get(normalizedHost);
157
+ }
158
+
159
+ if (this.leafPromiseCache.has(normalizedHost)) {
160
+ return this.leafPromiseCache.get(normalizedHost);
161
+ }
162
+
163
+ const promise = this.#loadOrCreateLeaf(normalizedHost)
164
+ .then((leaf) => {
165
+ this.leafCache.set(normalizedHost, leaf);
166
+ return leaf;
167
+ })
168
+ .finally(() => {
169
+ this.leafPromiseCache.delete(normalizedHost);
170
+ });
171
+
172
+ this.leafPromiseCache.set(normalizedHost, promise);
173
+ return promise;
174
+ }
175
+
176
+ async #loadOrCreateLeaf(hostname) {
177
+ const safeHost = sanitizeHost(hostname);
178
+ const certPath = path.join(this.config.leafCertDir, `${safeHost}.crt`);
179
+ const keyPath = path.join(this.config.leafCertDir, `${safeHost}.key.pem`);
180
+
181
+ const certPem = await readIfExists(certPath);
182
+ const keyPem = await readIfExists(keyPath);
183
+
184
+ if (certPem && keyPem) {
185
+ return { certPem, keyPem };
186
+ }
187
+
188
+ const leaf = createLeafCertificate(hostname, this.rootPrivateKey, this.rootCertificate);
189
+ await fs.promises.writeFile(certPath, leaf.certPem, "utf8");
190
+ await fs.promises.writeFile(keyPath, leaf.keyPem, "utf8");
191
+
192
+ return leaf;
193
+ }
194
+ }