@abtnode/core 1.16.38 → 1.16.39-beta-20250209-032436-5ceca2cb

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.
@@ -537,6 +537,7 @@ module.exports = Object.freeze({
537
537
  WELLKNOWN_PATH_PREFIX: '/.well-known',
538
538
  WELLKNOWN_ACME_CHALLENGE_PREFIX: '/.well-known/acme-challenge',
539
539
  WELLKNOWN_DID_RESOLVER_PREFIX: '/.well-known/did.json', // server wellknown endpoint
540
+ WELLKNOWN_BLACKLIST_PREFIX: '/.well-known/blacklist',
540
541
  WELLKNOWN_PING_PREFIX: '/.well-known/ping',
541
542
  WELLKNOWN_ANALYTICS_PREFIX: '/.well-known/analytics',
542
543
  WELLKNOWN_SERVICE_PATH_PREFIX: '/.well-known/service',
@@ -747,6 +748,10 @@ module.exports = Object.freeze({
747
748
  PUSH: 'push',
748
749
  WEBHOOK: 'webhook',
749
750
  },
751
+
752
+ BLACKLIST_SCOPE: {
753
+ ROUTER: 'router',
754
+ },
750
755
  });
751
756
 
752
757
 
@@ -38649,7 +38654,7 @@ module.exports = require("zlib");
38649
38654
  /***/ ((module) => {
38650
38655
 
38651
38656
  "use strict";
38652
- module.exports = /*#__PURE__*/JSON.parse('{"name":"@abtnode/core","publishConfig":{"access":"public"},"version":"1.16.37","description":"","main":"lib/index.js","files":["lib"],"scripts":{"lint":"eslint tests lib --ignore-pattern \'tests/assets/*\'","lint:fix":"eslint --fix tests lib","test":"node tools/jest.js","coverage":"npm run test -- --coverage"},"keywords":[],"author":"wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)","license":"Apache-2.0","dependencies":{"@abtnode/analytics":"1.16.37","@abtnode/auth":"1.16.37","@abtnode/certificate-manager":"1.16.37","@abtnode/constant":"1.16.37","@abtnode/cron":"1.16.37","@abtnode/docker-utils":"1.16.37","@abtnode/logger":"1.16.37","@abtnode/models":"1.16.37","@abtnode/queue":"1.16.37","@abtnode/rbac":"1.16.37","@abtnode/router-provider":"1.16.37","@abtnode/static-server":"1.16.37","@abtnode/timemachine":"1.16.37","@abtnode/util":"1.16.37","@arcblock/did":"1.19.9","@arcblock/did-auth":"1.19.9","@arcblock/did-ext":"^1.19.9","@arcblock/did-motif":"^1.1.13","@arcblock/did-util":"1.19.9","@arcblock/event-hub":"1.19.9","@arcblock/jwt":"^1.19.9","@arcblock/pm2-events":"^0.0.5","@arcblock/validator":"^1.19.9","@arcblock/vc":"1.19.9","@blocklet/constant":"1.16.37","@blocklet/did-space-js":"^1.0.9","@blocklet/env":"1.16.37","@blocklet/meta":"1.16.37","@blocklet/resolver":"1.16.37","@blocklet/sdk":"1.16.37","@blocklet/store":"1.16.37","@fidm/x509":"^1.2.1","@ocap/mcrypto":"1.19.9","@ocap/util":"1.19.9","@ocap/wallet":"1.19.9","@slack/webhook":"^5.0.4","archiver":"^7.0.1","axios":"^1.7.9","axon":"^2.0.3","chalk":"^4.1.2","cross-spawn":"^7.0.3","deep-diff":"^1.0.2","detect-port":"^1.5.1","envfile":"^7.1.0","escape-string-regexp":"^4.0.0","fast-glob":"^3.3.2","filesize":"^10.1.1","flat":"^5.0.2","fs-extra":"^11.2.0","get-port":"^5.1.1","hasha":"^5.2.2","is-base64":"^1.1.0","is-cidr":"4","is-ip":"3","is-url":"^1.2.4","joi":"17.12.2","joi-extension-semver":"^5.0.0","js-yaml":"^4.1.0","kill-port":"^2.0.1","lodash":"^4.17.21","lru-cache":"^11.0.2","node-stream-zip":"^1.15.0","p-all":"^3.0.0","p-limit":"^3.1.0","p-map":"^4.0.0","p-retry":"^4.6.2","read-last-lines":"^1.8.0","semver":"^7.6.3","sequelize":"^6.35.0","shelljs":"^0.8.5","ssri":"^8.0.1","stream-throttle":"^0.1.3","stream-to-promise":"^3.0.0","systeminformation":"^5.23.3","tail":"^2.2.4","tar":"^6.1.11","transliteration":"^2.3.5","ua-parser-js":"^1.0.2","ufo":"^1.5.3","uuid":"^9.0.1","valid-url":"^1.0.9","which":"^2.0.2","xbytes":"^1.8.0"},"devDependencies":{"expand-tilde":"^2.0.2","express":"^4.18.2","jest":"^29.7.0","unzipper":"^0.10.11"},"gitHead":"e5764f753181ed6a7c615cd4fc6682aacf0cb7cd"}');
38657
+ module.exports = /*#__PURE__*/JSON.parse('{"name":"@abtnode/core","publishConfig":{"access":"public"},"version":"1.16.38","description":"","main":"lib/index.js","files":["lib"],"scripts":{"lint":"eslint tests lib --ignore-pattern \'tests/assets/*\'","lint:fix":"eslint --fix tests lib","test":"node tools/jest.js","coverage":"npm run test -- --coverage"},"keywords":[],"author":"wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)","license":"Apache-2.0","dependencies":{"@abtnode/analytics":"1.16.38","@abtnode/auth":"1.16.38","@abtnode/certificate-manager":"1.16.38","@abtnode/constant":"1.16.38","@abtnode/cron":"1.16.38","@abtnode/docker-utils":"1.16.38","@abtnode/logger":"1.16.38","@abtnode/models":"1.16.38","@abtnode/queue":"1.16.38","@abtnode/rbac":"1.16.38","@abtnode/router-provider":"1.16.38","@abtnode/static-server":"1.16.38","@abtnode/timemachine":"1.16.38","@abtnode/util":"1.16.38","@arcblock/did":"1.19.9","@arcblock/did-auth":"1.19.9","@arcblock/did-ext":"^1.19.9","@arcblock/did-motif":"^1.1.13","@arcblock/did-util":"1.19.9","@arcblock/event-hub":"1.19.9","@arcblock/jwt":"^1.19.9","@arcblock/pm2-events":"^0.0.5","@arcblock/validator":"^1.19.9","@arcblock/vc":"1.19.9","@blocklet/constant":"1.16.38","@blocklet/did-space-js":"^1.0.9","@blocklet/env":"1.16.38","@blocklet/meta":"1.16.38","@blocklet/resolver":"1.16.38","@blocklet/sdk":"1.16.38","@blocklet/store":"1.16.38","@fidm/x509":"^1.2.1","@ocap/mcrypto":"1.19.9","@ocap/util":"1.19.9","@ocap/wallet":"1.19.9","@slack/webhook":"^5.0.4","archiver":"^7.0.1","axios":"^1.7.9","axon":"^2.0.3","chalk":"^4.1.2","cross-spawn":"^7.0.3","deep-diff":"^1.0.2","detect-port":"^1.5.1","envfile":"^7.1.0","escape-string-regexp":"^4.0.0","fast-glob":"^3.3.2","filesize":"^10.1.1","flat":"^5.0.2","rate-limiter-flexible":"^5.0.5","fs-extra":"^11.2.0","get-port":"^5.1.1","hasha":"^5.2.2","is-base64":"^1.1.0","is-cidr":"4","is-ip":"3","is-url":"^1.2.4","joi":"17.12.2","joi-extension-semver":"^5.0.0","js-yaml":"^4.1.0","kill-port":"^2.0.1","lodash":"^4.17.21","lru-cache":"^11.0.2","node-stream-zip":"^1.15.0","p-all":"^3.0.0","p-limit":"^3.1.0","p-map":"^4.0.0","p-retry":"^4.6.2","read-last-lines":"^1.8.0","semver":"^7.6.3","sequelize":"^6.35.0","shelljs":"^0.8.5","ssri":"^8.0.1","stream-throttle":"^0.1.3","stream-to-promise":"^3.0.0","systeminformation":"^5.23.3","tail":"^2.2.4","tar":"^6.1.11","transliteration":"^2.3.5","ua-parser-js":"^1.0.2","ufo":"^1.5.3","uuid":"^9.0.1","valid-url":"^1.0.9","which":"^2.0.2","xbytes":"^1.8.0"},"devDependencies":{"expand-tilde":"^2.0.2","express":"^4.18.2","jest":"^29.7.0","unzipper":"^0.10.11"},"gitHead":"e5764f753181ed6a7c615cd4fc6682aacf0cb7cd"}');
38653
38658
 
38654
38659
  /***/ }),
38655
38660
 
package/lib/index.js CHANGED
@@ -42,6 +42,7 @@ const getMetaFromUrl = require('./util/get-meta-from-url');
42
42
  const getDynamicComponents = require('./util/get-dynamic-components');
43
43
  const SecurityAPI = require('./blocklet/security');
44
44
  const dockerRestartAllContainers = require('./util/docker/docker-restart-all-containers');
45
+ const RouterBlocker = require('./router/security/blocker');
45
46
 
46
47
  /**
47
48
  * @typedef {{} & import('./api/team')}} TNode
@@ -275,6 +276,7 @@ function ABTNode(options) {
275
276
  deleteRoutingRule,
276
277
  addDomainAlias,
277
278
  deleteDomainAlias,
279
+ getGatewayBlacklist,
278
280
  } = getRouterHelpers({
279
281
  dataDirs,
280
282
  routingSnapshot,
@@ -590,6 +592,7 @@ function ABTNode(options) {
590
592
  addDomainAlias,
591
593
  deleteDomainAlias,
592
594
  isDidDomain: routerManager.isDidDomain.bind(routerManager),
595
+ getGatewayBlacklist,
593
596
 
594
597
  getRoutingProviders: () => listProviders(dataDirs.router),
595
598
  checkDomains: domainStatus.checkDomainsStatus.bind(domainStatus),
@@ -704,6 +707,7 @@ function ABTNode(options) {
704
707
  LogRotator.getCron(),
705
708
  ...getStateCrons(states),
706
709
  ...nodeAPI.getCrons(),
710
+ ...RouterBlocker.getSecurityCrons(),
707
711
  ].filter((x) => options.daemon || (options.service && get(x, 'options.runInService'))),
708
712
  onError: (error, name) => {
709
713
  states.notification.create({
@@ -9,7 +9,9 @@ const isUrl = require('is-url');
9
9
  const get = require('lodash/get');
10
10
  const cloneDeep = require('lodash/cloneDeep');
11
11
  const groupBy = require('lodash/groupBy');
12
+ const uniq = require('lodash/uniq');
12
13
  const isEqual = require('lodash/isEqual');
14
+ const countBy = require('lodash/countBy');
13
15
  const { joinURL } = require('ufo');
14
16
  const {
15
17
  replaceSlotToIp,
@@ -47,6 +49,7 @@ const {
47
49
  LOG_RETAIN_IN_DAYS,
48
50
  EVENTS,
49
51
  DEFAULT_IP_DOMAIN,
52
+ WELLKNOWN_BLACKLIST_PREFIX,
50
53
  } = require('@abtnode/constant');
51
54
  const {
52
55
  BLOCKLET_DYNAMIC_PATH_PREFIX,
@@ -75,7 +78,10 @@ const { getFromCache: getAccessibleExternalNodeIp } = require('../util/get-acces
75
78
 
76
79
  const Router = require('./index');
77
80
  const states = require('../states');
78
- const monitor = require('../util/monitor');
81
+ const monitor = require('./monitor');
82
+ const scanner = require('./security/scanner');
83
+ const blocker = require('./security/blocker');
84
+ const { createLimiter } = require('./security/limiter');
79
85
  const {
80
86
  getBlockletDomainGroupName,
81
87
  getDidFromDomainGroupName,
@@ -840,6 +846,12 @@ module.exports = function getRouterHelpers({
840
846
  to: proxyTarget,
841
847
  };
842
848
 
849
+ const blacklistWellknownRule = {
850
+ isProtected: true,
851
+ from: { pathPrefix: WELLKNOWN_BLACKLIST_PREFIX },
852
+ to: proxyTarget,
853
+ };
854
+
843
855
  const acmeChallengeWellknownRule = {
844
856
  isProtected: true,
845
857
  from: { pathPrefix: WELLKNOWN_ACME_CHALLENGE_PREFIX },
@@ -849,9 +861,10 @@ module.exports = function getRouterHelpers({
849
861
  if (site) {
850
862
  const didResolverRuleUpdateRes = await upsertSiteRule({ site, rule: didResolverWellknownRule }, context);
851
863
  const acmeRuleUpdateRes = await upsertSiteRule({ site, rule: acmeChallengeWellknownRule }, context);
864
+ const blacklistUpdateRes = await upsertSiteRule({ site, rule: blacklistWellknownRule }, context);
852
865
  const pingRuleRes = await upsertSiteRule({ site, rule: pingWellknownRule }, context);
853
866
  const analyticsRuleRes = await upsertSiteRule({ site, rule: analyticsWellknownRule }, context);
854
- return didResolverRuleUpdateRes || acmeRuleUpdateRes || pingRuleRes || analyticsRuleRes;
867
+ return didResolverRuleUpdateRes || acmeRuleUpdateRes || blacklistUpdateRes || pingRuleRes || analyticsRuleRes;
855
868
  }
856
869
 
857
870
  await routerManager.addRoutingSite(
@@ -860,7 +873,13 @@ module.exports = function getRouterHelpers({
860
873
  domain: DOMAIN_FOR_INTERNAL_SITE,
861
874
  port: await getWellknownSitePort(),
862
875
  name: NAME_FOR_WELLKNOWN_SITE,
863
- rules: [didResolverWellknownRule, acmeChallengeWellknownRule, pingWellknownRule, analyticsWellknownRule],
876
+ rules: [
877
+ didResolverWellknownRule,
878
+ acmeChallengeWellknownRule,
879
+ blacklistWellknownRule,
880
+ pingWellknownRule,
881
+ analyticsWellknownRule,
882
+ ],
864
883
  isProtected: true,
865
884
  },
866
885
  skipCheckDynamicBlacklist: true,
@@ -1302,6 +1321,81 @@ module.exports = function getRouterHelpers({
1302
1321
  }
1303
1322
 
1304
1323
  const providers = {}; // we need to keep reference for different router instances
1324
+
1325
+ const startAccessLogWatcher = (info) => {
1326
+ const providerName = get(info, 'routing.provider', null);
1327
+ if (!providerName || !providers[providerName]) {
1328
+ return;
1329
+ }
1330
+
1331
+ if (daemon && process.env.ABT_NODE_MONITOR_GATEWAY_5XX === '1') {
1332
+ monitor.stopLogWatcher();
1333
+ const logs = providers[providerName].getLogFilesForToday();
1334
+ monitor.startLogWatcher(logs.access, (logEntries) => {
1335
+ notification.create({
1336
+ title: 'Server 5xx Alert',
1337
+ description: `5xx request detected: ${JSON.stringify(logEntries.slice(0, 1), null, 2)}`,
1338
+ entityType: 'server',
1339
+ severity: 'error',
1340
+ sticky: true,
1341
+ });
1342
+ });
1343
+ }
1344
+ };
1345
+
1346
+ const startErrorLogWatcher = async (info, shouldScheduleReload = false) => {
1347
+ const providerName = get(info, 'routing.provider', null);
1348
+ if (!providerName || !providers[providerName]) {
1349
+ return;
1350
+ }
1351
+
1352
+ if (daemon && info.routing.blockPolicy?.autoBlocking?.enabled && providers[providerName].supportsModSecurity()) {
1353
+ scanner.stopLogWatcher();
1354
+ const logs = providers[providerName].getLogFilesForToday();
1355
+ const limiter = createLimiter(info.routing.blockPolicy.autoBlocking);
1356
+ scanner.startLogWatcher(logs.error, async (logEntries) => {
1357
+ const blocked = [];
1358
+ const grouped = countBy(logEntries, 'ip');
1359
+ for (const [ip, count] of Object.entries(grouped)) {
1360
+ // eslint-disable-next-line no-await-in-loop
1361
+ const timeout = await limiter.check(ip, count);
1362
+ if (timeout > 0) {
1363
+ blocked.push(timeout);
1364
+ }
1365
+ }
1366
+
1367
+ logger.debug('router error detected', { logEntries, grouped, blocked, timestamp: dayjs().unix() });
1368
+
1369
+ if (blocked.length) {
1370
+ providers[providerName].throttledReload();
1371
+ // Reload 1 second after block expiration
1372
+ uniq(blocked).forEach((timeout) => {
1373
+ setTimeout(() => {
1374
+ logger.info('router reload on block expire', { timeout, timestamp: dayjs().unix() });
1375
+ providers[providerName].throttledReload();
1376
+ }, timeout + 1000);
1377
+ });
1378
+ }
1379
+ });
1380
+ }
1381
+
1382
+ // Schedule router reload for active blacklist
1383
+ if (daemon && shouldScheduleReload) {
1384
+ const blacklist = await blocker.getActiveBlacklist(false);
1385
+ if (blacklist.length) {
1386
+ logger.info('schedule router reload for active blacklist', blacklist);
1387
+ const now = dayjs().unix();
1388
+ blacklist.forEach((x) => {
1389
+ const timeout = x.expiresAt - now + 1;
1390
+ setTimeout(() => {
1391
+ logger.info('router reload on block expire', { ip: x.key });
1392
+ providers[providerName].throttledReload();
1393
+ }, timeout * 1000);
1394
+ });
1395
+ }
1396
+ }
1397
+ };
1398
+
1305
1399
  const handleRouting = async (nodeInfo) => {
1306
1400
  const now = Date.now();
1307
1401
  logger.info('start handle routing');
@@ -1315,6 +1409,8 @@ module.exports = function getRouterHelpers({
1315
1409
  throw new Error(`${providerName} pre-check failed, ${checkResult.error}`);
1316
1410
  }
1317
1411
 
1412
+ const shouldScheduleReload = !providers[providerName];
1413
+
1318
1414
  if (providers[providerName]) {
1319
1415
  await providers[providerName].reload();
1320
1416
  } else {
@@ -1364,19 +1460,8 @@ module.exports = function getRouterHelpers({
1364
1460
  await providers[providerName].start();
1365
1461
  }
1366
1462
 
1367
- if (daemon && process.env.ABT_NODE_MONITOR_GATEWAY_5XX === '1') {
1368
- monitor.stopLogWatcher();
1369
- const logs = providers[providerName].getLogFilesForToday();
1370
- monitor.startLogWatcher(logs.access, (logEntries) => {
1371
- notification.create({
1372
- title: 'Server 5xx Alert',
1373
- description: `5xx request detected: ${JSON.stringify(logEntries.slice(0, 1), null, 2)}`,
1374
- entityType: 'server',
1375
- severity: 'error',
1376
- sticky: true,
1377
- });
1378
- });
1379
- }
1463
+ await startAccessLogWatcher(nodeInfo);
1464
+ await startErrorLogWatcher(nodeInfo, shouldScheduleReload);
1380
1465
 
1381
1466
  logger.info('done handle routing', { cost: Date.now() - now });
1382
1467
  };
@@ -1386,20 +1471,9 @@ module.exports = function getRouterHelpers({
1386
1471
  const providerName = get(info, 'routing.provider', null);
1387
1472
 
1388
1473
  if (providerName && providers[providerName] && typeof providers[providerName].rotateLogs === 'function') {
1389
- monitor.stopLogWatcher();
1390
1474
  await providers[providerName].rotateLogs({ retain: LOG_RETAIN_IN_DAYS });
1391
- if (daemon && process.env.ABT_NODE_MONITOR_GATEWAY_5XX === '1') {
1392
- const logs = providers[providerName].getLogFilesForToday();
1393
- monitor.startLogWatcher(logs.access, (logEntries) => {
1394
- notification.create({
1395
- title: 'Server 5xx Alert',
1396
- description: `5xx request detected: ${JSON.stringify(logEntries.slice(0, 1), null, 2)}`,
1397
- entityType: 'server',
1398
- severity: 'error',
1399
- sticky: true,
1400
- });
1401
- });
1402
- }
1475
+ await startAccessLogWatcher(info);
1476
+ await startErrorLogWatcher(info);
1403
1477
  }
1404
1478
  };
1405
1479
 
@@ -1692,6 +1766,21 @@ module.exports = function getRouterHelpers({
1692
1766
  // @ts-ignore
1693
1767
  const gatewayBlacklistRefreshInterval = +process.env.ABT_NODE_BLACKLIST_REFRESH_INTERVAL || 2;
1694
1768
 
1769
+ const getGatewayBlacklist = async (type = 'both') => {
1770
+ const info = await nodeState.read();
1771
+ const blockPolicy = info.routing.blockPolicy || { enabled: false, blacklist: [] };
1772
+ const blacklist = await expandBlacklist(blockPolicy.blacklist);
1773
+ const blockedIps = await blocker.getActiveBlacklist();
1774
+ if (type === 'both') {
1775
+ return uniq([...blacklist, ...blockedIps]);
1776
+ }
1777
+ if (type === 'dynamic') {
1778
+ return uniq([...blockedIps]);
1779
+ }
1780
+
1781
+ return uniq([...blacklist]);
1782
+ };
1783
+
1695
1784
  return {
1696
1785
  ensureDashboardRouting,
1697
1786
  ensureBlockletRouting,
@@ -1748,6 +1837,7 @@ module.exports = function getRouterHelpers({
1748
1837
  addDomainAlias,
1749
1838
  deleteDomainAlias,
1750
1839
  isGatewayCacheEnabled,
1840
+ getGatewayBlacklist,
1751
1841
  };
1752
1842
  };
1753
1843
 
@@ -1,4 +1,6 @@
1
1
  const get = require('lodash/get');
2
+ const uniq = require('lodash/uniq');
3
+ const throttle = require('lodash/throttle');
2
4
  const pick = require('lodash/pick');
3
5
  const isEqual = require('lodash/isEqual');
4
6
  const cloneDeep = require('lodash/cloneDeep');
@@ -17,6 +19,7 @@ const logger = require('@abtnode/logger')('@abtnode/core:router');
17
19
 
18
20
  const { isGatewayCacheEnabled, isBlockletSite } = require('../util');
19
21
  const { expandBlacklist } = require('../util/router');
22
+ const { getActiveBlacklist } = require('./security/blocker');
20
23
  const IP = require('../util/ip');
21
24
 
22
25
  const isServiceFeDevelopment = process.env.ABT_NODE_SERVICE_FE_PORT;
@@ -101,6 +104,9 @@ class Router {
101
104
  this.provider = provider;
102
105
  this.getRoutingParams = getRoutingParams;
103
106
  this.routingTable = [];
107
+
108
+ this.throttledReload = throttle(() => this.reload(), 5000, { leading: true, trailing: true });
109
+ this.throttledRestart = throttle(() => this.restart(), 5000, { leading: true, trailing: true });
104
110
  }
105
111
 
106
112
  async updateRoutingTable() {
@@ -128,6 +134,12 @@ class Router {
128
134
  if (result?.internal) {
129
135
  blockPolicy.blacklist = blockPolicy.blacklist.filter((x) => x !== result.internal);
130
136
  }
137
+
138
+ // Append blocked ips from database
139
+ const blockedIps = await getActiveBlacklist();
140
+ logger.info('router auto blocked ips', blockedIps);
141
+
142
+ blockPolicy.blacklist = uniq([...blockPolicy.blacklist, ...blockedIps]);
131
143
  }
132
144
 
133
145
  const proxyPolicy = nodeInfo.routing.proxyPolicy || {
@@ -141,7 +153,7 @@ class Router {
141
153
  enabled: false,
142
154
  mode: 'DetectionOnly',
143
155
  inboundAnomalyScoreThreshold: 10,
144
- outboundAnomalyScoreThreshold: 4,
156
+ outboundAnomalyScoreThreshold: 10,
145
157
  };
146
158
 
147
159
  await this.provider.update({
@@ -212,6 +224,10 @@ class Router {
212
224
  clearCache(group) {
213
225
  return this.provider.clearCache(group);
214
226
  }
227
+
228
+ supportsModSecurity() {
229
+ return !!this.provider.capabilities?.modsecurity;
230
+ }
215
231
  }
216
232
 
217
233
  Router.formatSites = (sites = []) => {
@@ -0,0 +1,55 @@
1
+ const { createLogWatcher } = require('./watcher');
2
+
3
+ // Function to parse the log entry and extract relevant information
4
+ function parseLogEntry(line, check = true) {
5
+ const regex =
6
+ /^(\S+) - (\S+) \[([^\]]+)\] (\S+) "([^"]+)" "([^"]+)" (\d{3}) (\d+) "(.*?)" "(.*?)" "(.*?)" rt="(\S+)" uid="(.+?)" uos="(.+?)" uct="(\S+)" uht="(\S+)" urt="(\S+)"/;
7
+ const match = line.match(regex);
8
+ if (match) {
9
+ const logEntry = {
10
+ ip: match[1],
11
+ remoteUser: match[2],
12
+ timeIso8601: match[3],
13
+ requestId: match[4],
14
+ host: match[5],
15
+ request: match[6],
16
+ status: parseInt(match[7], 10),
17
+ bodyBytesSent: parseInt(match[8], 10),
18
+ referer: match[9],
19
+ userAgent: match[10],
20
+ forwardedFor: match[11],
21
+ requestTime: parseFloat(match[12]),
22
+ connectedDid: match[13],
23
+ connectedWalletOs: match[14],
24
+ upstreamConnectTime: parseFloat(match[15]),
25
+ upstreamHeaderTime: parseFloat(match[16]),
26
+ upstreamResponseTime: parseFloat(match[17]),
27
+ };
28
+
29
+ if (!check) {
30
+ return logEntry;
31
+ }
32
+
33
+ if (
34
+ logEntry.status >= 500 &&
35
+ logEntry.status !== 503 &&
36
+ logEntry.status <= 599 &&
37
+ logEntry.request.includes('/.well-known/did.json') === false &&
38
+ logEntry.request.includes('/websocket?token=') === false &&
39
+ logEntry.request.includes('/.well-known/service/health') === false
40
+ ) {
41
+ console.warn(`5xx request detected: ${logEntry.host}`, line);
42
+ return logEntry;
43
+ }
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ const logWatcher = createLogWatcher(parseLogEntry);
50
+
51
+ module.exports = {
52
+ parseLogEntry,
53
+ startLogWatcher: logWatcher.start,
54
+ stopLogWatcher: logWatcher.stop,
55
+ };
@@ -0,0 +1,27 @@
1
+ const { BLACKLIST_SCOPE } = require('@abtnode/constant');
2
+ const logger = require('@abtnode/logger')('@abtnode/core:router:security:blocker');
3
+
4
+ const states = require('../../states');
5
+
6
+ async function cleanupExpiredBlacklist() {
7
+ const result = await states.blacklist.removeExpiredByScope(BLACKLIST_SCOPE.ROUTER);
8
+ logger.info('Cleanup router blacklists', { result });
9
+ }
10
+
11
+ async function getActiveBlacklist(justIp = true) {
12
+ const result = await states.blacklist.findActiveByScope(BLACKLIST_SCOPE.ROUTER);
13
+ return justIp ? result.map((item) => item.key) : result;
14
+ }
15
+
16
+ module.exports = {
17
+ getActiveBlacklist,
18
+ cleanupExpiredBlacklist,
19
+ getSecurityCrons: () => [
20
+ {
21
+ name: 'cleanup-router-blacklist',
22
+ time: '0 0 * * * *', // refresh blocking list every hour
23
+ fn: cleanupExpiredBlacklist,
24
+ options: { runOnInit: process.env.ABT_NODE_JOB_NAME === 'cleanup-router-blacklist' },
25
+ },
26
+ ],
27
+ };
@@ -0,0 +1,54 @@
1
+ // FIXME: use a crash safe store for the limiter
2
+ const dayjs = require('@abtnode/util/lib/dayjs');
3
+ const { BLACKLIST_SCOPE } = require('@abtnode/constant');
4
+ const { RateLimiterMemory } = require('rate-limiter-flexible');
5
+
6
+ const logger = require('@abtnode/logger')('@abtnode/core:router:security:limiter');
7
+
8
+ const states = require('../../states');
9
+
10
+ /**
11
+ * Create a rate limiter for suspicious requests.
12
+ * https://github.com/animir/node-rate-limiter-flexible/wiki/Options
13
+ *
14
+ * @param {{
15
+ * windowSize: number;
16
+ * windowQuota: number;
17
+ * blockDuration: number;
18
+ * }} options
19
+ * @returns {{ check: (ip: string, points?: number) => Promise<number> }}
20
+ */
21
+ function createLimiter(options) {
22
+ const limiter = new RateLimiterMemory({
23
+ points: options.windowQuota,
24
+ duration: options.windowSize,
25
+ blockDuration: options.blockDuration,
26
+ });
27
+
28
+ logger.info('Rate limiter created', { options });
29
+
30
+ const check = async (ip, points = 1) => {
31
+ try {
32
+ const result = await limiter.consume(ip, points);
33
+ logger.debug('Rate limit not exceeded', { ip, result });
34
+ } catch (err) {
35
+ logger.info('Rate limit has exceeded', { ip, err });
36
+ const expiresAt = dayjs().add(err.msBeforeNext, 'ms').unix();
37
+ const added = await states.blacklist.addItem(BLACKLIST_SCOPE.ROUTER, ip, expiresAt);
38
+ if (added) {
39
+ logger.info('User IP blocked', { ip, expiresAt });
40
+ return err.msBeforeNext;
41
+ }
42
+ }
43
+
44
+ return 0;
45
+ };
46
+
47
+ return {
48
+ check,
49
+ };
50
+ }
51
+
52
+ module.exports = {
53
+ createLimiter,
54
+ };
@@ -0,0 +1,46 @@
1
+ const { createLogWatcher } = require('../watcher');
2
+
3
+ // Function to parse the log entry and extract relevant information
4
+ function parseLogEntry(line) {
5
+ // Quick check before regex
6
+ if (!line.includes('ModSecurity:')) {
7
+ return null;
8
+ }
9
+
10
+ const pattern =
11
+ /^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}).*\[error\].*\[client (\d+\.\d+\.\d+\.\d+)\].*ModSecurity:.*\[id "(\d+)"\].*\[unique_id "([^"]+)"\]/;
12
+
13
+ const match = line.match(pattern);
14
+ if (match) {
15
+ const logEntry = {
16
+ type: 'modsecurity',
17
+ timestamp: match[1],
18
+ ip: match[2],
19
+ ruleId: match[3],
20
+ requestId: match[4],
21
+ };
22
+
23
+ // Ignore old log entries
24
+ if (process.env.NODE_ENV === 'test') {
25
+ return logEntry;
26
+ }
27
+
28
+ const now = Date.now();
29
+ const timestamp = new Date(match[1]).getTime();
30
+ if (now - timestamp > 1000) {
31
+ return null;
32
+ }
33
+
34
+ return logEntry;
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ const logWatcher = createLogWatcher(parseLogEntry);
41
+
42
+ module.exports = {
43
+ parseLogEntry,
44
+ startLogWatcher: logWatcher.start,
45
+ stopLogWatcher: logWatcher.stop,
46
+ };
@@ -0,0 +1,135 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ const fs = require('fs');
3
+
4
+ const THROTTLE_DELAY = 1000; // 1 second throttle
5
+
6
+ async function readLogFile(filePath, startPosition = 0) {
7
+ let fd = null;
8
+ try {
9
+ if (!fs.existsSync(filePath)) {
10
+ return { lines: [], fileSize: 0 };
11
+ }
12
+
13
+ const realPath = await fs.promises.realpath(filePath);
14
+ fd = await fs.promises.open(realPath, 'r');
15
+ const stats = await fd.stat();
16
+ const fileSize = stats.size;
17
+
18
+ if (startPosition < 0) {
19
+ // eslint-disable-next-line no-param-reassign
20
+ startPosition = Math.max(0, fileSize + startPosition);
21
+ }
22
+
23
+ if (startPosition >= fileSize) {
24
+ return { lines: [], fileSize };
25
+ }
26
+
27
+ const readSize = fileSize - startPosition;
28
+ if (readSize <= 0) {
29
+ return { lines: [], fileSize };
30
+ }
31
+
32
+ const buffer = Buffer.alloc(readSize);
33
+ const { bytesRead } = await fd.read(buffer, 0, readSize, startPosition);
34
+ const content = buffer.slice(0, bytesRead).toString();
35
+
36
+ const lines = content
37
+ .split('\n')
38
+ .map((line) => line.trim())
39
+ .filter(Boolean);
40
+
41
+ return { lines, fileSize };
42
+ } finally {
43
+ if (fd) {
44
+ await fd.close();
45
+ }
46
+ }
47
+ }
48
+
49
+ // A generic log watcher that can be used for different log formats
50
+ function createLogWatcher(parseLogEntry, throttleDelay = THROTTLE_DELAY) {
51
+ let watcher;
52
+ process.on('SIGINT', () => {
53
+ watcher?.close();
54
+ });
55
+
56
+ process.on('SIGTERM', () => {
57
+ watcher?.close();
58
+ });
59
+
60
+ function start(logFilePath, onLogEntry, initialBufferSize = 1024) {
61
+ if (!fs.existsSync(logFilePath)) {
62
+ console.error(`Log file ${logFilePath} does not exist`);
63
+ return;
64
+ }
65
+
66
+ console.warn(`Start watching ${logFilePath}...`);
67
+
68
+ let isProcessing = false;
69
+ let isFirstRun = true;
70
+ let timeoutId = null;
71
+ let lastProcessedPosition = 0;
72
+
73
+ async function processLogChanges() {
74
+ isProcessing = true;
75
+ try {
76
+ const { lines, fileSize } = await readLogFile(
77
+ logFilePath,
78
+ isFirstRun ? -initialBufferSize : lastProcessedPosition
79
+ );
80
+ const entries = [];
81
+
82
+ for (const line of lines) {
83
+ const logEntry = parseLogEntry(line);
84
+ if (logEntry) {
85
+ entries.push(logEntry);
86
+ }
87
+ }
88
+
89
+ if (entries.length > 0) {
90
+ onLogEntry(entries);
91
+ }
92
+
93
+ lastProcessedPosition = fileSize;
94
+ isFirstRun = false;
95
+ } catch (error) {
96
+ console.error(`Error reading log file ${logFilePath}`, error);
97
+ } finally {
98
+ isProcessing = false;
99
+ }
100
+ }
101
+
102
+ watcher = fs.watch(logFilePath, { persistent: true }, async (eventType, filename) => {
103
+ if (!filename || eventType !== 'change') return;
104
+
105
+ // Clear any pending timeout
106
+ if (timeoutId) {
107
+ clearTimeout(timeoutId);
108
+ }
109
+
110
+ // If already processing, schedule for later
111
+ if (isProcessing) {
112
+ timeoutId = setTimeout(() => {
113
+ processLogChanges();
114
+ }, throttleDelay);
115
+ return;
116
+ }
117
+
118
+ await processLogChanges();
119
+ });
120
+ }
121
+
122
+ function stop() {
123
+ watcher?.close();
124
+ }
125
+
126
+ return {
127
+ start,
128
+ stop,
129
+ };
130
+ }
131
+
132
+ module.exports = {
133
+ createLogWatcher,
134
+ readLogFile,
135
+ };
@@ -5,6 +5,7 @@ const get = require('lodash/get');
5
5
  const uniq = require('lodash/uniq');
6
6
  const isEqual = require('lodash/isEqual');
7
7
  const { joinURL } = require('ufo');
8
+ const { Op } = require('sequelize');
8
9
  const { getDisplayName } = require('@blocklet/meta/lib/util');
9
10
  const { BLOCKLET_SITE_GROUP_SUFFIX, NODE_SERVICES, WHO_CAN_ACCESS } = require('@abtnode/constant');
10
11
  const logger = require('@abtnode/logger')('@abtnode/core:states:audit-log');
@@ -706,12 +707,37 @@ class AuditLogState extends BaseState {
706
707
 
707
708
  fixActor(user);
708
709
 
710
+ const content = (await getLogContent(action, args, context, result, info, node)).trim();
711
+ const actor = pick(user.actual || user, ['did', 'fullName', 'role']);
712
+
713
+ const teamDid = user.blockletDid || args.teamDid || get(args, 'did.0');
714
+ const userDid = user.did || args.userDid || get(args, 'user.did') || args.ownerDid;
715
+ if (teamDid && userDid && teamDid !== userDid) {
716
+ try {
717
+ const fullUser = await node.getUser({
718
+ teamDid,
719
+ user: { did: userDid },
720
+ options: {
721
+ enableConnectedAccount: true,
722
+ },
723
+ });
724
+ actor.avatar = fullUser?.avatar || '';
725
+ if (!actor.fullName) {
726
+ actor.fullName = fullUser?.fullName || '';
727
+ }
728
+ } catch (err) {
729
+ logger.error('get user info error', err);
730
+ }
731
+ } else {
732
+ actor.avatar = '';
733
+ }
734
+
709
735
  const data = await this.insert({
710
736
  scope: getScope(args) || info.did, // server or blocklet did
711
737
  action,
712
738
  category: await getLogCategory(action),
713
- content: (await getLogContent(action, args, context, result, info, node)).trim(),
714
- actor: pick(user.actual || user, ['did', 'fullName', 'role']),
739
+ content: content.slice(0, 10 * 1000),
740
+ actor,
715
741
  extra: args,
716
742
  env: pick(uaInfo, ['browser', 'os', 'device']),
717
743
  ip,
@@ -727,13 +753,21 @@ class AuditLogState extends BaseState {
727
753
  });
728
754
  }
729
755
 
730
- findPaginated({ scope, category, paging } = {}) {
731
- const conditions = {};
756
+ findPaginated({ scope, category, actionOrContent, paging } = {}) {
757
+ const conditions = {
758
+ where: {},
759
+ };
732
760
  if (scope) {
733
- conditions.scope = scope;
761
+ conditions.where.scope = scope;
734
762
  }
735
763
  if (category) {
736
- conditions.category = category;
764
+ conditions.where.category = category;
765
+ }
766
+ if (actionOrContent) {
767
+ conditions.where[Op.or] = [
768
+ { action: { [Op.like]: `%${actionOrContent}%` } },
769
+ { content: { [Op.like]: `%${actionOrContent}%` } },
770
+ ];
737
771
  }
738
772
 
739
773
  return super.paginate(conditions, { createdAt: -1 }, { pageSize: 20, ...paging });
@@ -0,0 +1,46 @@
1
+ const { Op } = require('sequelize');
2
+ const dayjs = require('@abtnode/util/lib/dayjs');
3
+ const logger = require('@abtnode/logger')('@abtnode/core:state:blacklist');
4
+
5
+ const BaseState = require('./base');
6
+
7
+ /**
8
+ * This db is used to save blacklist data.
9
+ * @extends BaseState<import('@abtnode/models').BlacklistState>
10
+ */
11
+ class BlacklistState extends BaseState {
12
+ findActiveByScope(scope) {
13
+ const now = dayjs().unix();
14
+ return this.find({ where: { scope, expiresAt: { [Op.gt]: now } } });
15
+ }
16
+
17
+ removeExpiredByScope(scope) {
18
+ const now = dayjs().unix();
19
+ return this.remove({ where: { scope, expiresAt: { [Op.lte]: now } } });
20
+ }
21
+
22
+ async addItem(scope, key, expiresAt) {
23
+ const now = dayjs().unix();
24
+ if (expiresAt < now) {
25
+ return false;
26
+ }
27
+
28
+ const exist = await this.findOne({ where: { scope, key } });
29
+ if (exist) {
30
+ if (exist.expiresAt >= now) {
31
+ logger.debug('reuse existing item', { exist, scope, key, expiresAt });
32
+ return false;
33
+ }
34
+
35
+ // if the item is expired, remove it
36
+ logger.debug('remove expired item on add', { exist });
37
+ await this.remove({ where: { scope, key } });
38
+ }
39
+
40
+ logger.debug('add new item', { scope, key, expiresAt });
41
+ await this.insert({ scope, key, expiresAt });
42
+ return true;
43
+ }
44
+ }
45
+
46
+ module.exports = BlacklistState;
@@ -17,6 +17,7 @@ const JobState = require('./job');
17
17
  const BackupState = require('./backup');
18
18
  const TrafficInsightState = require('./traffic-insight');
19
19
  const RuntimeInsightState = require('./runtime-insight');
20
+ const BlacklistState = require('./blacklist');
20
21
 
21
22
  const { getDbFilePath } = require('../util');
22
23
 
@@ -44,6 +45,7 @@ const init = (dataDirs, config = {}) => {
44
45
  const backupState = new BackupState(models.Backup, config);
45
46
  const trafficInsight = new TrafficInsightState(models.TrafficInsight, config);
46
47
  const runtimeInsight = new RuntimeInsightState(models.RuntimeInsight, { ...config, maxPageSize: 8640 });
48
+ const blacklistState = new BlacklistState(models.Blacklist, config);
47
49
 
48
50
  return {
49
51
  node: nodeState,
@@ -61,6 +63,7 @@ const init = (dataDirs, config = {}) => {
61
63
  backup: backupState,
62
64
  trafficInsight,
63
65
  runtimeInsight,
66
+ blacklist: blacklistState,
64
67
  };
65
68
  };
66
69
  /**
@@ -72,6 +75,7 @@ const init = (dataDirs, config = {}) => {
72
75
  * notificationReceiver: import('./notification-receiver'),
73
76
  * job: import('./job'),
74
77
  * node: import('./node'),
78
+ * blacklist: import('./blacklist'),
75
79
  * [key: string]: any
76
80
  * }}
77
81
  */
@@ -106,6 +106,19 @@ const updateGatewaySchema = Joi.object({
106
106
  .optional()
107
107
  .max(256)
108
108
  .default([]),
109
+ autoBlocking: Joi.object({
110
+ enabled: Joi.boolean().optional().default(false),
111
+ windowSize: Joi.number().integer().min(1).default(1), // in seconds
112
+ windowQuota: Joi.number().integer().min(2).default(5), // number of requests
113
+ statusCodes: Joi.array().items(Joi.number().integer().min(100).max(599)).default([403]), // status codes to check
114
+ blockDuration: Joi.number().integer().min(60).default(3600), // in seconds
115
+ }).default({
116
+ enabled: false,
117
+ windowSize: 1,
118
+ windowQuota: 5,
119
+ statusCodes: [403],
120
+ blockDuration: 3600,
121
+ }),
109
122
  }),
110
123
  proxyPolicy: Joi.object({
111
124
  enabled: Joi.boolean().optional().default(false),
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.16.38",
6
+ "version": "1.16.39-beta-20250209-032436-5ceca2cb",
7
7
  "description": "",
8
8
  "main": "lib/index.js",
9
9
  "files": [
@@ -19,20 +19,20 @@
19
19
  "author": "wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)",
20
20
  "license": "Apache-2.0",
21
21
  "dependencies": {
22
- "@abtnode/analytics": "1.16.38",
23
- "@abtnode/auth": "1.16.38",
24
- "@abtnode/certificate-manager": "1.16.38",
25
- "@abtnode/constant": "1.16.38",
26
- "@abtnode/cron": "1.16.38",
27
- "@abtnode/docker-utils": "1.16.38",
28
- "@abtnode/logger": "1.16.38",
29
- "@abtnode/models": "1.16.38",
30
- "@abtnode/queue": "1.16.38",
31
- "@abtnode/rbac": "1.16.38",
32
- "@abtnode/router-provider": "1.16.38",
33
- "@abtnode/static-server": "1.16.38",
34
- "@abtnode/timemachine": "1.16.38",
35
- "@abtnode/util": "1.16.38",
22
+ "@abtnode/analytics": "1.16.39-beta-20250209-032436-5ceca2cb",
23
+ "@abtnode/auth": "1.16.39-beta-20250209-032436-5ceca2cb",
24
+ "@abtnode/certificate-manager": "1.16.39-beta-20250209-032436-5ceca2cb",
25
+ "@abtnode/constant": "1.16.39-beta-20250209-032436-5ceca2cb",
26
+ "@abtnode/cron": "1.16.39-beta-20250209-032436-5ceca2cb",
27
+ "@abtnode/docker-utils": "1.16.39-beta-20250209-032436-5ceca2cb",
28
+ "@abtnode/logger": "1.16.39-beta-20250209-032436-5ceca2cb",
29
+ "@abtnode/models": "1.16.39-beta-20250209-032436-5ceca2cb",
30
+ "@abtnode/queue": "1.16.39-beta-20250209-032436-5ceca2cb",
31
+ "@abtnode/rbac": "1.16.39-beta-20250209-032436-5ceca2cb",
32
+ "@abtnode/router-provider": "1.16.39-beta-20250209-032436-5ceca2cb",
33
+ "@abtnode/static-server": "1.16.39-beta-20250209-032436-5ceca2cb",
34
+ "@abtnode/timemachine": "1.16.39-beta-20250209-032436-5ceca2cb",
35
+ "@abtnode/util": "1.16.39-beta-20250209-032436-5ceca2cb",
36
36
  "@arcblock/did": "1.19.9",
37
37
  "@arcblock/did-auth": "1.19.9",
38
38
  "@arcblock/did-ext": "^1.19.9",
@@ -43,13 +43,13 @@
43
43
  "@arcblock/pm2-events": "^0.0.5",
44
44
  "@arcblock/validator": "^1.19.9",
45
45
  "@arcblock/vc": "1.19.9",
46
- "@blocklet/constant": "1.16.38",
46
+ "@blocklet/constant": "1.16.39-beta-20250209-032436-5ceca2cb",
47
47
  "@blocklet/did-space-js": "^1.0.9",
48
- "@blocklet/env": "1.16.38",
49
- "@blocklet/meta": "1.16.38",
50
- "@blocklet/resolver": "1.16.38",
51
- "@blocklet/sdk": "1.16.38",
52
- "@blocklet/store": "1.16.38",
48
+ "@blocklet/env": "1.16.39-beta-20250209-032436-5ceca2cb",
49
+ "@blocklet/meta": "1.16.39-beta-20250209-032436-5ceca2cb",
50
+ "@blocklet/resolver": "1.16.39-beta-20250209-032436-5ceca2cb",
51
+ "@blocklet/sdk": "1.16.39-beta-20250209-032436-5ceca2cb",
52
+ "@blocklet/store": "1.16.39-beta-20250209-032436-5ceca2cb",
53
53
  "@fidm/x509": "^1.2.1",
54
54
  "@ocap/mcrypto": "1.19.9",
55
55
  "@ocap/util": "1.19.9",
@@ -85,6 +85,7 @@
85
85
  "p-limit": "^3.1.0",
86
86
  "p-map": "^4.0.0",
87
87
  "p-retry": "^4.6.2",
88
+ "rate-limiter-flexible": "^5.0.5",
88
89
  "read-last-lines": "^1.8.0",
89
90
  "semver": "^7.6.3",
90
91
  "sequelize": "^6.35.0",
@@ -109,5 +110,5 @@
109
110
  "jest": "^29.7.0",
110
111
  "unzipper": "^0.10.11"
111
112
  },
112
- "gitHead": "c75b3026d0e3fa45ccc2af00e2a85962bf5d17af"
113
+ "gitHead": "949581a46cec2ebfb656a42632ca550ed3b0ba70"
113
114
  }
@@ -1,167 +0,0 @@
1
- /* eslint-disable no-await-in-loop */
2
- const fs = require('fs');
3
-
4
- const THROTTLE_DELAY = 1000; // 1 second throttle
5
-
6
- // Function to parse the log entry and extract relevant information
7
- function parseLogEntry(line) {
8
- const regex =
9
- /^(\S+) - (\S+) \[([^\]]+)\] (\S+) "([^"]+)" "([^"]+)" (\d{3}) (\d+) "(.*?)" "(.*?)" "(.*?)" rt="(\S+)" uid="(.+?)" uos="(.+?)" uct="(\S+)" uht="(\S+)" urt="(\S+)"/;
10
- const match = line.match(regex);
11
- if (match) {
12
- return {
13
- ip: match[1],
14
- remoteUser: match[2],
15
- timeIso8601: match[3],
16
- requestId: match[4],
17
- host: match[5],
18
- request: match[6],
19
- status: parseInt(match[7], 10),
20
- bodyBytesSent: parseInt(match[8], 10),
21
- referer: match[9],
22
- userAgent: match[10],
23
- forwardedFor: match[11],
24
- requestTime: parseFloat(match[12]),
25
- connectedDid: match[13],
26
- connectedWalletOs: match[14],
27
- upstreamConnectTime: parseFloat(match[15]),
28
- upstreamHeaderTime: parseFloat(match[16]),
29
- upstreamResponseTime: parseFloat(match[17]),
30
- };
31
- }
32
- return null;
33
- }
34
-
35
- async function readLogFile(filePath, startPosition = 0) {
36
- let fd = null;
37
- try {
38
- if (!fs.existsSync(filePath)) {
39
- return { lines: [], fileSize: 0 };
40
- }
41
-
42
- const realPath = await fs.promises.realpath(filePath);
43
- fd = await fs.promises.open(realPath, 'r');
44
- const stats = await fd.stat();
45
- const fileSize = stats.size;
46
-
47
- if (startPosition < 0) {
48
- // eslint-disable-next-line no-param-reassign
49
- startPosition = Math.max(0, fileSize + startPosition);
50
- }
51
-
52
- if (startPosition >= fileSize) {
53
- return { lines: [], fileSize };
54
- }
55
-
56
- const readSize = fileSize - startPosition;
57
- if (readSize <= 0) {
58
- return { lines: [], fileSize };
59
- }
60
-
61
- const buffer = Buffer.alloc(readSize);
62
- const { bytesRead } = await fd.read(buffer, 0, readSize, startPosition);
63
- const content = buffer.slice(0, bytesRead).toString();
64
-
65
- const lines = content
66
- .split('\n')
67
- .map((line) => line.trim())
68
- .filter(Boolean);
69
-
70
- return { lines, fileSize };
71
- } finally {
72
- if (fd) {
73
- await fd.close();
74
- }
75
- }
76
- }
77
-
78
- let watcher;
79
- process.on('SIGINT', () => {
80
- watcher?.close();
81
- });
82
-
83
- process.on('SIGTERM', () => {
84
- watcher?.close();
85
- });
86
-
87
- function startLogWatcher(logFilePath, onLogEntry, initialBufferSize = 10240) {
88
- if (!fs.existsSync(logFilePath)) {
89
- console.error(`Log file ${logFilePath} does not exist`);
90
- return;
91
- }
92
-
93
- console.warn(`Start watching ${logFilePath} for 5xx requests...`);
94
-
95
- let isProcessing = false;
96
- let isFirstRun = true;
97
- let timeoutId = null;
98
- let lastProcessedPosition = 0;
99
-
100
- async function processLogChanges() {
101
- isProcessing = true;
102
- try {
103
- const { lines, fileSize } = await readLogFile(
104
- logFilePath,
105
- isFirstRun ? -initialBufferSize : lastProcessedPosition
106
- );
107
- const entries = [];
108
-
109
- for (const line of lines) {
110
- const logEntry = parseLogEntry(line);
111
- if (
112
- logEntry &&
113
- logEntry.status >= 500 &&
114
- logEntry.status !== 503 &&
115
- logEntry.status <= 599 &&
116
- logEntry.request.includes('/.well-known/did.json') === false &&
117
- logEntry.request.includes('/websocket?token=') === false &&
118
- logEntry.request.includes('/.well-known/service/health') === false
119
- ) {
120
- console.warn(`5xx request detected: ${logEntry.host}`, line);
121
- entries.push(logEntry);
122
- }
123
- }
124
-
125
- if (entries.length > 0) {
126
- onLogEntry(entries);
127
- }
128
-
129
- lastProcessedPosition = fileSize;
130
- isFirstRun = false;
131
- } catch (error) {
132
- console.error(`Error reading log file ${logFilePath}`, error);
133
- } finally {
134
- isProcessing = false;
135
- }
136
- }
137
-
138
- watcher = fs.watch(logFilePath, { persistent: true }, async (eventType, filename) => {
139
- if (!filename || eventType !== 'change') return;
140
-
141
- // Clear any pending timeout
142
- if (timeoutId) {
143
- clearTimeout(timeoutId);
144
- }
145
-
146
- // If already processing, schedule for later
147
- if (isProcessing) {
148
- timeoutId = setTimeout(() => {
149
- processLogChanges();
150
- }, THROTTLE_DELAY);
151
- return;
152
- }
153
-
154
- await processLogChanges();
155
- });
156
- }
157
-
158
- function stopLogWatcher() {
159
- watcher?.close();
160
- }
161
-
162
- module.exports = {
163
- parseLogEntry,
164
- readLogFile,
165
- startLogWatcher,
166
- stopLogWatcher,
167
- };