memos-mcp 1.0.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/.vscode/settings.json +19 -0
- package/AGENTS.md +96 -0
- package/Jenkinsfile +80 -0
- package/README.md +164 -0
- package/RELEASE.md +25 -0
- package/eslint.config.js +34 -0
- package/memory.md +51 -0
- package/package.json +43 -0
- package/src/cert-manager.test.ts +322 -0
- package/src/cert-manager.ts +346 -0
- package/src/index.ts +345 -0
- package/src/memos-client.test.ts +312 -0
- package/src/memos-client.ts +253 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Certificate Manager
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import {
|
|
11
|
+
getAllIntermediateCerts,
|
|
12
|
+
getIntermediateCerts,
|
|
13
|
+
refreshAllCerts,
|
|
14
|
+
clearCache,
|
|
15
|
+
getCacheStatus,
|
|
16
|
+
} from "./cert-manager.ts";
|
|
17
|
+
|
|
18
|
+
// Test cache directory (same as in cert-manager.ts)
|
|
19
|
+
const CERT_CACHE_DIR = path.join(os.homedir(), ".cache", "memos-mcp", "certs");
|
|
20
|
+
const CACHE_FILE = path.join(CERT_CACHE_DIR, "cert-cache.json");
|
|
21
|
+
|
|
22
|
+
describe("CertManager", () => {
|
|
23
|
+
// Store original cache if exists
|
|
24
|
+
let originalCache: string | null = null;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Backup original cache if it exists (sync for beforeEach)
|
|
28
|
+
if (existsSync(CACHE_FILE)) {
|
|
29
|
+
originalCache = readFileSync(CACHE_FILE, "utf-8");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
// Restore original cache
|
|
35
|
+
if (originalCache) {
|
|
36
|
+
writeFileSync(CACHE_FILE, originalCache);
|
|
37
|
+
} else if (existsSync(CACHE_FILE)) {
|
|
38
|
+
// If there was no original cache, remove the test one
|
|
39
|
+
unlinkSync(CACHE_FILE);
|
|
40
|
+
}
|
|
41
|
+
originalCache = null;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("clearCache", () => {
|
|
45
|
+
it("should remove the cache file", async () => {
|
|
46
|
+
// Ensure cache exists first
|
|
47
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
48
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ version: 1, certs: {} }));
|
|
51
|
+
|
|
52
|
+
await clearCache();
|
|
53
|
+
|
|
54
|
+
assert.strictEqual(existsSync(CACHE_FILE), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not throw if cache file does not exist", async () => {
|
|
58
|
+
// Ensure cache doesn't exist
|
|
59
|
+
if (existsSync(CACHE_FILE)) {
|
|
60
|
+
unlinkSync(CACHE_FILE);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await assert.doesNotReject(async () => await clearCache());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("getCacheStatus", () => {
|
|
68
|
+
it("should return empty object when cache is empty", async () => {
|
|
69
|
+
await clearCache();
|
|
70
|
+
|
|
71
|
+
const status = await getCacheStatus();
|
|
72
|
+
|
|
73
|
+
assert.deepStrictEqual(status, {});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return status for cached certificates", async () => {
|
|
77
|
+
// Clear in-memory cache first so it reloads from file
|
|
78
|
+
await clearCache();
|
|
79
|
+
|
|
80
|
+
// Create a mock cache
|
|
81
|
+
const mockCache = {
|
|
82
|
+
version: 1,
|
|
83
|
+
certs: {
|
|
84
|
+
E8: {
|
|
85
|
+
pem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
86
|
+
fetchedAt: Date.now(),
|
|
87
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year from now
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
93
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
96
|
+
|
|
97
|
+
const status = await getCacheStatus();
|
|
98
|
+
|
|
99
|
+
assert.ok(status.E8);
|
|
100
|
+
assert.ok(status.E8.fetchedAt instanceof Date);
|
|
101
|
+
assert.ok(status.E8.expiresAt instanceof Date);
|
|
102
|
+
assert.strictEqual(typeof status.E8.needsRefresh, "boolean");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should indicate needsRefresh for old cache entries", async () => {
|
|
106
|
+
// Clear in-memory cache first so it reloads from file
|
|
107
|
+
await clearCache();
|
|
108
|
+
|
|
109
|
+
// Create an old cache entry (40 days ago)
|
|
110
|
+
const fortyDaysAgo = Date.now() - 40 * 24 * 60 * 60 * 1000;
|
|
111
|
+
const mockCache = {
|
|
112
|
+
version: 1,
|
|
113
|
+
certs: {
|
|
114
|
+
E8: {
|
|
115
|
+
pem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
116
|
+
fetchedAt: fortyDaysAgo,
|
|
117
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
123
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
126
|
+
|
|
127
|
+
const status = await getCacheStatus();
|
|
128
|
+
|
|
129
|
+
assert.strictEqual(status.E8.needsRefresh, true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should indicate needsRefresh for soon-to-expire certificates", async () => {
|
|
133
|
+
// Clear in-memory cache first so it reloads from file
|
|
134
|
+
await clearCache();
|
|
135
|
+
|
|
136
|
+
// Create a cache entry that expires in 20 days
|
|
137
|
+
const twentyDaysFromNow = Date.now() + 20 * 24 * 60 * 60 * 1000;
|
|
138
|
+
const mockCache = {
|
|
139
|
+
version: 1,
|
|
140
|
+
certs: {
|
|
141
|
+
E8: {
|
|
142
|
+
pem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
143
|
+
fetchedAt: Date.now(),
|
|
144
|
+
expiresAt: twentyDaysFromNow,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
150
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
153
|
+
|
|
154
|
+
const status = await getCacheStatus();
|
|
155
|
+
|
|
156
|
+
assert.strictEqual(status.E8.needsRefresh, true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("getIntermediateCerts", () => {
|
|
161
|
+
it("should return empty array for unknown certificate names", async () => {
|
|
162
|
+
const result = await getIntermediateCerts(["UNKNOWN_CERT"]);
|
|
163
|
+
|
|
164
|
+
assert.deepStrictEqual(result, []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should return cached certificate if available and fresh", async () => {
|
|
168
|
+
// Clear in-memory cache first so it reloads from file
|
|
169
|
+
await clearCache();
|
|
170
|
+
|
|
171
|
+
// Create a fresh cache
|
|
172
|
+
const mockCache = {
|
|
173
|
+
version: 1,
|
|
174
|
+
certs: {
|
|
175
|
+
E8: {
|
|
176
|
+
pem: "-----BEGIN CERTIFICATE-----\nMOCK_E8_CERT\n-----END CERTIFICATE-----",
|
|
177
|
+
fetchedAt: Date.now(),
|
|
178
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
184
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
187
|
+
|
|
188
|
+
const result = await getIntermediateCerts(["E8"]);
|
|
189
|
+
|
|
190
|
+
assert.ok(result.length === 1);
|
|
191
|
+
assert.ok(result[0].includes("MOCK_E8_CERT"));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should return multiple certificates when requested", async () => {
|
|
195
|
+
// Clear in-memory cache first so it reloads from file
|
|
196
|
+
await clearCache();
|
|
197
|
+
|
|
198
|
+
// Create cache with multiple certs
|
|
199
|
+
const mockCache = {
|
|
200
|
+
version: 1,
|
|
201
|
+
certs: {
|
|
202
|
+
E8: {
|
|
203
|
+
pem: "-----BEGIN CERTIFICATE-----\nMOCK_E8\n-----END CERTIFICATE-----",
|
|
204
|
+
fetchedAt: Date.now(),
|
|
205
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
206
|
+
},
|
|
207
|
+
R3: {
|
|
208
|
+
pem: "-----BEGIN CERTIFICATE-----\nMOCK_R3\n-----END CERTIFICATE-----",
|
|
209
|
+
fetchedAt: Date.now(),
|
|
210
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
216
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
219
|
+
|
|
220
|
+
const result = await getIntermediateCerts(["E8", "R3"]);
|
|
221
|
+
|
|
222
|
+
assert.strictEqual(result.length, 2);
|
|
223
|
+
assert.ok(result.some((c) => c.includes("MOCK_E8")));
|
|
224
|
+
assert.ok(result.some((c) => c.includes("MOCK_R3")));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("getAllIntermediateCerts (integration)", () => {
|
|
229
|
+
it("should fetch and cache certificates from Let's Encrypt", async () => {
|
|
230
|
+
// Clear cache first
|
|
231
|
+
await clearCache();
|
|
232
|
+
|
|
233
|
+
// This is an integration test - it actually fetches from the network
|
|
234
|
+
const certs = await getAllIntermediateCerts();
|
|
235
|
+
|
|
236
|
+
// Should have fetched at least some certificates
|
|
237
|
+
assert.ok(certs.length > 0, "Should have fetched certificates");
|
|
238
|
+
assert.ok(
|
|
239
|
+
certs.some((c) => c.includes("-----BEGIN CERTIFICATE-----")),
|
|
240
|
+
"Should contain PEM certificates",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Check cache was populated
|
|
244
|
+
const status = await getCacheStatus();
|
|
245
|
+
const cachedCerts = Object.keys(status);
|
|
246
|
+
assert.ok(cachedCerts.length > 0, "Should have cached certificates");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("refreshAllCerts (integration)", () => {
|
|
251
|
+
it("should refresh all certificates", async () => {
|
|
252
|
+
// Clear cache first
|
|
253
|
+
await clearCache();
|
|
254
|
+
|
|
255
|
+
// Refresh all certs
|
|
256
|
+
await refreshAllCerts();
|
|
257
|
+
|
|
258
|
+
// Check cache was populated
|
|
259
|
+
const status = await getCacheStatus();
|
|
260
|
+
const cachedCerts = Object.keys(status);
|
|
261
|
+
|
|
262
|
+
// Should have cached multiple certificates
|
|
263
|
+
assert.ok(cachedCerts.length >= 5, `Expected at least 5 certs, got ${cachedCerts.length}`);
|
|
264
|
+
|
|
265
|
+
// All should have been recently fetched
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
for (const [name, info] of Object.entries(status)) {
|
|
268
|
+
const fetchedAge = now - info.fetchedAt.getTime();
|
|
269
|
+
// Should have been fetched within the last minute
|
|
270
|
+
assert.ok(fetchedAge < 60000, `${name} should have been fetched recently, age: ${fetchedAge}ms`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("cache persistence", () => {
|
|
276
|
+
it("should persist cache across calls", async () => {
|
|
277
|
+
// Clear in-memory cache first so it reloads from file
|
|
278
|
+
await clearCache();
|
|
279
|
+
|
|
280
|
+
// Create initial cache
|
|
281
|
+
const mockCache = {
|
|
282
|
+
version: 1,
|
|
283
|
+
certs: {
|
|
284
|
+
TEST: {
|
|
285
|
+
pem: "-----BEGIN CERTIFICATE-----\nTEST_PERSIST\n-----END CERTIFICATE-----",
|
|
286
|
+
fetchedAt: Date.now(),
|
|
287
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
293
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mockCache));
|
|
296
|
+
|
|
297
|
+
// Read status - this loads the cache
|
|
298
|
+
const status1 = await getCacheStatus();
|
|
299
|
+
assert.ok(status1.TEST);
|
|
300
|
+
|
|
301
|
+
// Read again - should still have the same data
|
|
302
|
+
const status2 = await getCacheStatus();
|
|
303
|
+
assert.ok(status2.TEST);
|
|
304
|
+
assert.strictEqual(status1.TEST.fetchedAt.getTime(), status2.TEST.fetchedAt.getTime());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should handle corrupted cache gracefully", async () => {
|
|
308
|
+
// Clear in-memory cache first so it reloads from file
|
|
309
|
+
await clearCache();
|
|
310
|
+
|
|
311
|
+
// Write corrupted cache
|
|
312
|
+
if (!existsSync(CERT_CACHE_DIR)) {
|
|
313
|
+
mkdirSync(CERT_CACHE_DIR, { recursive: true });
|
|
314
|
+
}
|
|
315
|
+
writeFileSync(CACHE_FILE, "not valid json {{{");
|
|
316
|
+
|
|
317
|
+
// Should not throw, should return empty
|
|
318
|
+
const status = await getCacheStatus();
|
|
319
|
+
assert.deepStrictEqual(status, {});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Certificate Manager
|
|
3
|
+
* Automatically fetches, caches, and refreshes intermediate CA certificates
|
|
4
|
+
* for servers with incomplete certificate chains.
|
|
5
|
+
*
|
|
6
|
+
* Uses native Node.js APIs - no external dependencies like curl or openssl CLI.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { Agent } from "undici";
|
|
14
|
+
import tls from "node:tls";
|
|
15
|
+
|
|
16
|
+
// Official Let's Encrypt certificate URLs (HTTPS - secure)
|
|
17
|
+
// Source: https://letsencrypt.org/certificates/
|
|
18
|
+
const INTERMEDIATE_CERT_URLS: Record<string, string> = {
|
|
19
|
+
// Let's Encrypt ECDSA intermediates (2024)
|
|
20
|
+
E5: "https://letsencrypt.org/certs/2024/e5.pem",
|
|
21
|
+
E6: "https://letsencrypt.org/certs/2024/e6.pem",
|
|
22
|
+
E7: "https://letsencrypt.org/certs/2024/e7.pem",
|
|
23
|
+
E8: "https://letsencrypt.org/certs/2024/e8.pem",
|
|
24
|
+
E9: "https://letsencrypt.org/certs/2024/e9.pem",
|
|
25
|
+
// Let's Encrypt RSA intermediates (2024)
|
|
26
|
+
R10: "https://letsencrypt.org/certs/2024/r10.pem",
|
|
27
|
+
R11: "https://letsencrypt.org/certs/2024/r11.pem",
|
|
28
|
+
// Legacy intermediates
|
|
29
|
+
R3: "https://letsencrypt.org/certs/2024/r3.pem",
|
|
30
|
+
R4: "https://letsencrypt.org/certs/2024/r4.pem",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Cache directory for certificates
|
|
34
|
+
const CERT_CACHE_DIR = path.join(os.homedir(), ".cache", "memos-mcp", "certs");
|
|
35
|
+
|
|
36
|
+
// Refresh interval: 30 days in milliseconds
|
|
37
|
+
const REFRESH_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
interface CertCacheEntry {
|
|
40
|
+
pem: string;
|
|
41
|
+
fetchedAt: number;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CertCache {
|
|
46
|
+
version: number;
|
|
47
|
+
certs: Record<string, CertCacheEntry>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// In-memory cache
|
|
51
|
+
let memoryCache: CertCache | null = null;
|
|
52
|
+
|
|
53
|
+
// Cached https agent
|
|
54
|
+
let cachedAgent: Agent | null = null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure cache directory exists
|
|
58
|
+
*/
|
|
59
|
+
async function ensureCacheDir(): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
await fs.mkdir(CERT_CACHE_DIR, { recursive: true });
|
|
62
|
+
} catch {
|
|
63
|
+
// Directory may already exist
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get path to cache file
|
|
69
|
+
*/
|
|
70
|
+
function getCacheFilePath(): string {
|
|
71
|
+
return path.join(CERT_CACHE_DIR, "cert-cache.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load certificate cache from disk
|
|
76
|
+
*/
|
|
77
|
+
async function loadCache(): Promise<CertCache> {
|
|
78
|
+
if (memoryCache) {
|
|
79
|
+
return memoryCache;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const cacheFile = getCacheFilePath();
|
|
84
|
+
const data = await fs.readFile(cacheFile, "utf-8");
|
|
85
|
+
memoryCache = JSON.parse(data) as CertCache;
|
|
86
|
+
return memoryCache;
|
|
87
|
+
} catch {
|
|
88
|
+
// Cache corrupted or unreadable, start fresh
|
|
89
|
+
}
|
|
90
|
+
memoryCache = { version: 1, certs: {} };
|
|
91
|
+
return memoryCache;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Save certificate cache to disk
|
|
96
|
+
*/
|
|
97
|
+
async function saveCache(cache: CertCache): Promise<void> {
|
|
98
|
+
memoryCache = cache;
|
|
99
|
+
try {
|
|
100
|
+
await ensureCacheDir();
|
|
101
|
+
const cacheFile = getCacheFilePath();
|
|
102
|
+
await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2));
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore cache write errors - not critical
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert DER buffer to PEM string
|
|
110
|
+
*/
|
|
111
|
+
function derToPem(der: Buffer): string {
|
|
112
|
+
const base64 = der.toString("base64");
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
for (let i = 0; i < base64.length; i += 64) {
|
|
115
|
+
lines.push(base64.slice(i, i + 64));
|
|
116
|
+
}
|
|
117
|
+
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse certificate expiration date from PEM using Node.js crypto
|
|
122
|
+
*/
|
|
123
|
+
function getCertExpirationDate(pem: string): Date | null {
|
|
124
|
+
try {
|
|
125
|
+
const cert = new crypto.X509Certificate(pem);
|
|
126
|
+
return new Date(cert.validTo);
|
|
127
|
+
} catch {
|
|
128
|
+
// Ignore parsing errors
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Download certificate from URL (async) - returns DER buffer
|
|
135
|
+
*/
|
|
136
|
+
async function downloadCertAsync(url: string): Promise<Buffer | null> {
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetch(url, {
|
|
139
|
+
signal: AbortSignal.timeout(10000),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
147
|
+
return Buffer.from(arrayBuffer);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Download certificate and return as PEM (async)
|
|
155
|
+
* Handles both PEM (from HTTPS URLs) and DER (legacy AIA URLs) formats
|
|
156
|
+
*/
|
|
157
|
+
async function downloadCert(url: string): Promise<string | null> {
|
|
158
|
+
const data = await downloadCertAsync(url);
|
|
159
|
+
if (!data || data.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Check if it's already PEM format (starts with "-----BEGIN")
|
|
165
|
+
const dataStr = data.toString("utf8");
|
|
166
|
+
if (dataStr.startsWith("-----BEGIN")) {
|
|
167
|
+
// Verify it's a valid certificate by parsing it
|
|
168
|
+
new crypto.X509Certificate(dataStr);
|
|
169
|
+
return dataStr.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Otherwise assume DER format and convert to PEM
|
|
173
|
+
const pem = derToPem(data);
|
|
174
|
+
// Verify it's a valid certificate by parsing it
|
|
175
|
+
new crypto.X509Certificate(pem);
|
|
176
|
+
return pem;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a cached certificate needs refresh
|
|
184
|
+
*/
|
|
185
|
+
function needsRefresh(entry: CertCacheEntry): boolean {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
|
|
188
|
+
// Refresh if:
|
|
189
|
+
// 1. Cache is older than REFRESH_INTERVAL_MS
|
|
190
|
+
// 2. Certificate expires within 30 days
|
|
191
|
+
const cacheAge = now - entry.fetchedAt;
|
|
192
|
+
const timeUntilExpiry = entry.expiresAt - now;
|
|
193
|
+
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
|
194
|
+
|
|
195
|
+
return cacheAge > REFRESH_INTERVAL_MS || timeUntilExpiry < thirtyDays;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get a certificate by name, fetching/refreshing as needed (async)
|
|
200
|
+
*/
|
|
201
|
+
async function getCertificate(name: string): Promise<string | null> {
|
|
202
|
+
const url = INTERMEDIATE_CERT_URLS[name];
|
|
203
|
+
if (!url) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cache = await loadCache();
|
|
208
|
+
const cached = cache.certs[name];
|
|
209
|
+
|
|
210
|
+
// Return cached cert if valid and fresh
|
|
211
|
+
if (cached && !needsRefresh(cached)) {
|
|
212
|
+
return cached.pem;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Try to fetch fresh certificate
|
|
216
|
+
const pem = await downloadCert(url);
|
|
217
|
+
if (pem) {
|
|
218
|
+
const expirationDate = getCertExpirationDate(pem);
|
|
219
|
+
cache.certs[name] = {
|
|
220
|
+
pem,
|
|
221
|
+
fetchedAt: Date.now(),
|
|
222
|
+
expiresAt: expirationDate?.getTime() ?? Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
223
|
+
};
|
|
224
|
+
await saveCache(cache);
|
|
225
|
+
return pem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fall back to cached cert if fetch failed
|
|
229
|
+
if (cached) {
|
|
230
|
+
return cached.pem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get all intermediate certificates (fetches/refreshes as needed)
|
|
238
|
+
* Returns array of PEM strings
|
|
239
|
+
*/
|
|
240
|
+
export async function getAllIntermediateCerts(): Promise<string[]> {
|
|
241
|
+
const certs: string[] = [];
|
|
242
|
+
|
|
243
|
+
const results = await Promise.all(Object.keys(INTERMEDIATE_CERT_URLS).map((name) => getCertificate(name)));
|
|
244
|
+
|
|
245
|
+
for (const cert of results) {
|
|
246
|
+
if (cert) {
|
|
247
|
+
certs.push(cert);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return certs;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get specific intermediate certificates by name
|
|
256
|
+
*/
|
|
257
|
+
export async function getIntermediateCerts(names: string[]): Promise<string[]> {
|
|
258
|
+
const results = await Promise.all(names.map((name) => getCertificate(name)));
|
|
259
|
+
return results.filter((cert): cert is string => cert !== null);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Force refresh all certificates
|
|
264
|
+
*/
|
|
265
|
+
export async function refreshAllCerts(): Promise<void> {
|
|
266
|
+
// Clear memory cache to force fresh fetch
|
|
267
|
+
memoryCache = null;
|
|
268
|
+
cachedAgent = null;
|
|
269
|
+
|
|
270
|
+
const cache: CertCache = { version: 1, certs: {} };
|
|
271
|
+
|
|
272
|
+
const entries = Object.entries(INTERMEDIATE_CERT_URLS);
|
|
273
|
+
const results = await Promise.all(
|
|
274
|
+
entries.map(async ([name, url]) => {
|
|
275
|
+
const pem = await downloadCert(url);
|
|
276
|
+
return { name, pem };
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
for (const { name, pem } of results) {
|
|
281
|
+
if (pem) {
|
|
282
|
+
const expirationDate = getCertExpirationDate(pem);
|
|
283
|
+
cache.certs[name] = {
|
|
284
|
+
pem,
|
|
285
|
+
fetchedAt: Date.now(),
|
|
286
|
+
expiresAt: expirationDate?.getTime() ?? Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await saveCache(cache);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Clear the certificate cache
|
|
296
|
+
*/
|
|
297
|
+
export async function clearCache(): Promise<void> {
|
|
298
|
+
memoryCache = null;
|
|
299
|
+
cachedAgent = null;
|
|
300
|
+
try {
|
|
301
|
+
const cacheFile = getCacheFilePath();
|
|
302
|
+
await fs.unlink(cacheFile);
|
|
303
|
+
} catch {
|
|
304
|
+
// Ignore errors - file may not exist
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get cache status for debugging
|
|
310
|
+
*/
|
|
311
|
+
export async function getCacheStatus(): Promise<
|
|
312
|
+
Record<string, { fetchedAt: Date; expiresAt: Date; needsRefresh: boolean }>
|
|
313
|
+
> {
|
|
314
|
+
const cache = await loadCache();
|
|
315
|
+
const status: Record<string, { fetchedAt: Date; expiresAt: Date; needsRefresh: boolean }> = {};
|
|
316
|
+
|
|
317
|
+
for (const [name, entry] of Object.entries(cache.certs)) {
|
|
318
|
+
status[name] = {
|
|
319
|
+
fetchedAt: new Date(entry.fetchedAt),
|
|
320
|
+
expiresAt: new Date(entry.expiresAt),
|
|
321
|
+
needsRefresh: needsRefresh(entry),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return status;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get an HTTPS agent configured with intermediate certificates
|
|
330
|
+
* Creates agent lazily and caches it
|
|
331
|
+
*/
|
|
332
|
+
export async function getHttpsAgent(): Promise<Agent> {
|
|
333
|
+
if (cachedAgent) {
|
|
334
|
+
return cachedAgent;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const intermediateCerts = await getAllIntermediateCerts();
|
|
338
|
+
|
|
339
|
+
cachedAgent = new Agent({
|
|
340
|
+
connect: {
|
|
341
|
+
ca: [...tls.rootCertificates, ...intermediateCerts],
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return cachedAgent;
|
|
346
|
+
}
|