iri-shield 1.2.3 → 1.2.4
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/README.md +18 -3
- package/package.json +1 -1
- package/src/dashboard.js +86 -37
- package/src/index.js +1 -0
- package/src/mongodb-storage.js +168 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# iri-shield
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/iri-shield)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](#explainable-risk-scoring)
|
|
@@ -402,10 +402,13 @@ dashboard: {
|
|
|
402
402
|
path: '/iri-shield',
|
|
403
403
|
username: process.env.SHIELD_ADMIN_USER,
|
|
404
404
|
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
405
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET,
|
|
405
406
|
refreshMs: 60 * 1000
|
|
406
407
|
}
|
|
407
408
|
```
|
|
408
409
|
|
|
410
|
+
Set a stable `sessionSecret` on serverless hosts. Otherwise old deployments that used a random per-instance dashboard token could intermittently return `401 Unauthorized` from dashboard API calls after a cold start or instance switch.
|
|
411
|
+
|
|
409
412
|
### Production Configuration
|
|
410
413
|
|
|
411
414
|
```js
|
|
@@ -443,7 +446,8 @@ const shield = createShield({
|
|
|
443
446
|
enabled: true,
|
|
444
447
|
path: '/iri-shield',
|
|
445
448
|
username: process.env.SHIELD_ADMIN_USER,
|
|
446
|
-
password: process.env.SHIELD_ADMIN_PASSWORD
|
|
449
|
+
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
450
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET
|
|
447
451
|
}
|
|
448
452
|
});
|
|
449
453
|
|
|
@@ -476,6 +480,15 @@ storage: {
|
|
|
476
480
|
|
|
477
481
|
This avoids filesystem initialization errors and is suitable for testing the middleware, dashboard, detection rules, and API behaviour.
|
|
478
482
|
|
|
483
|
+
For persistent Vercel dashboard history, use MongoDB:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
storage: {
|
|
487
|
+
mode: 'mongodb',
|
|
488
|
+
mongoUrl: process.env.IRI_MONGO_URL
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
479
492
|
If you explicitly want SQLite for a short-lived demo, use `/tmp`:
|
|
480
493
|
|
|
481
494
|
```js
|
|
@@ -515,7 +528,7 @@ const shield = createShield({
|
|
|
515
528
|
trustProxy: true,
|
|
516
529
|
|
|
517
530
|
storage: {
|
|
518
|
-
mode: process.env.IRI_STORAGE_MODE || 'memory',
|
|
531
|
+
mode: process.env.IRI_STORAGE_MODE || (process.env.IRI_MONGO_URL ? 'mongodb' : 'memory'),
|
|
519
532
|
sqliteFile: process.env.IRI_SQLITE_FILE || '/tmp/iri-shield.sqlite',
|
|
520
533
|
mongoUrl: process.env.IRI_MONGO_URL
|
|
521
534
|
},
|
|
@@ -530,6 +543,7 @@ const shield = createShield({
|
|
|
530
543
|
path: '/iri-shield',
|
|
531
544
|
username: process.env.SHIELD_ADMIN_USER,
|
|
532
545
|
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
546
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET,
|
|
533
547
|
refreshMs: 60 * 1000
|
|
534
548
|
}
|
|
535
549
|
});
|
|
@@ -564,6 +578,7 @@ NODE_ENV=production
|
|
|
564
578
|
APP_NAME=my-api
|
|
565
579
|
SHIELD_ADMIN_USER=admin
|
|
566
580
|
SHIELD_ADMIN_PASSWORD=replace-with-a-strong-secret
|
|
581
|
+
IRI_SHIELD_DASHBOARD_SECRET=replace-with-a-long-random-session-secret
|
|
567
582
|
IRI_STORAGE_MODE=memory
|
|
568
583
|
```
|
|
569
584
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iri-shield",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Enterprise Express.js security middleware — explainable risk scoring, multi-signal identity profiling, behavioural anomaly detection, attack correlation, sensitive-data redaction, and dashboard monitoring.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iri-shield",
|
package/src/dashboard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const express = require('express');
|
|
4
|
-
const {
|
|
4
|
+
const { createHmac, timingSafeEqual } = require('crypto');
|
|
5
5
|
|
|
6
6
|
function createDashboardRouter({ storage, config }) {
|
|
7
7
|
const router = express.Router();
|
|
@@ -46,60 +46,66 @@ function createDashboardRouter({ storage, config }) {
|
|
|
46
46
|
// API endpoints
|
|
47
47
|
// =========================================================================
|
|
48
48
|
|
|
49
|
-
router.
|
|
50
|
-
res.
|
|
49
|
+
router.use('/api/', (_req, res, next) => {
|
|
50
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
51
|
+
next();
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
router.get('/api/stats', asyncRoute(async (req, res) => {
|
|
55
|
+
const stats = await callStorage(storage, 'getStats');
|
|
56
|
+
res.json({ ...stats, config: publicConfig(config) });
|
|
57
|
+
}));
|
|
58
|
+
|
|
53
59
|
// Paginated events
|
|
54
|
-
router.get('/api/events', (req, res) => {
|
|
60
|
+
router.get('/api/events', asyncRoute(async (req, res) => {
|
|
55
61
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
56
62
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
57
63
|
const riskLevel = req.query.riskLevel || null;
|
|
58
64
|
if (typeof storage.getEvents === 'function') {
|
|
59
|
-
res.json(storage
|
|
65
|
+
res.json(await callStorage(storage, 'getEvents', { page, perPage, riskLevel }));
|
|
60
66
|
} else {
|
|
61
67
|
const events = (storage.events || []).filter((e) => !riskLevel || e.riskLevel === riskLevel);
|
|
62
68
|
res.json(paginateArray(events, page, perPage));
|
|
63
69
|
}
|
|
64
|
-
});
|
|
70
|
+
}));
|
|
65
71
|
|
|
66
72
|
// Paginated clients
|
|
67
|
-
router.get('/api/clients', (req, res) => {
|
|
73
|
+
router.get('/api/clients', asyncRoute(async (req, res) => {
|
|
68
74
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
69
75
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
70
76
|
if (typeof storage.getClients === 'function') {
|
|
71
|
-
res.json(storage
|
|
77
|
+
res.json(await callStorage(storage, 'getClients', { page, perPage }));
|
|
72
78
|
} else {
|
|
73
79
|
const clients = Array.from((storage.clients || new Map()).values()).sort(
|
|
74
80
|
(a, b) => b.requestCount - a.requestCount
|
|
75
81
|
);
|
|
76
82
|
res.json(paginateArray(clients, page, perPage));
|
|
77
83
|
}
|
|
78
|
-
});
|
|
84
|
+
}));
|
|
79
85
|
|
|
80
86
|
// Paginated blocked IPs with status filter
|
|
81
|
-
router.get('/api/blocked', (req, res) => {
|
|
87
|
+
router.get('/api/blocked', asyncRoute(async (req, res) => {
|
|
82
88
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
83
89
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
84
90
|
const status = req.query.status || 'all';
|
|
85
91
|
if (typeof storage.getBlockedIps === 'function') {
|
|
86
|
-
res.json(storage
|
|
92
|
+
res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
|
|
87
93
|
} else {
|
|
88
|
-
res.json(storage
|
|
94
|
+
res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
|
|
89
95
|
}
|
|
90
|
-
});
|
|
96
|
+
}));
|
|
91
97
|
|
|
92
98
|
// Paginated alerts
|
|
93
|
-
router.get('/api/alerts', (req, res) => {
|
|
99
|
+
router.get('/api/alerts', asyncRoute(async (req, res) => {
|
|
94
100
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
95
101
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
96
102
|
const dismissed = req.query.dismissed === 'true';
|
|
97
103
|
if (typeof storage.getAlerts === 'function') {
|
|
98
|
-
res.json(storage
|
|
104
|
+
res.json(await callStorage(storage, 'getAlerts', { page, perPage, dismissed }));
|
|
99
105
|
} else {
|
|
100
106
|
res.json(paginateArray([], page, perPage));
|
|
101
107
|
}
|
|
102
|
-
});
|
|
108
|
+
}));
|
|
103
109
|
|
|
104
110
|
// Unblock / Delete Block for an IP
|
|
105
111
|
router.post('/api/unblock', (req, res) => {
|
|
@@ -711,6 +717,27 @@ function formatThreatLabel(name) {
|
|
|
711
717
|
return cleaned.length > 12 ? cleaned.slice(0, 11) + '..' : cleaned;
|
|
712
718
|
}
|
|
713
719
|
|
|
720
|
+
async function apiJson(url, options) {
|
|
721
|
+
const res = await fetch(url, Object.assign({ cache: 'no-store' }, options || {}));
|
|
722
|
+
if (res.status === 401) {
|
|
723
|
+
window.location.href = BASE + '/login';
|
|
724
|
+
throw new Error('Dashboard session expired');
|
|
725
|
+
}
|
|
726
|
+
let data = {};
|
|
727
|
+
try { data = await res.json(); } catch (_) { /* non-json response */ }
|
|
728
|
+
if (!res.ok) throw new Error(data.error || ('Request failed with status ' + res.status));
|
|
729
|
+
return data;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function normalizePageData(pageData) {
|
|
733
|
+
const data = pageData && typeof pageData === 'object' ? pageData : {};
|
|
734
|
+
const total = Number.isFinite(Number(data.total)) ? Number(data.total) : 0;
|
|
735
|
+
const perPage = Number.isFinite(Number(data.perPage)) && Number(data.perPage) > 0 ? Number(data.perPage) : 20;
|
|
736
|
+
const totalPages = Math.max(1, Number.isFinite(Number(data.totalPages)) ? Number(data.totalPages) : Math.ceil(total / perPage) || 1);
|
|
737
|
+
const page = Math.max(1, Math.min(Number.isFinite(Number(data.page)) ? Number(data.page) : 1, totalPages));
|
|
738
|
+
return { ...data, data: Array.isArray(data.data) ? data.data : [], total, perPage, totalPages, page };
|
|
739
|
+
}
|
|
740
|
+
|
|
714
741
|
// -------------------------------------------------------------------------
|
|
715
742
|
// Navigation & Panel Memory
|
|
716
743
|
// -------------------------------------------------------------------------
|
|
@@ -826,13 +853,13 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
|
|
|
826
853
|
|
|
827
854
|
try {
|
|
828
855
|
if (shouldBlock) {
|
|
829
|
-
await
|
|
856
|
+
await apiJson(BASE + '/api/block', {
|
|
830
857
|
method: 'POST',
|
|
831
858
|
headers: { 'content-type': 'application/json' },
|
|
832
859
|
body: JSON.stringify({ ip, reason: reason || 'manual_action', durationMs })
|
|
833
860
|
});
|
|
834
861
|
} else {
|
|
835
|
-
await
|
|
862
|
+
await apiJson(BASE + '/api/unblock', {
|
|
836
863
|
method: 'POST',
|
|
837
864
|
headers: { 'content-type': 'application/json' },
|
|
838
865
|
body: JSON.stringify({ ip })
|
|
@@ -858,8 +885,7 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
|
|
|
858
885
|
|
|
859
886
|
async function loadStats() {
|
|
860
887
|
try {
|
|
861
|
-
const
|
|
862
|
-
const data = await res.json();
|
|
888
|
+
const data = await apiJson(BASE + '/api/stats');
|
|
863
889
|
|
|
864
890
|
refreshMs = Number(data.config?.dashboard?.refreshMs || refreshMs);
|
|
865
891
|
document.getElementById('refreshLabel').textContent = Math.round(refreshMs / 1000) + 's';
|
|
@@ -954,8 +980,7 @@ async function loadEvents(page) {
|
|
|
954
980
|
const risk = document.getElementById('eventRiskFilter')?.value || '';
|
|
955
981
|
try {
|
|
956
982
|
const url = BASE + '/api/events?page=' + currentPage.events + '&perPage=20' + (risk ? '&riskLevel=' + risk : '');
|
|
957
|
-
const
|
|
958
|
-
const data = await res.json();
|
|
983
|
+
const data = normalizePageData(await apiJson(url));
|
|
959
984
|
rawEventsData = data.data || [];
|
|
960
985
|
const tbody = document.getElementById('eventRows');
|
|
961
986
|
if (!rawEventsData.length) {
|
|
@@ -1103,8 +1128,7 @@ function toggleEventDetail(id) {
|
|
|
1103
1128
|
async function loadClients(page) {
|
|
1104
1129
|
currentPage.clients = page || currentPage.clients;
|
|
1105
1130
|
try {
|
|
1106
|
-
const
|
|
1107
|
-
const data = await res.json();
|
|
1131
|
+
const data = normalizePageData(await apiJson(BASE + '/api/clients?page=' + currentPage.clients + '&perPage=20'));
|
|
1108
1132
|
const tbody = document.getElementById('clientRows');
|
|
1109
1133
|
if (!data.data?.length) {
|
|
1110
1134
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="9">No clients recorded</td></tr>';
|
|
@@ -1137,8 +1161,7 @@ async function loadBlocked(page) {
|
|
|
1137
1161
|
currentPage.blocked = page || currentPage.blocked;
|
|
1138
1162
|
const status = document.getElementById('blockedStatusFilter')?.value || 'all';
|
|
1139
1163
|
try {
|
|
1140
|
-
const
|
|
1141
|
-
const data = await res.json();
|
|
1164
|
+
const data = normalizePageData(await apiJson(BASE + '/api/blocked?page=' + currentPage.blocked + '&perPage=20&status=' + status));
|
|
1142
1165
|
const tbody = document.getElementById('blockedRows');
|
|
1143
1166
|
if (!data.data?.length) {
|
|
1144
1167
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No IP addresses recorded in blocklist</td></tr>';
|
|
@@ -1183,7 +1206,7 @@ async function loadBlocked(page) {
|
|
|
1183
1206
|
async function doDeleteBlock(ip) {
|
|
1184
1207
|
if (!confirm('Permanently remove IP ' + ip + ' from block history?')) return;
|
|
1185
1208
|
try {
|
|
1186
|
-
await
|
|
1209
|
+
await apiJson(BASE + '/api/unblock', {
|
|
1187
1210
|
method: 'POST',
|
|
1188
1211
|
headers: { 'content-type': 'application/json' },
|
|
1189
1212
|
body: JSON.stringify({ ip })
|
|
@@ -1202,8 +1225,7 @@ async function doDeleteBlock(ip) {
|
|
|
1202
1225
|
async function loadAlerts(page) {
|
|
1203
1226
|
currentPage.alerts = page || currentPage.alerts;
|
|
1204
1227
|
try {
|
|
1205
|
-
const
|
|
1206
|
-
const data = await res.json();
|
|
1228
|
+
const data = normalizePageData(await apiJson(BASE + '/api/alerts?page=' + currentPage.alerts + '&perPage=20'));
|
|
1207
1229
|
const tbody = document.getElementById('alertRows');
|
|
1208
1230
|
if (!data.data?.length) {
|
|
1209
1231
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No active alerts</td></tr>';
|
|
@@ -1231,7 +1253,7 @@ async function loadAlerts(page) {
|
|
|
1231
1253
|
|
|
1232
1254
|
async function doDismiss(clientId) {
|
|
1233
1255
|
try {
|
|
1234
|
-
await
|
|
1256
|
+
await apiJson(BASE + '/api/alerts/' + encodeURIComponent(clientId) + '/dismiss', { method: 'POST' });
|
|
1235
1257
|
loadAlerts(currentPage.alerts);
|
|
1236
1258
|
loadStats();
|
|
1237
1259
|
} catch(e) { console.error('Dismiss error', e); }
|
|
@@ -1261,8 +1283,7 @@ async function submitBlock() {
|
|
|
1261
1283
|
|
|
1262
1284
|
async function loadSettings() {
|
|
1263
1285
|
try {
|
|
1264
|
-
const
|
|
1265
|
-
const data = await res.json();
|
|
1286
|
+
const data = await apiJson(BASE + '/api/settings');
|
|
1266
1287
|
if (data.security) selectMode(data.security);
|
|
1267
1288
|
} catch(e) { console.error('Load settings error', e); }
|
|
1268
1289
|
}
|
|
@@ -1283,12 +1304,11 @@ document.getElementById('settingsForm').addEventListener('submit', async functio
|
|
|
1283
1304
|
if (modeEl) payload.security = modeEl.value;
|
|
1284
1305
|
|
|
1285
1306
|
try {
|
|
1286
|
-
const
|
|
1307
|
+
const updated = await apiJson(BASE + '/api/settings', {
|
|
1287
1308
|
method: 'POST',
|
|
1288
1309
|
headers: { 'content-type': 'application/json' },
|
|
1289
1310
|
body: JSON.stringify(payload)
|
|
1290
1311
|
});
|
|
1291
|
-
const updated = await res.json();
|
|
1292
1312
|
if (updated.security) selectMode(updated.security);
|
|
1293
1313
|
|
|
1294
1314
|
const msg = document.getElementById('settingsMsg');
|
|
@@ -1376,9 +1396,15 @@ function renderChart(rows) {
|
|
|
1376
1396
|
// -------------------------------------------------------------------------
|
|
1377
1397
|
|
|
1378
1398
|
function renderPagination(containerId, pageData, loadFn) {
|
|
1399
|
+
pageData = normalizePageData(pageData);
|
|
1379
1400
|
const container = document.getElementById(containerId);
|
|
1380
|
-
if (!container
|
|
1381
|
-
|
|
1401
|
+
if (!container) return;
|
|
1402
|
+
if (pageData.total === 0) {
|
|
1403
|
+
container.innerHTML = '<div class="text-xs text-gray-500 font-medium">Showing <strong>0</strong> records</div>';
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (pageData.totalPages <= 1) {
|
|
1407
|
+
container.innerHTML = \`<div class="text-xs text-gray-500 font-medium">Showing <strong>1-\${fmt.format(pageData.total)}</strong> of <strong>\${fmt.format(pageData.total)}</strong> records</div>\`;
|
|
1382
1408
|
return;
|
|
1383
1409
|
}
|
|
1384
1410
|
const { page, totalPages, total, perPage } = pageData;
|
|
@@ -1561,7 +1587,15 @@ function iconAlert() { return '<svg class="w-5 h-5 text-blue-600" fill="none"
|
|
|
1561
1587
|
function dashboardAuth(options = {}) {
|
|
1562
1588
|
const username = options.username || 'admin';
|
|
1563
1589
|
const password = options.password || 'admin';
|
|
1564
|
-
const
|
|
1590
|
+
const sessionSecret =
|
|
1591
|
+
options.sessionSecret ||
|
|
1592
|
+
process.env.IRI_SHIELD_DASHBOARD_SECRET ||
|
|
1593
|
+
process.env.SESSION_SECRET ||
|
|
1594
|
+
process.env.JWT_SECRET ||
|
|
1595
|
+
`${username}:${password}`;
|
|
1596
|
+
const token = createHmac('sha256', String(sessionSecret))
|
|
1597
|
+
.update(`iri-shield-dashboard:${username}:${password}`)
|
|
1598
|
+
.digest('hex');
|
|
1565
1599
|
return {
|
|
1566
1600
|
token,
|
|
1567
1601
|
canLogin(inputUser, inputPass) {
|
|
@@ -1573,6 +1607,20 @@ function dashboardAuth(options = {}) {
|
|
|
1573
1607
|
};
|
|
1574
1608
|
}
|
|
1575
1609
|
|
|
1610
|
+
function asyncRoute(handler) {
|
|
1611
|
+
return function routeHandler(req, res, next) {
|
|
1612
|
+
Promise.resolve(handler(req, res, next)).catch(next);
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
async function callStorage(storage, method, ...args) {
|
|
1617
|
+
if (storage.ready && typeof storage.ready.then === 'function') {
|
|
1618
|
+
await storage.ready;
|
|
1619
|
+
}
|
|
1620
|
+
const result = storage[method](...args);
|
|
1621
|
+
return result && typeof result.then === 'function' ? await result : result;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1576
1624
|
function applyDashboardSettings(config, body) {
|
|
1577
1625
|
const numberFields = [
|
|
1578
1626
|
['rateLimit.max', 1, 100000],
|
|
@@ -1626,6 +1674,7 @@ function applyDashboardSettings(config, body) {
|
|
|
1626
1674
|
function publicConfig(config) {
|
|
1627
1675
|
const copy = JSON.parse(JSON.stringify(config));
|
|
1628
1676
|
if (copy.dashboard?.password) copy.dashboard.password = '';
|
|
1677
|
+
if (copy.dashboard?.sessionSecret) copy.dashboard.sessionSecret = '';
|
|
1629
1678
|
return copy;
|
|
1630
1679
|
}
|
|
1631
1680
|
|
package/src/index.js
CHANGED
|
@@ -651,6 +651,7 @@ function mergeInto(target, patch) {
|
|
|
651
651
|
function sanitizeConfig(config) {
|
|
652
652
|
const copy = JSON.parse(JSON.stringify(config));
|
|
653
653
|
if (copy.dashboard && copy.dashboard.password) copy.dashboard.password = '';
|
|
654
|
+
if (copy.dashboard && copy.dashboard.sessionSecret) copy.dashboard.sessionSecret = '';
|
|
654
655
|
return copy;
|
|
655
656
|
}
|
|
656
657
|
|
package/src/mongodb-storage.js
CHANGED
|
@@ -19,8 +19,8 @@ class MongoStorage extends MemoryStorage {
|
|
|
19
19
|
this.db = null;
|
|
20
20
|
this._cols = {};
|
|
21
21
|
|
|
22
|
-
// Start connection asynchronously — does not block constructor
|
|
23
|
-
this._connect();
|
|
22
|
+
// Start connection asynchronously — does not block constructor.
|
|
23
|
+
this.ready = this._connect();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
async _connect() {
|
|
@@ -47,8 +47,8 @@ class MongoStorage extends MemoryStorage {
|
|
|
47
47
|
// Initialize collections and TTL indexes
|
|
48
48
|
await this._initCollections();
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
await this.
|
|
50
|
+
// Warm the in-memory dashboard cache after cold starts.
|
|
51
|
+
await this._loadAllPersistentData();
|
|
52
52
|
|
|
53
53
|
console.log('[iri-shield] MongoDB connected:', this.mongoUrl);
|
|
54
54
|
} catch (err) {
|
|
@@ -86,6 +86,14 @@ class MongoStorage extends MemoryStorage {
|
|
|
86
86
|
this._cols = { requests, events, clients, blocks, alerts };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async _loadAllPersistentData() {
|
|
90
|
+
await this._loadPersistentBlocks();
|
|
91
|
+
await this._loadPersistentClients();
|
|
92
|
+
await this._loadPersistentAlerts();
|
|
93
|
+
await this._loadPersistentEvents();
|
|
94
|
+
await this._loadPersistentRequests();
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
async _loadPersistentBlocks() {
|
|
90
98
|
if (!this.connected) return;
|
|
91
99
|
try {
|
|
@@ -104,6 +112,86 @@ class MongoStorage extends MemoryStorage {
|
|
|
104
112
|
} catch { /* ignore */ }
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
async _loadPersistentClients() {
|
|
116
|
+
if (!this.connected) return;
|
|
117
|
+
try {
|
|
118
|
+
const rows = await this._cols.clients.find({}).sort({ requestCount: -1 }).limit(this.maxClients).toArray();
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
if (row.clientId) this.clients.set(row.clientId, stripMongoId(row));
|
|
121
|
+
}
|
|
122
|
+
} catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async _loadPersistentAlerts() {
|
|
126
|
+
if (!this.connected) return;
|
|
127
|
+
try {
|
|
128
|
+
const rows = await this._cols.alerts.find({}).sort({ lastAlertAt: -1 }).limit(this.maxClients).toArray();
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
if (row.clientId) this.alerts.set(row.clientId, stripMongoId(row));
|
|
131
|
+
}
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async _loadPersistentEvents() {
|
|
136
|
+
if (!this.connected) return;
|
|
137
|
+
try {
|
|
138
|
+
const rows = await this._cols.events.find({}).sort({ timestamp: -1, createdAt: -1 }).limit(this.maxEvents).toArray();
|
|
139
|
+
this.events = rows.map(stripMongoId);
|
|
140
|
+
this.threatDistribution = new Map();
|
|
141
|
+
for (const event of this.events) {
|
|
142
|
+
const threat = event.threat || 'unknown';
|
|
143
|
+
this.threatDistribution.set(threat, (this.threatDistribution.get(threat) || 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _loadPersistentRequests() {
|
|
149
|
+
if (!this.connected) return;
|
|
150
|
+
try {
|
|
151
|
+
const rows = await this._cols.requests.find({}).sort({ createdAt: -1 }).limit(this.maxRequests).toArray();
|
|
152
|
+
this.requests = rows.map(stripMongoId);
|
|
153
|
+
const totals = await this._cols.requests.aggregate([
|
|
154
|
+
{
|
|
155
|
+
$group: {
|
|
156
|
+
_id: null,
|
|
157
|
+
totalRequests: { $sum: 1 },
|
|
158
|
+
blockedRequests: { $sum: { $cond: ['$blocked', 1, 0] } },
|
|
159
|
+
totalLatencyMs: { $sum: { $ifNull: ['$durationMs', 0] } }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]).next();
|
|
163
|
+
this.totalRequests = totals?.totalRequests || this.requests.length;
|
|
164
|
+
this.blockedRequests = totals?.blockedRequests || 0;
|
|
165
|
+
this.totalLatencyMs = totals?.totalLatencyMs || 0;
|
|
166
|
+
|
|
167
|
+
const endpoints = await this._cols.requests.aggregate([
|
|
168
|
+
{
|
|
169
|
+
$group: {
|
|
170
|
+
_id: { method: '$method', endpoint: '$endpoint' },
|
|
171
|
+
count: { $sum: 1 },
|
|
172
|
+
errors: { $sum: { $cond: [{ $gte: ['$statusCode', 400] }, 1, 0] } },
|
|
173
|
+
avgLatencyMs: { $avg: '$durationMs' }
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{ $sort: { count: -1 } },
|
|
177
|
+
{ $limit: 20 }
|
|
178
|
+
]).toArray();
|
|
179
|
+
|
|
180
|
+
this.endpointStats = new Map();
|
|
181
|
+
for (const row of endpoints) {
|
|
182
|
+
const method = row._id?.method || 'GET';
|
|
183
|
+
const endpoint = row._id?.endpoint || '/';
|
|
184
|
+
this.endpointStats.set(`${method} ${endpoint}`, {
|
|
185
|
+
method,
|
|
186
|
+
endpoint,
|
|
187
|
+
count: row.count || 0,
|
|
188
|
+
errors: row.errors || 0,
|
|
189
|
+
avgLatencyMs: row.avgLatencyMs || 0
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
107
195
|
// ---------------------------------------------------------------------------
|
|
108
196
|
// Helpers
|
|
109
197
|
// ---------------------------------------------------------------------------
|
|
@@ -120,9 +208,11 @@ class MongoStorage extends MemoryStorage {
|
|
|
120
208
|
|
|
121
209
|
recordRequest(request) {
|
|
122
210
|
super.recordRequest(request);
|
|
211
|
+
const timestamp = new Date().toISOString();
|
|
123
212
|
this._safe(() =>
|
|
124
213
|
this._cols.requests.insertOne({
|
|
125
214
|
...request,
|
|
215
|
+
timestamp,
|
|
126
216
|
createdAt: new Date(),
|
|
127
217
|
blocked: Boolean(request.blocked)
|
|
128
218
|
})
|
|
@@ -220,6 +310,62 @@ class MongoStorage extends MemoryStorage {
|
|
|
220
310
|
return result;
|
|
221
311
|
}
|
|
222
312
|
|
|
313
|
+
async getEvents({ page = 1, perPage = 20, riskLevel = null } = {}) {
|
|
314
|
+
if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
|
|
315
|
+
await this.ready;
|
|
316
|
+
if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
|
|
317
|
+
try {
|
|
318
|
+
const filter = riskLevel ? { riskLevel } : {};
|
|
319
|
+
const total = await this._cols.events.countDocuments(filter);
|
|
320
|
+
const data = await this._cols.events
|
|
321
|
+
.find(filter)
|
|
322
|
+
.sort({ timestamp: -1, createdAt: -1 })
|
|
323
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
324
|
+
.limit(perPage)
|
|
325
|
+
.toArray();
|
|
326
|
+
return paginateMongo(data, total, page, perPage);
|
|
327
|
+
} catch {
|
|
328
|
+
return super.getEvents({ page, perPage, riskLevel });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async getClients({ page = 1, perPage = 20 } = {}) {
|
|
333
|
+
if (!this.connected) return super.getClients({ page, perPage });
|
|
334
|
+
await this.ready;
|
|
335
|
+
if (!this.connected) return super.getClients({ page, perPage });
|
|
336
|
+
try {
|
|
337
|
+
const total = await this._cols.clients.countDocuments({});
|
|
338
|
+
const data = await this._cols.clients
|
|
339
|
+
.find({})
|
|
340
|
+
.sort({ requestCount: -1, lastSeenAt: -1 })
|
|
341
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
342
|
+
.limit(perPage)
|
|
343
|
+
.toArray();
|
|
344
|
+
return paginateMongo(data, total, page, perPage);
|
|
345
|
+
} catch {
|
|
346
|
+
return super.getClients({ page, perPage });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async getAlerts({ page = 1, perPage = 20, dismissed = false } = {}) {
|
|
351
|
+
if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
|
|
352
|
+
await this.ready;
|
|
353
|
+
if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
|
|
354
|
+
try {
|
|
355
|
+
const filter = { dismissed: Boolean(dismissed) };
|
|
356
|
+
const total = await this._cols.alerts.countDocuments(filter);
|
|
357
|
+
const data = await this._cols.alerts
|
|
358
|
+
.find(filter)
|
|
359
|
+
.sort({ lastAlertAt: -1, updatedAt: -1 })
|
|
360
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
361
|
+
.limit(perPage)
|
|
362
|
+
.toArray();
|
|
363
|
+
return paginateMongo(data, total, page, perPage);
|
|
364
|
+
} catch {
|
|
365
|
+
return super.getAlerts({ page, perPage, dismissed });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
223
369
|
// ---------------------------------------------------------------------------
|
|
224
370
|
// Stats
|
|
225
371
|
// ---------------------------------------------------------------------------
|
|
@@ -235,3 +381,21 @@ class MongoStorage extends MemoryStorage {
|
|
|
235
381
|
}
|
|
236
382
|
|
|
237
383
|
module.exports = { MongoStorage };
|
|
384
|
+
|
|
385
|
+
function stripMongoId(row) {
|
|
386
|
+
if (!row) return row;
|
|
387
|
+
const { _id, ...rest } = row;
|
|
388
|
+
return rest;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function paginateMongo(rows, total, page, perPage) {
|
|
392
|
+
const totalPages = Math.ceil(total / perPage) || 1;
|
|
393
|
+
const safePage = Math.max(1, Math.min(page, totalPages));
|
|
394
|
+
return {
|
|
395
|
+
data: rows.map(stripMongoId),
|
|
396
|
+
total,
|
|
397
|
+
page: safePage,
|
|
398
|
+
perPage,
|
|
399
|
+
totalPages
|
|
400
|
+
};
|
|
401
|
+
}
|