@relay-federation/bridge 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +753 -74
- package/dashboard/index.html +2212 -0
- package/lib/actions.js +373 -0
- package/lib/address-scanner.js +169 -0
- package/lib/address-watcher.js +161 -0
- package/lib/bsv-node-client.js +311 -0
- package/lib/bsv-peer.js +791 -0
- package/lib/config.js +17 -2
- package/lib/data-validator.js +6 -0
- package/lib/endpoint-probe.js +45 -0
- package/lib/gossip.js +266 -0
- package/lib/header-relay.js +6 -1
- package/lib/ip-diversity.js +88 -0
- package/lib/output-parser.js +494 -0
- package/lib/peer-manager.js +81 -12
- package/lib/persistent-store.js +708 -0
- package/lib/status-server.js +963 -14
- package/package.json +4 -2
package/lib/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, access } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
4
5
|
import { PrivateKey } from '@bsv/sdk'
|
|
5
6
|
|
|
6
7
|
const DEFAULT_DIR = join(homedir(), '.relay-bridge')
|
|
@@ -23,17 +24,31 @@ export function defaultConfigDir () {
|
|
|
23
24
|
export async function initConfig (dir = DEFAULT_DIR) {
|
|
24
25
|
const privKey = PrivateKey.fromRandom()
|
|
25
26
|
|
|
27
|
+
const address = privKey.toPublicKey().toAddress()
|
|
28
|
+
|
|
26
29
|
const config = {
|
|
27
30
|
wif: privKey.toWif(),
|
|
28
31
|
pubkeyHex: privKey.toPublicKey().toString(),
|
|
32
|
+
address,
|
|
29
33
|
endpoint: 'wss://your-bridge.example.com:8333',
|
|
30
|
-
meshId: '
|
|
34
|
+
meshId: '70016',
|
|
31
35
|
capabilities: ['tx_relay', 'header_sync', 'broadcast', 'address_history'],
|
|
32
36
|
spvEndpoint: 'https://relay.indelible.one',
|
|
33
37
|
apiKey: '',
|
|
34
38
|
port: 8333,
|
|
35
39
|
statusPort: 9333,
|
|
36
|
-
|
|
40
|
+
statusSecret: randomBytes(32).toString('hex'),
|
|
41
|
+
maxPeers: 20,
|
|
42
|
+
dataDir: join(dir, 'data'),
|
|
43
|
+
seedPeers: [],
|
|
44
|
+
// apps: [
|
|
45
|
+
// {
|
|
46
|
+
// name: 'My App',
|
|
47
|
+
// url: 'https://myapp.example.com',
|
|
48
|
+
// healthUrl: 'http://127.0.0.1:3000', // optional — local URL for health checks (avoids DNS/TLS loopback timeout)
|
|
49
|
+
// bridgeDomain: 'bridge.myapp.example.com'
|
|
50
|
+
// }
|
|
51
|
+
// ]
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
await mkdir(dir, { recursive: true })
|
package/lib/data-validator.js
CHANGED
|
@@ -188,6 +188,12 @@ export class DataValidator extends EventEmitter {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
_validateHeaderAnnounce (pubkeyHex, msg) {
|
|
191
|
+
// height: -1 means "no headers yet" — valid, skip hash check
|
|
192
|
+
if (msg.height === -1) {
|
|
193
|
+
this.scorer.recordGoodData(pubkeyHex)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
if (typeof msg.height !== 'number' || msg.height < 0) {
|
|
192
198
|
this.scorer.recordBadData(pubkeyHex)
|
|
193
199
|
this.emit('validation:fail', { pubkeyHex, type: 'header_announce', reason: 'invalid_height' })
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import WebSocket from 'ws'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Probe a WebSocket endpoint for reachability.
|
|
5
|
+
*
|
|
6
|
+
* Opens a WebSocket connection, waits for the 'open' event,
|
|
7
|
+
* then immediately closes. Returns true if reachable, false
|
|
8
|
+
* if the connection fails or times out.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} endpoint — WebSocket URL (ws:// or wss://)
|
|
11
|
+
* @param {number} [timeoutMs=5000] — Probe timeout in milliseconds
|
|
12
|
+
* @returns {Promise<boolean>} true if endpoint is reachable
|
|
13
|
+
*/
|
|
14
|
+
export async function probeEndpoint (endpoint, timeoutMs = 5000) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let settled = false
|
|
17
|
+
|
|
18
|
+
const ws = new WebSocket(endpoint, {
|
|
19
|
+
handshakeTimeout: timeoutMs
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
if (settled) return
|
|
24
|
+
settled = true
|
|
25
|
+
try { ws.close() } catch {}
|
|
26
|
+
resolve(false)
|
|
27
|
+
}, timeoutMs)
|
|
28
|
+
|
|
29
|
+
ws.on('open', () => {
|
|
30
|
+
if (settled) return
|
|
31
|
+
settled = true
|
|
32
|
+
clearTimeout(timer)
|
|
33
|
+
try { ws.close() } catch {}
|
|
34
|
+
resolve(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
ws.on('error', () => {
|
|
38
|
+
if (settled) return
|
|
39
|
+
settled = true
|
|
40
|
+
clearTimeout(timer)
|
|
41
|
+
try { ws.close() } catch {}
|
|
42
|
+
resolve(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
package/lib/gossip.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { signHash, verifyHash } from '@relay-federation/common/crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GossipManager — peer discovery via WebSocket gossip protocol.
|
|
6
|
+
*
|
|
7
|
+
* Replaces HTTP-based registry scanning with pure P2P peer discovery.
|
|
8
|
+
* Three message types:
|
|
9
|
+
*
|
|
10
|
+
* getpeers — "tell me who you know"
|
|
11
|
+
* peers — response with list of known peers
|
|
12
|
+
* announce — "I'm alive" (signed, propagated to all peers)
|
|
13
|
+
*
|
|
14
|
+
* Announcements are signed with the bridge's private key to prevent
|
|
15
|
+
* impersonation. Each announcement includes a timestamp — stale
|
|
16
|
+
* announcements (older than maxAge) are discarded.
|
|
17
|
+
*
|
|
18
|
+
* Events:
|
|
19
|
+
* 'peer:discovered' — { pubkeyHex, endpoint, meshId }
|
|
20
|
+
* 'peers:response' — { peers: Array }
|
|
21
|
+
*/
|
|
22
|
+
export class GossipManager extends EventEmitter {
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('./peer-manager.js').PeerManager} peerManager
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {import('@bsv/sdk').PrivateKey} opts.privKey — Bridge private key for signing
|
|
27
|
+
* @param {string} opts.pubkeyHex — Our compressed pubkey hex
|
|
28
|
+
* @param {string} opts.endpoint — Our advertised WSS endpoint
|
|
29
|
+
* @param {string} [opts.meshId='70016'] — Mesh identifier
|
|
30
|
+
* @param {number} [opts.announceIntervalMs=60000] — Re-announce interval
|
|
31
|
+
* @param {number} [opts.maxAge=300000] — Max age for announcements (5 min)
|
|
32
|
+
* @param {number} [opts.maxPeersResponse=50] — Max peers in a response
|
|
33
|
+
*/
|
|
34
|
+
constructor (peerManager, opts) {
|
|
35
|
+
super()
|
|
36
|
+
this.peerManager = peerManager
|
|
37
|
+
this._privKey = opts.privKey
|
|
38
|
+
this._pubkeyHex = opts.pubkeyHex
|
|
39
|
+
this._endpoint = opts.endpoint
|
|
40
|
+
this._meshId = opts.meshId || '70016'
|
|
41
|
+
this._announceIntervalMs = opts.announceIntervalMs || 60000
|
|
42
|
+
this._maxAge = opts.maxAge || 300000
|
|
43
|
+
this._maxPeersResponse = opts.maxPeersResponse || 50
|
|
44
|
+
|
|
45
|
+
/** @type {Map<string, { pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>} */
|
|
46
|
+
this._directory = new Map()
|
|
47
|
+
|
|
48
|
+
/** @type {Set<string>} recently seen announce hashes (dedup) */
|
|
49
|
+
this._seenAnnounces = new Set()
|
|
50
|
+
|
|
51
|
+
this._announceTimer = null
|
|
52
|
+
|
|
53
|
+
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
54
|
+
this._handleMessage(pubkeyHex, message)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start periodic announcements.
|
|
60
|
+
*/
|
|
61
|
+
start () {
|
|
62
|
+
// Announce immediately
|
|
63
|
+
this._broadcastAnnounce()
|
|
64
|
+
// Then on interval
|
|
65
|
+
this._announceTimer = setInterval(() => {
|
|
66
|
+
this._broadcastAnnounce()
|
|
67
|
+
}, this._announceIntervalMs)
|
|
68
|
+
if (this._announceTimer.unref) this._announceTimer.unref()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop periodic announcements.
|
|
73
|
+
*/
|
|
74
|
+
stop () {
|
|
75
|
+
if (this._announceTimer) {
|
|
76
|
+
clearInterval(this._announceTimer)
|
|
77
|
+
this._announceTimer = null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Request peer list from a specific peer.
|
|
83
|
+
* @param {string} pubkeyHex — peer to ask
|
|
84
|
+
*/
|
|
85
|
+
requestPeers (pubkeyHex) {
|
|
86
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
87
|
+
if (conn) {
|
|
88
|
+
conn.send({ type: 'getpeers' })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Request peer lists from all connected peers.
|
|
94
|
+
*/
|
|
95
|
+
requestPeersFromAll () {
|
|
96
|
+
this.peerManager.broadcast({ type: 'getpeers' })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the current peer directory.
|
|
101
|
+
* @returns {Array<{ pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>}
|
|
102
|
+
*/
|
|
103
|
+
getDirectory () {
|
|
104
|
+
const now = Date.now()
|
|
105
|
+
const result = []
|
|
106
|
+
for (const [, entry] of this._directory) {
|
|
107
|
+
if (now - entry.lastSeen < this._maxAge) {
|
|
108
|
+
result.push({ ...entry })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get directory size (excluding stale entries).
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
directorySize () {
|
|
119
|
+
return this.getDirectory().length
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Manually add a peer to the directory (e.g., seed peers from config).
|
|
124
|
+
* @param {{ pubkeyHex: string, endpoint: string, meshId?: string }} peer
|
|
125
|
+
*/
|
|
126
|
+
addSeed (peer) {
|
|
127
|
+
this._directory.set(peer.pubkeyHex, {
|
|
128
|
+
pubkeyHex: peer.pubkeyHex,
|
|
129
|
+
endpoint: peer.endpoint,
|
|
130
|
+
meshId: peer.meshId || this._meshId,
|
|
131
|
+
lastSeen: Date.now()
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @private */
|
|
136
|
+
_handleMessage (pubkeyHex, message) {
|
|
137
|
+
switch (message.type) {
|
|
138
|
+
case 'getpeers':
|
|
139
|
+
this._onGetPeers(pubkeyHex)
|
|
140
|
+
break
|
|
141
|
+
case 'peers':
|
|
142
|
+
this._onPeers(pubkeyHex, message)
|
|
143
|
+
break
|
|
144
|
+
case 'announce':
|
|
145
|
+
this._onAnnounce(pubkeyHex, message)
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @private */
|
|
151
|
+
_onGetPeers (pubkeyHex) {
|
|
152
|
+
const peers = this.getDirectory()
|
|
153
|
+
.filter(p => p.pubkeyHex !== pubkeyHex) // don't send them back to themselves
|
|
154
|
+
.slice(0, this._maxPeersResponse)
|
|
155
|
+
|
|
156
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
157
|
+
if (conn) {
|
|
158
|
+
conn.send({ type: 'peers', peers })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @private */
|
|
163
|
+
_onPeers (pubkeyHex, message) {
|
|
164
|
+
if (!Array.isArray(message.peers)) return
|
|
165
|
+
|
|
166
|
+
for (const peer of message.peers) {
|
|
167
|
+
if (!peer.pubkeyHex || !peer.endpoint) continue
|
|
168
|
+
if (peer.pubkeyHex === this._pubkeyHex) continue // skip self
|
|
169
|
+
|
|
170
|
+
const isNew = !this._directory.has(peer.pubkeyHex)
|
|
171
|
+
|
|
172
|
+
this._directory.set(peer.pubkeyHex, {
|
|
173
|
+
pubkeyHex: peer.pubkeyHex,
|
|
174
|
+
endpoint: peer.endpoint,
|
|
175
|
+
meshId: peer.meshId || 'unknown',
|
|
176
|
+
lastSeen: Date.now()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
if (isNew) {
|
|
180
|
+
this.emit('peer:discovered', {
|
|
181
|
+
pubkeyHex: peer.pubkeyHex,
|
|
182
|
+
endpoint: peer.endpoint,
|
|
183
|
+
meshId: peer.meshId || 'unknown'
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.emit('peers:response', { peers: message.peers, from: pubkeyHex })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @private */
|
|
192
|
+
_onAnnounce (sourcePubkey, message) {
|
|
193
|
+
if (!message.pubkeyHex || !message.endpoint || !message.timestamp || !message.signature) return
|
|
194
|
+
|
|
195
|
+
// Dedup — don't process the same announcement twice
|
|
196
|
+
const announceId = `${message.pubkeyHex}:${message.timestamp}`
|
|
197
|
+
if (this._seenAnnounces.has(announceId)) return
|
|
198
|
+
this._seenAnnounces.add(announceId)
|
|
199
|
+
|
|
200
|
+
// Trim dedup set if it gets too large
|
|
201
|
+
if (this._seenAnnounces.size > 10000) {
|
|
202
|
+
const arr = [...this._seenAnnounces]
|
|
203
|
+
this._seenAnnounces = new Set(arr.slice(arr.length - 5000))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check age
|
|
207
|
+
const age = Date.now() - message.timestamp
|
|
208
|
+
if (age > this._maxAge || age < -30000) return // too old or too far in the future
|
|
209
|
+
|
|
210
|
+
// Verify signature
|
|
211
|
+
const payload = `${message.pubkeyHex}:${message.endpoint}:${message.meshId || ''}:${message.timestamp}`
|
|
212
|
+
const dataHex = Buffer.from(payload, 'utf8').toString('hex')
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const valid = verifyHash(dataHex, message.signature, message.pubkeyHex)
|
|
216
|
+
if (!valid) return
|
|
217
|
+
} catch {
|
|
218
|
+
return // invalid signature format
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Skip self
|
|
222
|
+
if (message.pubkeyHex === this._pubkeyHex) return
|
|
223
|
+
|
|
224
|
+
const isNew = !this._directory.has(message.pubkeyHex)
|
|
225
|
+
|
|
226
|
+
this._directory.set(message.pubkeyHex, {
|
|
227
|
+
pubkeyHex: message.pubkeyHex,
|
|
228
|
+
endpoint: message.endpoint,
|
|
229
|
+
meshId: message.meshId || 'unknown',
|
|
230
|
+
lastSeen: Date.now()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
if (isNew) {
|
|
234
|
+
this.emit('peer:discovered', {
|
|
235
|
+
pubkeyHex: message.pubkeyHex,
|
|
236
|
+
endpoint: message.endpoint,
|
|
237
|
+
meshId: message.meshId || 'unknown'
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Re-broadcast to all peers except source
|
|
242
|
+
this.peerManager.broadcast(message, sourcePubkey)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @private */
|
|
246
|
+
_broadcastAnnounce () {
|
|
247
|
+
const timestamp = Date.now()
|
|
248
|
+
const payload = `${this._pubkeyHex}:${this._endpoint}:${this._meshId}:${timestamp}`
|
|
249
|
+
const dataHex = Buffer.from(payload, 'utf8').toString('hex')
|
|
250
|
+
const signature = signHash(dataHex, this._privKey)
|
|
251
|
+
|
|
252
|
+
const message = {
|
|
253
|
+
type: 'announce',
|
|
254
|
+
pubkeyHex: this._pubkeyHex,
|
|
255
|
+
endpoint: this._endpoint,
|
|
256
|
+
meshId: this._meshId,
|
|
257
|
+
timestamp,
|
|
258
|
+
signature
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Add to our own dedup set
|
|
262
|
+
this._seenAnnounces.add(`${this._pubkeyHex}:${timestamp}`)
|
|
263
|
+
|
|
264
|
+
this.peerManager.broadcast(message)
|
|
265
|
+
}
|
|
266
|
+
}
|
package/lib/header-relay.js
CHANGED
|
@@ -171,8 +171,13 @@ export class HeaderRelay extends EventEmitter {
|
|
|
171
171
|
this.emit('header:sync', {
|
|
172
172
|
pubkeyHex,
|
|
173
173
|
added,
|
|
174
|
-
bestHeight: this.bestHeight
|
|
174
|
+
bestHeight: this.bestHeight,
|
|
175
|
+
headers: msg.headers
|
|
175
176
|
})
|
|
177
|
+
// If we got a full batch, tell the source our new height so they send more
|
|
178
|
+
if (msg.headers.length >= this._maxBatch) {
|
|
179
|
+
this._announceToPeer(pubkeyHex)
|
|
180
|
+
}
|
|
176
181
|
// Re-announce to all peers except the source
|
|
177
182
|
this.peerManager.broadcast({
|
|
178
183
|
type: 'header_announce',
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Diversity — enforces minimum /16 subnet diversity among peers.
|
|
3
|
+
*
|
|
4
|
+
* Prevents all connections from clustering in the same datacenter
|
|
5
|
+
* by tracking the /16 prefix (first two octets) of each peer's IP.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - Once we have 3+ peers, at least 3 different /16 subnets must be present
|
|
9
|
+
* - A new connection is rejected if it would reduce subnet count below the minimum
|
|
10
|
+
* - Non-IP hostnames are always allowed (can't determine subnet)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract the /16 subnet prefix from a WebSocket endpoint URL.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} endpoint — ws:// or wss:// URL
|
|
17
|
+
* @returns {string|null} First two octets (e.g. '144.202') or null if not an IP
|
|
18
|
+
*/
|
|
19
|
+
export function extractSubnet (endpoint) {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(endpoint)
|
|
22
|
+
const host = url.hostname
|
|
23
|
+
|
|
24
|
+
// Check if host is an IPv4 address
|
|
25
|
+
const parts = host.split('.')
|
|
26
|
+
if (parts.length !== 4) return null
|
|
27
|
+
if (!parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return null
|
|
28
|
+
|
|
29
|
+
return `${parts[0]}.${parts[1]}`
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count unique /16 subnets in a set of endpoints.
|
|
37
|
+
*
|
|
38
|
+
* @param {string[]} endpoints — array of ws:// or wss:// URLs
|
|
39
|
+
* @returns {Set<string>} Set of unique /16 subnet prefixes
|
|
40
|
+
*/
|
|
41
|
+
export function getSubnets (endpoints) {
|
|
42
|
+
const subnets = new Set()
|
|
43
|
+
for (const ep of endpoints) {
|
|
44
|
+
const subnet = extractSubnet(ep)
|
|
45
|
+
if (subnet) subnets.add(subnet)
|
|
46
|
+
}
|
|
47
|
+
return subnets
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if connecting to a candidate endpoint would maintain IP diversity.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} connectedEndpoints — endpoints of currently connected peers
|
|
54
|
+
* @param {string} candidateEndpoint — endpoint of peer we want to connect to
|
|
55
|
+
* @param {number} [minSubnets=3] — minimum number of unique /16 subnets required
|
|
56
|
+
* @returns {{ allowed: boolean, reason?: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function checkIpDiversity (connectedEndpoints, candidateEndpoint, minSubnets = 3) {
|
|
59
|
+
const candidateSubnet = extractSubnet(candidateEndpoint)
|
|
60
|
+
|
|
61
|
+
// Non-IP endpoints are always allowed (hostname-based — can't determine subnet)
|
|
62
|
+
if (!candidateSubnet) {
|
|
63
|
+
return { allowed: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If we don't have enough peers yet, always allow
|
|
67
|
+
if (connectedEndpoints.length < minSubnets) {
|
|
68
|
+
return { allowed: true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Get current subnet distribution
|
|
72
|
+
const currentSubnets = getSubnets(connectedEndpoints)
|
|
73
|
+
|
|
74
|
+
// If candidate is in a subnet we already have, check if we'd still meet minimum
|
|
75
|
+
if (currentSubnets.has(candidateSubnet)) {
|
|
76
|
+
// Already have this subnet — only block if we're at exactly minSubnets
|
|
77
|
+
// and too many peers are in this subnet (>50% of connections)
|
|
78
|
+
const sameSubnetCount = connectedEndpoints.filter(ep => extractSubnet(ep) === candidateSubnet).length
|
|
79
|
+
const totalAfter = connectedEndpoints.length + 1
|
|
80
|
+
if (sameSubnetCount + 1 > Math.floor(totalAfter / 2) && currentSubnets.size <= minSubnets) {
|
|
81
|
+
return { allowed: false, reason: `subnet ${candidateSubnet}.x.x already has ${sameSubnetCount}/${connectedEndpoints.length} peers` }
|
|
82
|
+
}
|
|
83
|
+
return { allowed: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// New subnet — always good for diversity
|
|
87
|
+
return { allowed: true }
|
|
88
|
+
}
|