@friggframework/core 2.0.0-next.4 → 2.0.0-next.41
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/core/create-handler.js +2 -1
- package/database/models/WebsocketConnection.js +5 -0
- package/database/mongo.js +131 -5
- package/encrypt/encrypt.js +5 -33
- package/handlers/app-handler-helpers.js +59 -0
- package/handlers/backend-utils.js +85 -0
- package/handlers/routers/HEALTHCHECK.md +240 -0
- package/handlers/routers/auth.js +26 -0
- package/handlers/routers/health.js +844 -0
- package/handlers/routers/health.test.js +203 -0
- package/handlers/routers/integration-defined-routers.js +42 -0
- package/handlers/routers/middleware/loadUser.js +15 -0
- package/handlers/routers/middleware/requireLoggedInUser.js +12 -0
- package/handlers/routers/user.js +41 -0
- package/handlers/routers/websocket.js +55 -0
- package/handlers/workers/integration-defined-workers.js +24 -0
- package/index.js +4 -0
- package/module-plugin/requester/requester.js +1 -0
- package/package.json +20 -12
- package/utils/backend-path.js +38 -0
- package/utils/index.js +6 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const mongoose = require('mongoose');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { moduleFactory, integrationFactory } = require('./../backend-utils');
|
|
6
|
+
const { createAppHandler } = require('./../app-handler-helpers');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
const validateApiKey = (req, res, next) => {
|
|
11
|
+
const apiKey = req.headers['x-api-key'];
|
|
12
|
+
|
|
13
|
+
if (req.path === '/health') {
|
|
14
|
+
return next();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!apiKey || apiKey !== process.env.HEALTH_API_KEY) {
|
|
18
|
+
console.error('Unauthorized access attempt to health endpoint');
|
|
19
|
+
return res.status(401).json({
|
|
20
|
+
status: 'error',
|
|
21
|
+
message: 'Unauthorized',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
next();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
router.use(validateApiKey);
|
|
29
|
+
|
|
30
|
+
const checkExternalAPI = (url, timeout = 5000) => {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const protocol = url.startsWith('https:') ? https : http;
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const request = protocol.get(url, { timeout }, (res) => {
|
|
37
|
+
const responseTime = Date.now() - startTime;
|
|
38
|
+
resolve({
|
|
39
|
+
status: 'healthy',
|
|
40
|
+
statusCode: res.statusCode,
|
|
41
|
+
responseTime,
|
|
42
|
+
reachable: res.statusCode < 500,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
request.on('error', (error) => {
|
|
47
|
+
resolve({
|
|
48
|
+
status: 'unhealthy',
|
|
49
|
+
error: error.message,
|
|
50
|
+
responseTime: Date.now() - startTime,
|
|
51
|
+
reachable: false,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
request.on('timeout', () => {
|
|
56
|
+
request.destroy();
|
|
57
|
+
resolve({
|
|
58
|
+
status: 'timeout',
|
|
59
|
+
error: 'Request timeout',
|
|
60
|
+
responseTime: timeout,
|
|
61
|
+
reachable: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
resolve({
|
|
66
|
+
status: 'error',
|
|
67
|
+
error: error.message,
|
|
68
|
+
responseTime: Date.now() - startTime,
|
|
69
|
+
reachable: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const getDatabaseState = () => {
|
|
76
|
+
const stateMap = {
|
|
77
|
+
0: 'disconnected',
|
|
78
|
+
1: 'connected',
|
|
79
|
+
2: 'connecting',
|
|
80
|
+
3: 'disconnecting',
|
|
81
|
+
};
|
|
82
|
+
const readyState = mongoose.connection.readyState;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
readyState,
|
|
86
|
+
stateName: stateMap[readyState],
|
|
87
|
+
isConnected: readyState === 1,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const checkDatabaseHealth = async () => {
|
|
92
|
+
const { stateName, isConnected } = getDatabaseState();
|
|
93
|
+
const result = {
|
|
94
|
+
status: isConnected ? 'healthy' : 'unhealthy',
|
|
95
|
+
state: stateName,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (isConnected) {
|
|
99
|
+
const pingStart = Date.now();
|
|
100
|
+
await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
|
|
101
|
+
result.responseTime = Date.now() - pingStart;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const getEncryptionConfiguration = () => {
|
|
108
|
+
const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } =
|
|
109
|
+
process.env;
|
|
110
|
+
|
|
111
|
+
const defaultBypassStages = ['dev', 'test', 'local'];
|
|
112
|
+
const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined;
|
|
113
|
+
const bypassStages = useEnv
|
|
114
|
+
? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim())
|
|
115
|
+
: defaultBypassStages;
|
|
116
|
+
|
|
117
|
+
const isBypassed = bypassStages.includes(STAGE);
|
|
118
|
+
const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
|
|
119
|
+
const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
|
|
120
|
+
const mode = hasAES ? 'aes' : hasKMS ? 'kms' : 'none';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
stage: STAGE || null,
|
|
124
|
+
isBypassed,
|
|
125
|
+
hasAES,
|
|
126
|
+
hasKMS,
|
|
127
|
+
mode,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const createTestEncryptionModel = () => {
|
|
132
|
+
const { Encrypt } = require('./../../encrypt');
|
|
133
|
+
|
|
134
|
+
const testSchema = new mongoose.Schema(
|
|
135
|
+
{
|
|
136
|
+
testSecret: { type: String, lhEncrypt: true },
|
|
137
|
+
normalField: { type: String },
|
|
138
|
+
nestedSecret: {
|
|
139
|
+
value: { type: String, lhEncrypt: true },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{ timestamps: false }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
testSchema.plugin(Encrypt);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
mongoose.models.TestEncryption ||
|
|
149
|
+
mongoose.model('TestEncryption', testSchema)
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const verifyDecryption = (retrievedDoc, originalData) => {
|
|
154
|
+
return (
|
|
155
|
+
retrievedDoc &&
|
|
156
|
+
retrievedDoc.testSecret === originalData.testSecret &&
|
|
157
|
+
retrievedDoc.normalField === originalData.normalField &&
|
|
158
|
+
retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const verifyEncryptionInDatabase = async (testDoc, originalData, TestModel) => {
|
|
163
|
+
const collectionName = TestModel.collection.name;
|
|
164
|
+
const rawDoc = await mongoose.connection.db
|
|
165
|
+
.collection(collectionName)
|
|
166
|
+
.findOne({ _id: testDoc._id });
|
|
167
|
+
|
|
168
|
+
const secretIsEncrypted =
|
|
169
|
+
rawDoc &&
|
|
170
|
+
typeof rawDoc.testSecret === 'string' &&
|
|
171
|
+
rawDoc.testSecret.includes(':') &&
|
|
172
|
+
rawDoc.testSecret !== originalData.testSecret;
|
|
173
|
+
|
|
174
|
+
const nestedIsEncrypted =
|
|
175
|
+
rawDoc?.nestedSecret?.value &&
|
|
176
|
+
typeof rawDoc.nestedSecret.value === 'string' &&
|
|
177
|
+
rawDoc.nestedSecret.value.includes(':') &&
|
|
178
|
+
rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
|
|
179
|
+
|
|
180
|
+
const normalNotEncrypted =
|
|
181
|
+
rawDoc && rawDoc.normalField === originalData.normalField;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
secretIsEncrypted,
|
|
185
|
+
nestedIsEncrypted,
|
|
186
|
+
normalNotEncrypted,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const evaluateEncryptionTestResults = (decryptionWorks, encryptionResults) => {
|
|
191
|
+
const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } =
|
|
192
|
+
encryptionResults;
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
decryptionWorks &&
|
|
196
|
+
secretIsEncrypted &&
|
|
197
|
+
nestedIsEncrypted &&
|
|
198
|
+
normalNotEncrypted
|
|
199
|
+
) {
|
|
200
|
+
return {
|
|
201
|
+
status: 'enabled',
|
|
202
|
+
testResult: 'Encryption and decryption verified successfully',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
|
|
207
|
+
return {
|
|
208
|
+
status: 'unhealthy',
|
|
209
|
+
testResult: 'Fields are not being encrypted in database',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (decryptionWorks && !normalNotEncrypted) {
|
|
214
|
+
return {
|
|
215
|
+
status: 'unhealthy',
|
|
216
|
+
testResult: 'Normal fields are being incorrectly encrypted',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
status: 'unhealthy',
|
|
222
|
+
testResult: 'Decryption failed or data mismatch',
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const withTimeout = (promise, ms, errorMessage) => {
|
|
227
|
+
return Promise.race([
|
|
228
|
+
promise,
|
|
229
|
+
new Promise((_, reject) =>
|
|
230
|
+
setTimeout(() => reject(new Error(errorMessage)), ms)
|
|
231
|
+
),
|
|
232
|
+
]);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const testEncryption = async () => {
|
|
236
|
+
// eslint-disable-next-line no-console
|
|
237
|
+
console.log('Starting encryption test');
|
|
238
|
+
const TestModel = createTestEncryptionModel();
|
|
239
|
+
// eslint-disable-next-line no-console
|
|
240
|
+
console.log('Test model created');
|
|
241
|
+
|
|
242
|
+
const testData = {
|
|
243
|
+
testSecret: 'This is a secret value that should be encrypted',
|
|
244
|
+
normalField: 'This is a normal field that should not be encrypted',
|
|
245
|
+
nestedSecret: {
|
|
246
|
+
value: 'This is a nested secret that should be encrypted',
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const testDoc = new TestModel(testData);
|
|
251
|
+
await withTimeout(testDoc.save(), 5000, 'Save operation timed out');
|
|
252
|
+
// eslint-disable-next-line no-console
|
|
253
|
+
console.log('Test document saved');
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const retrievedDoc = await withTimeout(
|
|
257
|
+
TestModel.findById(testDoc._id),
|
|
258
|
+
5000,
|
|
259
|
+
'Find operation timed out'
|
|
260
|
+
);
|
|
261
|
+
// eslint-disable-next-line no-console
|
|
262
|
+
console.log('Test document retrieved');
|
|
263
|
+
const decryptionWorks = verifyDecryption(retrievedDoc, testData);
|
|
264
|
+
const encryptionResults = await withTimeout(
|
|
265
|
+
verifyEncryptionInDatabase(testDoc, testData, TestModel),
|
|
266
|
+
5000,
|
|
267
|
+
'Database verification timed out'
|
|
268
|
+
);
|
|
269
|
+
// eslint-disable-next-line no-console
|
|
270
|
+
console.log('Encryption verification completed');
|
|
271
|
+
|
|
272
|
+
const evaluation = evaluateEncryptionTestResults(
|
|
273
|
+
decryptionWorks,
|
|
274
|
+
encryptionResults
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
...evaluation,
|
|
279
|
+
encryptionWorks: decryptionWorks,
|
|
280
|
+
};
|
|
281
|
+
} finally {
|
|
282
|
+
await withTimeout(
|
|
283
|
+
TestModel.deleteOne({ _id: testDoc._id }),
|
|
284
|
+
5000,
|
|
285
|
+
'Delete operation timed out'
|
|
286
|
+
);
|
|
287
|
+
// eslint-disable-next-line no-console
|
|
288
|
+
console.log('Test document deleted');
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const checkEncryptionHealth = async () => {
|
|
293
|
+
const config = getEncryptionConfiguration();
|
|
294
|
+
|
|
295
|
+
if (config.isBypassed || config.mode === 'none') {
|
|
296
|
+
// eslint-disable-next-line no-console
|
|
297
|
+
console.log('Encryption check bypassed:', {
|
|
298
|
+
stage: config.stage,
|
|
299
|
+
mode: config.mode,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const testResult = config.isBypassed
|
|
303
|
+
? 'Encryption bypassed for this stage'
|
|
304
|
+
: 'No encryption keys configured';
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
status: 'disabled',
|
|
308
|
+
mode: config.mode,
|
|
309
|
+
bypassed: config.isBypassed,
|
|
310
|
+
stage: config.stage,
|
|
311
|
+
testResult,
|
|
312
|
+
encryptionWorks: false,
|
|
313
|
+
debug: {
|
|
314
|
+
hasKMS: config.hasKMS,
|
|
315
|
+
hasAES: config.hasAES,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const testResults = await testEncryption();
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
...testResults,
|
|
325
|
+
mode: config.mode,
|
|
326
|
+
bypassed: config.isBypassed,
|
|
327
|
+
stage: config.stage,
|
|
328
|
+
debug: {
|
|
329
|
+
hasKMS: config.hasKMS,
|
|
330
|
+
hasAES: config.hasAES,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return {
|
|
335
|
+
status: 'unhealthy',
|
|
336
|
+
mode: config.mode,
|
|
337
|
+
bypassed: config.isBypassed,
|
|
338
|
+
stage: config.stage,
|
|
339
|
+
testResult: `Encryption test failed: ${error.message}`,
|
|
340
|
+
encryptionWorks: false,
|
|
341
|
+
debug: {
|
|
342
|
+
hasKMS: config.hasKMS,
|
|
343
|
+
hasAES: config.hasAES,
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const checkExternalAPIs = async () => {
|
|
350
|
+
const apis = [
|
|
351
|
+
{ name: 'github', url: 'https://api.github.com/status' },
|
|
352
|
+
{ name: 'npm', url: 'https://registry.npmjs.org' },
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const results = await Promise.all(
|
|
356
|
+
apis.map((api) =>
|
|
357
|
+
checkExternalAPI(api.url).then((result) => ({
|
|
358
|
+
name: api.name,
|
|
359
|
+
...result,
|
|
360
|
+
}))
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const apiStatuses = {};
|
|
365
|
+
let allReachable = true;
|
|
366
|
+
|
|
367
|
+
results.forEach(({ name, ...checkResult }) => {
|
|
368
|
+
apiStatuses[name] = checkResult;
|
|
369
|
+
if (!checkResult.reachable) {
|
|
370
|
+
allReachable = false;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return { apiStatuses, allReachable };
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const checkIntegrations = () => {
|
|
378
|
+
const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
|
|
379
|
+
? moduleFactory.moduleTypes
|
|
380
|
+
: [];
|
|
381
|
+
|
|
382
|
+
const integrationTypes = Array.isArray(integrationFactory.integrationTypes)
|
|
383
|
+
? integrationFactory.integrationTypes
|
|
384
|
+
: [];
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
status: 'healthy',
|
|
388
|
+
modules: {
|
|
389
|
+
count: moduleTypes.length,
|
|
390
|
+
available: moduleTypes,
|
|
391
|
+
},
|
|
392
|
+
integrations: {
|
|
393
|
+
count: integrationTypes.length,
|
|
394
|
+
available: integrationTypes,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const buildHealthCheckResponse = (startTime) => {
|
|
400
|
+
return {
|
|
401
|
+
service: 'frigg-core-api',
|
|
402
|
+
status: 'healthy',
|
|
403
|
+
timestamp: new Date().toISOString(),
|
|
404
|
+
checks: {},
|
|
405
|
+
calculateResponseTime: () => Date.now() - startTime,
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// Helper to detect VPC configuration
|
|
410
|
+
const detectVpcConfiguration = async () => {
|
|
411
|
+
const results = {
|
|
412
|
+
isInVpc: false,
|
|
413
|
+
hasInternetAccess: false,
|
|
414
|
+
canResolvePublicDns: false,
|
|
415
|
+
canConnectToAws: false,
|
|
416
|
+
vpcEndpoints: [],
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
// Check if we're in a VPC by looking for VPC-specific environment
|
|
421
|
+
// Lambda in VPC has specific network interface configuration
|
|
422
|
+
const dns = require('dns').promises;
|
|
423
|
+
|
|
424
|
+
// Test 1: Can we resolve public DNS? (indicates DNS configuration)
|
|
425
|
+
try {
|
|
426
|
+
await Promise.race([
|
|
427
|
+
dns.resolve4('www.google.com'),
|
|
428
|
+
new Promise((_, reject) =>
|
|
429
|
+
setTimeout(() => reject(new Error('timeout')), 2000)
|
|
430
|
+
),
|
|
431
|
+
]);
|
|
432
|
+
results.canResolvePublicDns = true;
|
|
433
|
+
} catch (e) {
|
|
434
|
+
console.log('Public DNS resolution failed:', e.message);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Test 2: Can we reach internet? (indicates NAT gateway)
|
|
438
|
+
try {
|
|
439
|
+
const https = require('https');
|
|
440
|
+
await new Promise((resolve, reject) => {
|
|
441
|
+
const req = https.get(
|
|
442
|
+
'https://www.google.com',
|
|
443
|
+
{ timeout: 2000 },
|
|
444
|
+
(res) => {
|
|
445
|
+
res.destroy();
|
|
446
|
+
resolve(true);
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
req.on('error', reject);
|
|
450
|
+
req.on('timeout', () => {
|
|
451
|
+
req.destroy();
|
|
452
|
+
reject(new Error('timeout'));
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
results.hasInternetAccess = true;
|
|
456
|
+
} catch (e) {
|
|
457
|
+
console.log('Internet connectivity test failed:', e.message);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Test 3: Check for VPC endpoints by trying to resolve internal AWS endpoints
|
|
461
|
+
const region = process.env.AWS_REGION; // Lambda always provides this
|
|
462
|
+
const vpcEndpointDomains = [
|
|
463
|
+
`com.amazonaws.${region}.kms`,
|
|
464
|
+
`com.amazonaws.vpce.${region}`,
|
|
465
|
+
`kms.${region}.amazonaws.com`,
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
for (const domain of vpcEndpointDomains) {
|
|
469
|
+
try {
|
|
470
|
+
const addresses = await Promise.race([
|
|
471
|
+
dns.resolve4(domain).catch(() => dns.resolve6(domain)),
|
|
472
|
+
new Promise((_, reject) =>
|
|
473
|
+
setTimeout(() => reject(new Error('timeout')), 1000)
|
|
474
|
+
),
|
|
475
|
+
]);
|
|
476
|
+
if (addresses && addresses.length > 0) {
|
|
477
|
+
// Check if it's a private IP (VPC endpoint indicator)
|
|
478
|
+
const isPrivateIp = addresses.some(
|
|
479
|
+
(ip) =>
|
|
480
|
+
ip.startsWith('10.') ||
|
|
481
|
+
ip.startsWith('172.') ||
|
|
482
|
+
ip.startsWith('192.168.')
|
|
483
|
+
);
|
|
484
|
+
if (isPrivateIp) {
|
|
485
|
+
results.vpcEndpoints.push(domain);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch (e) {
|
|
489
|
+
// Expected for non-existent endpoints
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
results.isInVpc =
|
|
494
|
+
!results.hasInternetAccess || results.vpcEndpoints.length > 0;
|
|
495
|
+
results.canConnectToAws =
|
|
496
|
+
results.hasInternetAccess || results.vpcEndpoints.length > 0;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
console.error('VPC detection error:', error.message);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return results;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// KMS decrypt capability check
|
|
505
|
+
const checkKmsDecryptCapability = async () => {
|
|
506
|
+
const start = Date.now();
|
|
507
|
+
const { KMS_KEY_ARN } = process.env;
|
|
508
|
+
if (!KMS_KEY_ARN) {
|
|
509
|
+
return {
|
|
510
|
+
status: 'skipped',
|
|
511
|
+
reason: 'KMS_KEY_ARN not configured',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Log environment for debugging
|
|
516
|
+
console.log('KMS Check Debug:', {
|
|
517
|
+
hasKmsKeyArn: !!KMS_KEY_ARN,
|
|
518
|
+
kmsKeyArnPrefix: KMS_KEY_ARN?.substring(0, 30),
|
|
519
|
+
awsRegion: process.env.AWS_REGION,
|
|
520
|
+
hasDiscoveryKey: !!process.env.AWS_DISCOVERY_KMS_KEY_ID,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// First, detect VPC configuration
|
|
524
|
+
const vpcConfig = await detectVpcConfiguration();
|
|
525
|
+
console.log('VPC Configuration:', vpcConfig);
|
|
526
|
+
|
|
527
|
+
// Test DNS resolution for KMS endpoint
|
|
528
|
+
try {
|
|
529
|
+
const dns = require('dns').promises;
|
|
530
|
+
const region = process.env.AWS_REGION; // Lambda always provides this
|
|
531
|
+
const kmsEndpoint = `kms.${region}.amazonaws.com`;
|
|
532
|
+
console.log('Testing DNS resolution for:', kmsEndpoint);
|
|
533
|
+
|
|
534
|
+
// Wrap DNS resolution in a timeout
|
|
535
|
+
const dnsPromise = dns.resolve4(kmsEndpoint);
|
|
536
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
537
|
+
setTimeout(() => reject(new Error('DNS resolution timeout')), 3000)
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const addresses = await Promise.race([dnsPromise, timeoutPromise]);
|
|
541
|
+
console.log('KMS endpoint resolved to:', addresses);
|
|
542
|
+
|
|
543
|
+
// Check if resolved to private IP (VPC endpoint)
|
|
544
|
+
const isVpcEndpoint = addresses.some(
|
|
545
|
+
(ip) =>
|
|
546
|
+
ip.startsWith('10.') ||
|
|
547
|
+
ip.startsWith('172.') ||
|
|
548
|
+
ip.startsWith('192.168.')
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
if (isVpcEndpoint) {
|
|
552
|
+
console.log(
|
|
553
|
+
'KMS VPC Endpoint detected - using private connectivity'
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Test TCP connectivity to KMS (port 443)
|
|
558
|
+
const net = require('net');
|
|
559
|
+
const testConnection = () =>
|
|
560
|
+
new Promise((resolve) => {
|
|
561
|
+
const socket = new net.Socket();
|
|
562
|
+
const connectionTimeout = setTimeout(() => {
|
|
563
|
+
socket.destroy();
|
|
564
|
+
resolve({ connected: false, error: 'Connection timeout' });
|
|
565
|
+
}, 3000);
|
|
566
|
+
|
|
567
|
+
socket.on('connect', () => {
|
|
568
|
+
clearTimeout(connectionTimeout);
|
|
569
|
+
socket.destroy();
|
|
570
|
+
resolve({ connected: true });
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
socket.on('error', (err) => {
|
|
574
|
+
clearTimeout(connectionTimeout);
|
|
575
|
+
resolve({ connected: false, error: err.message });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Try connecting to first resolved address on HTTPS port
|
|
579
|
+
socket.connect(443, addresses[0]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const connResult = await testConnection();
|
|
583
|
+
console.log('TCP connectivity test:', connResult);
|
|
584
|
+
|
|
585
|
+
if (!connResult.connected) {
|
|
586
|
+
return {
|
|
587
|
+
status: 'unhealthy',
|
|
588
|
+
error: `Cannot connect to KMS endpoint: ${connResult.error}`,
|
|
589
|
+
dnsResolved: true,
|
|
590
|
+
tcpConnection: false,
|
|
591
|
+
vpcConfig,
|
|
592
|
+
latencyMs: Date.now() - start,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
} catch (dnsError) {
|
|
596
|
+
console.error('DNS resolution failed:', dnsError.message);
|
|
597
|
+
return {
|
|
598
|
+
status: 'unhealthy',
|
|
599
|
+
error: `Cannot resolve KMS endpoint: ${dnsError.message}`,
|
|
600
|
+
dnsResolved: false,
|
|
601
|
+
vpcConfig,
|
|
602
|
+
latencyMs: Date.now() - start,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
// Use AWS SDK v3 for consistency with the rest of the codebase
|
|
608
|
+
// eslint-disable-next-line global-require
|
|
609
|
+
const {
|
|
610
|
+
KMSClient,
|
|
611
|
+
GenerateDataKeyCommand,
|
|
612
|
+
DecryptCommand,
|
|
613
|
+
} = require('@aws-sdk/client-kms');
|
|
614
|
+
|
|
615
|
+
// Lambda always provides AWS_REGION
|
|
616
|
+
const region = process.env.AWS_REGION;
|
|
617
|
+
|
|
618
|
+
const kms = new KMSClient({
|
|
619
|
+
region,
|
|
620
|
+
requestHandler: {
|
|
621
|
+
connectionTimeout: 10000, // 10 second connection timeout
|
|
622
|
+
requestTimeout: 25000, // 25 second timeout for slow VPC connections
|
|
623
|
+
},
|
|
624
|
+
maxAttempts: 1, // No retries on health checks
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Generate a data key (without plaintext logging) then immediately decrypt ciphertext to ensure decrypt perms.
|
|
628
|
+
const dataKeyResp = await kms.send(
|
|
629
|
+
new GenerateDataKeyCommand({
|
|
630
|
+
KeyId: KMS_KEY_ARN,
|
|
631
|
+
KeySpec: 'AES_256',
|
|
632
|
+
})
|
|
633
|
+
);
|
|
634
|
+
const decryptResp = await kms.send(
|
|
635
|
+
new DecryptCommand({ CiphertextBlob: dataKeyResp.CiphertextBlob })
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
const success = Boolean(
|
|
639
|
+
dataKeyResp.CiphertextBlob && decryptResp.Plaintext
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
status: success ? 'healthy' : 'unhealthy',
|
|
644
|
+
kmsKeyArnSuffix: KMS_KEY_ARN.slice(-12),
|
|
645
|
+
vpcConfig,
|
|
646
|
+
latencyMs: Date.now() - start,
|
|
647
|
+
};
|
|
648
|
+
} catch (error) {
|
|
649
|
+
return {
|
|
650
|
+
status: 'unhealthy',
|
|
651
|
+
error: error.message,
|
|
652
|
+
vpcConfig,
|
|
653
|
+
latencyMs: Date.now() - start,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
router.get('/health', async (_req, res) => {
|
|
659
|
+
const status = {
|
|
660
|
+
status: 'ok',
|
|
661
|
+
timestamp: new Date().toISOString(),
|
|
662
|
+
service: 'frigg-core-api',
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
res.status(200).json(status);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
router.get('/health/detailed', async (_req, res) => {
|
|
669
|
+
// eslint-disable-next-line no-console
|
|
670
|
+
console.log('Starting detailed health check');
|
|
671
|
+
const startTime = Date.now();
|
|
672
|
+
const response = buildHealthCheckResponse(startTime);
|
|
673
|
+
|
|
674
|
+
// Log environment before any async operations
|
|
675
|
+
console.log('Health Check Environment:', {
|
|
676
|
+
hasKmsKeyArn: !!process.env.KMS_KEY_ARN,
|
|
677
|
+
awsRegion: process.env.AWS_REGION,
|
|
678
|
+
awsDefaultRegion: process.env.AWS_DEFAULT_REGION,
|
|
679
|
+
nodeEnv: process.env.NODE_ENV,
|
|
680
|
+
stage: process.env.STAGE,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// 1. Network diagnostics (run first to understand connectivity)
|
|
684
|
+
try {
|
|
685
|
+
console.log('Running network diagnostics...');
|
|
686
|
+
const networkStart = Date.now();
|
|
687
|
+
response.checks.network = await Promise.race([
|
|
688
|
+
detectVpcConfiguration(),
|
|
689
|
+
new Promise((_, reject) =>
|
|
690
|
+
setTimeout(
|
|
691
|
+
() => reject(new Error('Network diagnostics timeout')),
|
|
692
|
+
5000
|
|
693
|
+
)
|
|
694
|
+
),
|
|
695
|
+
]);
|
|
696
|
+
response.checks.network.latencyMs = Date.now() - networkStart;
|
|
697
|
+
console.log('Network diagnostics completed:', response.checks.network);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
response.checks.network = {
|
|
700
|
+
status: 'error',
|
|
701
|
+
error: error.message,
|
|
702
|
+
};
|
|
703
|
+
console.log('Network diagnostics error:', error.message);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 2. KMS decrypt capability (must succeed before DB assumed healthy if encryption depends on KMS)
|
|
707
|
+
try {
|
|
708
|
+
console.log('About to check KMS capability...');
|
|
709
|
+
// Wrap the entire KMS check in a timeout (allow up to 25 seconds for slow VPC)
|
|
710
|
+
const kmsCheckPromise = checkKmsDecryptCapability();
|
|
711
|
+
const kmsTimeoutPromise = new Promise((_, reject) =>
|
|
712
|
+
setTimeout(
|
|
713
|
+
() => reject(new Error('KMS check timeout after 25 seconds')),
|
|
714
|
+
25000
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
response.checks.kms = await Promise.race([
|
|
719
|
+
kmsCheckPromise,
|
|
720
|
+
kmsTimeoutPromise,
|
|
721
|
+
]);
|
|
722
|
+
if (response.checks.kms.status === 'unhealthy') {
|
|
723
|
+
response.status = 'unhealthy';
|
|
724
|
+
}
|
|
725
|
+
// eslint-disable-next-line no-console
|
|
726
|
+
console.log('KMS check completed:', response.checks.kms);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
response.checks.kms = { status: 'unhealthy', error: error.message };
|
|
729
|
+
response.status = 'unhealthy';
|
|
730
|
+
// eslint-disable-next-line no-console
|
|
731
|
+
console.log('KMS check error:', error.message);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
response.checks.database = await checkDatabaseHealth();
|
|
736
|
+
const dbState = getDatabaseState();
|
|
737
|
+
if (!dbState.isConnected) {
|
|
738
|
+
response.status = 'unhealthy';
|
|
739
|
+
}
|
|
740
|
+
// eslint-disable-next-line no-console
|
|
741
|
+
console.log('Database check completed:', response.checks.database);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
response.checks.database = {
|
|
744
|
+
status: 'unhealthy',
|
|
745
|
+
error: error.message,
|
|
746
|
+
};
|
|
747
|
+
response.status = 'unhealthy';
|
|
748
|
+
// eslint-disable-next-line no-console
|
|
749
|
+
console.log('Database check error:', error.message);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
response.checks.encryption = await checkEncryptionHealth();
|
|
754
|
+
if (response.checks.encryption.status === 'unhealthy') {
|
|
755
|
+
response.status = 'unhealthy';
|
|
756
|
+
}
|
|
757
|
+
// eslint-disable-next-line no-console
|
|
758
|
+
console.log('Encryption check completed:', response.checks.encryption);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
response.checks.encryption = {
|
|
761
|
+
status: 'unhealthy',
|
|
762
|
+
error: error.message,
|
|
763
|
+
};
|
|
764
|
+
response.status = 'unhealthy';
|
|
765
|
+
// eslint-disable-next-line no-console
|
|
766
|
+
console.log('Encryption check error:', error.message);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const { apiStatuses, allReachable } = await checkExternalAPIs();
|
|
770
|
+
response.checks.externalApis = apiStatuses;
|
|
771
|
+
if (!allReachable) {
|
|
772
|
+
response.status = 'unhealthy';
|
|
773
|
+
}
|
|
774
|
+
// eslint-disable-next-line no-console
|
|
775
|
+
console.log('External APIs check completed:', response.checks.externalApis);
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
response.checks.integrations = checkIntegrations();
|
|
779
|
+
// eslint-disable-next-line no-console
|
|
780
|
+
console.log(
|
|
781
|
+
'Integrations check completed:',
|
|
782
|
+
response.checks.integrations
|
|
783
|
+
);
|
|
784
|
+
} catch (error) {
|
|
785
|
+
response.checks.integrations = {
|
|
786
|
+
status: 'unhealthy',
|
|
787
|
+
error: error.message,
|
|
788
|
+
};
|
|
789
|
+
response.status = 'unhealthy';
|
|
790
|
+
// eslint-disable-next-line no-console
|
|
791
|
+
console.log('Integrations check error:', error.message);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
response.responseTime = response.calculateResponseTime();
|
|
795
|
+
delete response.calculateResponseTime;
|
|
796
|
+
|
|
797
|
+
const statusCode = response.status === 'healthy' ? 200 : 503;
|
|
798
|
+
res.status(statusCode).json(response);
|
|
799
|
+
|
|
800
|
+
// eslint-disable-next-line no-console
|
|
801
|
+
console.log(
|
|
802
|
+
'Final health status:',
|
|
803
|
+
response.status,
|
|
804
|
+
'Response time:',
|
|
805
|
+
response.responseTime
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
router.get('/health/live', (_req, res) => {
|
|
810
|
+
res.status(200).json({
|
|
811
|
+
status: 'alive',
|
|
812
|
+
timestamp: new Date().toISOString(),
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
router.get('/health/ready', async (_req, res) => {
|
|
817
|
+
const dbState = getDatabaseState();
|
|
818
|
+
const isDbReady = dbState.isConnected;
|
|
819
|
+
|
|
820
|
+
let areModulesReady = false;
|
|
821
|
+
try {
|
|
822
|
+
const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
|
|
823
|
+
? moduleFactory.moduleTypes
|
|
824
|
+
: [];
|
|
825
|
+
areModulesReady = moduleTypes.length > 0;
|
|
826
|
+
} catch (error) {
|
|
827
|
+
areModulesReady = false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const isReady = isDbReady && areModulesReady;
|
|
831
|
+
|
|
832
|
+
res.status(isReady ? 200 : 503).json({
|
|
833
|
+
ready: isReady,
|
|
834
|
+
timestamp: new Date().toISOString(),
|
|
835
|
+
checks: {
|
|
836
|
+
database: isDbReady,
|
|
837
|
+
modules: areModulesReady,
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const handler = createAppHandler('HTTP Event: Health', router);
|
|
843
|
+
|
|
844
|
+
module.exports = { handler, router };
|