blind-peer 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +188 -4
  2. package/lib/top-k.js +94 -0
  3. package/package.json +20 -7
package/index.js CHANGED
@@ -12,14 +12,25 @@ const safetyCatch = require('safety-catch')
12
12
  const Wakeup = require('protomux-wakeup')
13
13
  const ScopeLock = require('scope-lock')
14
14
  const IdEnc = require('hypercore-id-encoding')
15
+ const ProtomuxRpcClientPool = require('protomux-rpc-client-pool')
16
+ const ProtomuxRpcClient = require('protomux-rpc-client')
17
+ const rrp = require('resolve-reject-promise')
18
+ const IpBanList = require('ip-ban-list')
19
+ const {
20
+ ADMIN_CHANNEL_ID,
21
+ AdminQueryTopKEncoding,
22
+ AddCoreEncoding,
23
+ DeleteCoreEncoding,
24
+ RouterResolvePeersRequest,
25
+ RouterResolvePeersResponse
26
+ } = require('blind-peer-encodings')
15
27
 
16
28
  const BlindPeerDB = require('./lib/db.js')
29
+ const TopKWindow = require('./lib/top-k.js')
17
30
 
18
31
  // Enable Small wants in Hypercore. Must be before anywhere that uses Hypercore
19
32
  Hypercore.enable(Hypercore.SMALL_WANTS)
20
33
 
21
- const { AddCoreEncoding, DeleteCoreEncoding } = require('blind-peer-encodings')
22
-
23
34
  class CoreTracker {
24
35
  constructor(blindPeer, core) {
25
36
  this.blindPeer = blindPeer
@@ -197,10 +208,16 @@ class BlindPeer extends ReadyResource {
197
208
  maxBytes = 100_000_000_000,
198
209
  enableGc = true,
199
210
  trustedPubKeys,
211
+ routerKey,
212
+ routerPoolOpts,
213
+ ipBanListKeys = [],
214
+ banTimeout = 16_000,
200
215
  port,
201
216
  announcingInterval = 100,
202
217
  wakeupGcTickTime = null,
203
- replicationLagThreshold = 100
218
+ replicationLagThreshold = 100,
219
+ topK = {},
220
+ adminRouter = null
204
221
  } = {}
205
222
  ) {
206
223
  super()
@@ -208,6 +225,10 @@ class BlindPeer extends ReadyResource {
208
225
  this.rocks = typeof rocks === 'string' ? new RocksDB(rocks) : rocks
209
226
  this.store = store || new Corestore(this.rocks, { active: false })
210
227
  this.swarm = swarm || null
228
+ const ipBanNs = this.store.namespace('ip-ban-lists')
229
+ this.ipBanLists = ipBanListKeys.map((key) => new IpBanList(ipBanNs, { key }))
230
+ this.banTimeout = banTimeout
231
+
211
232
  this._port = port || 0
212
233
  this.announcingInterval = announcingInterval
213
234
  this.trustedPubKeys = new Set()
@@ -227,6 +248,11 @@ class BlindPeer extends ReadyResource {
227
248
  this.announcedCores = new Map()
228
249
  this.replicationLagThreshold = replicationLagThreshold
229
250
 
251
+ this.routerKey = routerKey || null
252
+ this.routerPoolOpts = routerPoolOpts || {}
253
+ this.routerPool = null
254
+ this.adminRouter = adminRouter
255
+
230
256
  this.stats = {
231
257
  bytesGcd: 0,
232
258
  coresAdded: 0,
@@ -236,6 +262,35 @@ class BlindPeer extends ReadyResource {
236
262
  muxerPaired: 0,
237
263
  muxerErrors: 0
238
264
  }
265
+
266
+ const {
267
+ bucketCount = 6,
268
+ bucketTime = 10_000,
269
+ k = 5,
270
+ peerThreshold = 100,
271
+ referrerThreshold = 100
272
+ } = topK
273
+
274
+ this.topKByPeer = new TopKWindow(bucketCount, bucketTime, k, peerThreshold)
275
+ this.topKByReferrer = new TopKWindow(bucketCount, bucketTime, k, referrerThreshold)
276
+ this.topKByIp = new TopKWindow(bucketCount, bucketTime, k, null) // we do not want to expose the ip spike
277
+
278
+ if (this.adminRouter) {
279
+ this.adminRouter.use({
280
+ onrequest: (ctx, next) => {
281
+ if (!this._isTrustedPeer(ctx.connection.remotePublicKey)) {
282
+ throw new Error('Only trusted peers can query top-k')
283
+ }
284
+
285
+ return next()
286
+ }
287
+ })
288
+ this.adminRouter.method(
289
+ 'query-top-k',
290
+ AdminQueryTopKEncoding,
291
+ this._onadminquerytopk.bind(this)
292
+ )
293
+ }
239
294
  }
240
295
 
241
296
  get encryptionPublicKey() {
@@ -262,6 +317,15 @@ class BlindPeer extends ReadyResource {
262
317
  return this.trustedPubKeys.has(IdEnc.normalize(key))
263
318
  }
264
319
 
320
+ _onadminquerytopk() {
321
+ return {
322
+ version: 1,
323
+ ip: this.topKByIp.topK,
324
+ referrer: this.topKByReferrer.topK,
325
+ peerPublicKey: this.topKByPeer.topK
326
+ }
327
+ }
328
+
265
329
  async _open() {
266
330
  await this.store.ready()
267
331
 
@@ -285,6 +349,23 @@ class BlindPeer extends ReadyResource {
285
349
  }
286
350
  this.swarm.on('connection', this._onconnection.bind(this))
287
351
 
352
+ await Promise.all(
353
+ this.ipBanLists.map(async (banIpList) => {
354
+ await banIpList.ready()
355
+ this.swarm.join(banIpList.discoveryKey, { server: false, client: true })
356
+ })
357
+ )
358
+
359
+ if (this.routerKey) {
360
+ const rpcClient = new ProtomuxRpcClient(this.swarm.dht)
361
+ this.routerPool = new ProtomuxRpcClientPool([this.routerKey], rpcClient, this.routerPoolOpts)
362
+ }
363
+
364
+ await this.topKByPeer.ready()
365
+ await this.topKByReferrer.ready()
366
+ await this.topKByIp.ready()
367
+ if (this.adminRouter) await this.adminRouter.ready()
368
+
288
369
  this._announceCores().catch(safetyCatch) // announcing cores asynchronously
289
370
  this.flushInterval = setInterval(this.flush.bind(this), 10_000)
290
371
  }
@@ -432,6 +513,7 @@ class BlindPeer extends ReadyResource {
432
513
 
433
514
  rpc.respond('add-core', AddCoreEncoding, this._onaddcore.bind(this, conn))
434
515
  rpc.respond('delete-core', DeleteCoreEncoding, this._ondeletecore.bind(this, conn))
516
+ if (this.adminRouter) this.adminRouter.handleConnection(conn, ADMIN_CHANNEL_ID)
435
517
 
436
518
  const self = this
437
519
  BlindPeerMuxer.pair(conn, function () {
@@ -451,6 +533,36 @@ class BlindPeer extends ReadyResource {
451
533
  })
452
534
  }
453
535
 
536
+ _isBlocked(conn) {
537
+ // current behavior:
538
+ // as we do simple check ban
539
+ // bans can run in parallel across lists, but only the original banner can unban
540
+ for (const ipBanList of this.ipBanLists) {
541
+ if (ipBanList.isBanned(conn.rawStream.remoteHost)) {
542
+ return true
543
+ }
544
+ }
545
+ return false
546
+ }
547
+
548
+ async _timeoutThenThrow() {
549
+ const { promise, resolve } = rrp()
550
+
551
+ const done = () => {
552
+ clearTimeout(timer)
553
+ this.off('close', done)
554
+ resolve()
555
+ }
556
+
557
+ const timer = setTimeout(done, this.banTimeout)
558
+ timer.unref()
559
+
560
+ this.on('close', done)
561
+
562
+ await promise
563
+ throw new Error('Timed out')
564
+ }
565
+
454
566
  async _activateCore(stream, record) {
455
567
  this.stats.activations++
456
568
 
@@ -473,6 +585,23 @@ class BlindPeer extends ReadyResource {
473
585
  stream.on('close', () => core.close().catch(safetyCatch))
474
586
  }
475
587
 
588
+ async _resolvePeers(key) {
589
+ if (!this.routerPool) return
590
+
591
+ const result = await this.routerPool.makeRequest(
592
+ 'resolve-peers',
593
+ { key },
594
+ {
595
+ requestEncoding: RouterResolvePeersRequest,
596
+ responseEncoding: RouterResolvePeersResponse
597
+ }
598
+ )
599
+
600
+ this.emit('resolve-peers', { key, result })
601
+
602
+ return result
603
+ }
604
+
476
605
  async _announceCores() {
477
606
  for await (const record of this.db.createAnnouncingCoresStream()) {
478
607
  if (this.closing) return
@@ -534,6 +663,10 @@ class BlindPeer extends ReadyResource {
534
663
 
535
664
  async _onaddcore(stream, record) {
536
665
  if (!this.opened) await this.ready()
666
+ if (this._isBlocked(stream)) {
667
+ this.emit('connection-banned', stream)
668
+ await this._timeoutThenThrow()
669
+ }
537
670
 
538
671
  record.priority = Math.min(record.priority, 1) // 2 is reserved for trusted peers
539
672
  if (record.announce !== false && !this._isTrustedPeer(stream.remotePublicKey)) {
@@ -574,9 +707,20 @@ class BlindPeer extends ReadyResource {
574
707
  }
575
708
 
576
709
  async _onaddcores(stream, request) {
710
+ if (this._isBlocked(stream)) {
711
+ this.emit('connection-banned', stream)
712
+ throw new Error('Timed out')
713
+ }
577
714
  this.stats.addCoresRx++
578
- const priority = Math.min(request.priority, 1) // 2 is reserved for trusted peers
715
+
579
716
  const { cores, referrer } = request
717
+ if (referrer) {
718
+ this.topKByReferrer.hit(IdEnc.normalize(referrer))
719
+ }
720
+ this.topKByPeer.hit(IdEnc.normalize(stream.remotePublicKey))
721
+ this.topKByIp.hit(stream.rawStream.remoteHost)
722
+
723
+ const priority = Math.min(request.priority, 1) // 2 is reserved for trusted peers
580
724
 
581
725
  if (request.announce !== false && !this._isTrustedPeer(stream.remotePublicKey)) {
582
726
  // Note: we can't use the original downgrade-announce event because that assumes a 'record' object
@@ -650,6 +794,13 @@ class BlindPeer extends ReadyResource {
650
794
  const discoveryKey = core.discoveryKey
651
795
  await core.close()
652
796
  await this._onwakeup(discoveryKey, muxer)
797
+
798
+ // TODO: will process the result in V2
799
+ // TODO: handle no referrer
800
+ this._resolvePeers(referrer).catch((err) => {
801
+ safetyCatch(err)
802
+ this.emit('resolve-peers-error', { key: referrer, error: err })
803
+ })
653
804
  }
654
805
 
655
806
  for (const r of recordsToAdd) this.emit('add-new-core', r, true, stream)
@@ -668,6 +819,10 @@ class BlindPeer extends ReadyResource {
668
819
  }
669
820
 
670
821
  async _ondeletecore(stream, { key }) {
822
+ if (this._isBlocked(stream)) {
823
+ this.emit('connection-banned', stream)
824
+ await this._timeoutThenThrow()
825
+ }
671
826
  if (!this._isTrustedPeer(stream.remotePublicKey)) {
672
827
  this.emit('delete-blocked', stream, { key })
673
828
  throw new Error('Only trusted peers can delete cores')
@@ -713,7 +868,15 @@ class BlindPeer extends ReadyResource {
713
868
  }
714
869
 
715
870
  async _close() {
871
+ if (this.routerPool) {
872
+ await this.routerPool.destroy()
873
+ await this.routerPool.statelessRpc.close()
874
+ }
875
+ if (this.adminRouter) await this.adminRouter.close()
716
876
  clearInterval(this.flushInterval)
877
+ await this.topKByPeer.close()
878
+ await this.topKByReferrer.close()
879
+ await this.topKByIp.close()
717
880
  if (this.ownsWakeup) this.wakeup.destroy()
718
881
  if (this.ownsSwarm) await this.swarm.destroy()
719
882
  await this.flush()
@@ -822,6 +985,27 @@ class BlindPeer extends ReadyResource {
822
985
  this.set(self.stats.addCoresRx)
823
986
  }
824
987
  })
988
+ new promClient.Gauge({
989
+ name: 'blind_peer_add_cores_top5_by_remote_key',
990
+ help: 'The total number of requests from the top 5 peers in the last minute',
991
+ collect() {
992
+ this.set(self.topKByPeer.topKSum())
993
+ }
994
+ })
995
+ new promClient.Gauge({
996
+ name: 'blind_peer_add_cores_top5_by_referrer',
997
+ help: 'The total number of requests from the top 5 referrers in the last minute',
998
+ collect() {
999
+ this.set(self.topKByReferrer.topKSum())
1000
+ }
1001
+ })
1002
+ new promClient.Gauge({
1003
+ name: 'blind_peer_add_cores_top5_by_remote_ip',
1004
+ help: 'The total number of requests from the top 5 remote IPs in the last minute',
1005
+ collect() {
1006
+ this.set(self.topKByIp.topKSum())
1007
+ }
1008
+ })
825
1009
  if (self.rocks.stats) {
826
1010
  new promClient.Gauge({
827
1011
  // eslint-disable-line no-new
package/lib/top-k.js ADDED
@@ -0,0 +1,94 @@
1
+ const ReadyResource = require('ready-resource')
2
+
3
+ /**
4
+ * Tracks the most frequent keys within a rolling time window.
5
+ */
6
+ class TopKWindow extends ReadyResource {
7
+ /**
8
+ * @param {number} [bucketCount=6]
9
+ * @param {number} [bucketTime=10_000]
10
+ * @param {number} [k=5]
11
+ * @param {number | null} [spikeThreshold=null]
12
+ */
13
+ constructor(bucketCount = 6, bucketTime = 10_000, k = 5, spikeThreshold = null) {
14
+ super()
15
+
16
+ this.bucketCount = bucketCount
17
+ this.bucketTime = bucketTime
18
+ this.k = k
19
+ this.spikeThreshold = spikeThreshold
20
+
21
+ /** @type {Map<string, number>[]} */
22
+ this._buckets = Array.from({ length: bucketCount }, () => new Map())
23
+ this._index = 0
24
+ this.topK = []
25
+ this._timer = null
26
+ }
27
+
28
+ /**
29
+ * @param {string} key
30
+ */
31
+ hit(key) {
32
+ const bucket = this._buckets[this._index]
33
+ bucket.set(key, (bucket.get(key) || 0) + 1)
34
+ }
35
+
36
+ /**
37
+ * @returns {number}
38
+ */
39
+ topKSum() {
40
+ let sum = 0
41
+ for (const { count } of this.topK) {
42
+ sum += count
43
+ }
44
+ return sum
45
+ }
46
+
47
+ _open() {
48
+ this._timer = setInterval(this._rotate.bind(this), this.bucketTime)
49
+ }
50
+
51
+ _close() {
52
+ if (this._timer !== null) {
53
+ clearInterval(this._timer)
54
+ this._timer = null
55
+ }
56
+
57
+ this.topK = []
58
+ for (const bucket of this._buckets) {
59
+ bucket.clear()
60
+ }
61
+ }
62
+
63
+ _rotate() {
64
+ // compute the top-k and cache
65
+ const totals = new Map()
66
+ for (const bucket of this._buckets) {
67
+ for (const [key, count] of bucket) {
68
+ totals.set(key, (totals.get(key) || 0) + count)
69
+ }
70
+ }
71
+
72
+ this.topK = [...totals.entries()]
73
+ .sort((a, b) => b[1] - a[1])
74
+ .slice(0, this.k)
75
+ .map(([key, count]) => ({ key, count }))
76
+
77
+ if (this.spikeThreshold !== null) {
78
+ // Deliberately emit only for cached top-k entries to keep spike volume bounded.
79
+ for (const { key, count } of this.topK) {
80
+ if (count >= this.spikeThreshold) {
81
+ this.emit('spike', key, count)
82
+ }
83
+ }
84
+ }
85
+
86
+ // rotate current bucket and clear the old bucket
87
+ this._index = (this._index + 1) % this.bucketCount
88
+ this._buckets[this._index].clear()
89
+
90
+ this.emit('rotated')
91
+ }
92
+ }
93
+
94
+ module.exports = TopKWindow
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "blind-peer",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "Blind peers help keep hypercores available",
5
5
  "main": "index.js",
6
6
  "dependencies": {
7
7
  "autobase": "^7.0.18",
8
8
  "b4a": "^1.6.7",
9
- "blind-peer-encodings": "^3.1.1",
9
+ "blind-peer-encodings": "^3.3.0",
10
10
  "blind-peer-muxer": "^1.0.0",
11
11
  "compact-encoding": "^2.16.0",
12
12
  "corestore": "^7.4.4",
@@ -14,12 +14,17 @@
14
14
  "hypercore-crypto": "^3.5.0",
15
15
  "hypercore-id-encoding": "^1.3.0",
16
16
  "hyperdb": "^5.0.0",
17
+ "hyperdht": "^6.29.0",
17
18
  "hyperschema": "^1.10.3",
18
19
  "hyperswarm": "^4.13.1",
20
+ "ip-ban-list": "^0.3.0",
19
21
  "protomux-rpc": "^1.7.1",
22
+ "protomux-rpc-client": "^2.1.0",
23
+ "protomux-rpc-client-pool": "^2.1.0",
20
24
  "protomux-wakeup": "^2.9.0",
21
25
  "ready-resource": "^1.1.2",
22
26
  "repl-swarm": "^2.3.0",
27
+ "resolve-reject-promise": "^1.0.3",
23
28
  "rocksdb-native": "^3.1.6",
24
29
  "safety-catch": "^1.0.2",
25
30
  "scope-lock": "^1.2.4"
@@ -28,14 +33,17 @@
28
33
  "bare-events": "^2.8.2",
29
34
  "bare-process": "^4.2.2",
30
35
  "bare-prom-client": "^15.1.3",
31
- "blind-peering": "^2.0.0",
36
+ "blind-peer-router": "^0.2.2",
37
+ "blind-peering": "^2.1.0",
32
38
  "brittle": "^3.7.0",
33
39
  "debounceify": "^1.1.0",
34
- "hyperdht": "^6.20.1",
40
+ "graceful-goodbye": "^1.3.3",
41
+ "hyperdht-address": "^1.0.1",
42
+ "lunte": "^1.6.0",
35
43
  "prettier": "^3.6.2",
36
44
  "prettier-config-holepunch": "^2.0.0",
37
- "test-tmp": "^1.3.0",
38
- "which-runtime": "^1.3.2"
45
+ "protomux-rpc-router": "^1.2.0",
46
+ "test-tmp": "^1.3.0"
39
47
  },
40
48
  "files": [
41
49
  "index.js",
@@ -44,7 +52,8 @@
44
52
  "scripts": {
45
53
  "format": "prettier --write .",
46
54
  "format:check": "prettier --check .",
47
- "test": "npm run format:check && brittle test/*.js",
55
+ "test": "npm run format:check && lunte && brittle test/*.js",
56
+ "test:netns": "brittle test-netns/*.test.js",
48
57
  "test:bare": "bare test/test.js"
49
58
  },
50
59
  "repository": {
@@ -61,6 +70,10 @@
61
70
  "events": {
62
71
  "bare": "bare-events",
63
72
  "default": "events"
73
+ },
74
+ "prom-client": {
75
+ "bare": "bare-prom-client",
76
+ "default": "prom-client"
64
77
  }
65
78
  }
66
79
  }