@relay-federation/bridge 0.3.9 → 0.3.10
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 +18 -7
- package/lib/gossip.js +300 -266
- package/lib/peer-manager.js +278 -272
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { initConfig, loadConfig, configExists, defaultConfigDir } from './lib/config.js'
|
|
@@ -325,7 +325,12 @@ async function cmdStart () {
|
|
|
325
325
|
peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
|
|
326
326
|
peerHealth.recordSeen(pubkeyHex)
|
|
327
327
|
scorer.setStakeAge(pubkeyHex, 7)
|
|
328
|
-
if (endpoint)
|
|
328
|
+
if (endpoint) {
|
|
329
|
+
const updated = gossipManager.updatePeerEndpoint({ pubkeyHex, endpoint, meshId: config.meshId })
|
|
330
|
+
if (updated) {
|
|
331
|
+
console.log(` Endpoint updated: ${pubkeyHex.slice(0, 16)}... → ${endpoint}`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
329
334
|
})
|
|
330
335
|
|
|
331
336
|
peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
|
|
@@ -501,17 +506,23 @@ async function cmdStart () {
|
|
|
501
506
|
// Tie-break duplicate connections (inbound may have been accepted during handshake)
|
|
502
507
|
const existing = peerManager.peers.get(result.peerPubkey)
|
|
503
508
|
if (existing && existing !== conn) {
|
|
504
|
-
|
|
509
|
+
// If existing connection is dead, prefer new working connection
|
|
510
|
+
if (!existing.connected) {
|
|
511
|
+
console.log(` Replacing dead connection to ${result.peerPubkey.slice(0, 16)}...`)
|
|
512
|
+
existing._shouldReconnect = false
|
|
513
|
+
existing.destroy()
|
|
514
|
+
} else if (config.pubkeyHex > result.peerPubkey) {
|
|
505
515
|
// Higher pubkey drops outbound — keep existing inbound
|
|
506
516
|
console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
|
|
507
517
|
conn._shouldReconnect = false
|
|
508
518
|
conn.destroy()
|
|
509
519
|
return
|
|
520
|
+
} else {
|
|
521
|
+
// Lower pubkey keeps outbound — drop existing inbound
|
|
522
|
+
console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
|
|
523
|
+
existing._shouldReconnect = false
|
|
524
|
+
existing.destroy()
|
|
510
525
|
}
|
|
511
|
-
// Lower pubkey keeps outbound — drop existing inbound
|
|
512
|
-
console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
|
|
513
|
-
existing._shouldReconnect = false
|
|
514
|
-
existing.destroy()
|
|
515
526
|
}
|
|
516
527
|
|
|
517
528
|
peerManager.peers.set(result.peerPubkey, conn)
|
package/lib/gossip.js
CHANGED
|
@@ -1,266 +1,300 @@
|
|
|
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
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
this.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
/**
|
|
136
|
+
* Update a peer's endpoint after successful handshake and broadcast the update.
|
|
137
|
+
*
|
|
138
|
+
* This enables self-healing when a peer changes IP: after they complete a
|
|
139
|
+
* cryptographically verified handshake from their new endpoint, we update
|
|
140
|
+
* our directory and immediately broadcast the change to all connected peers.
|
|
141
|
+
*
|
|
142
|
+
* @param {{ pubkeyHex: string, endpoint: string, meshId?: string }} peer
|
|
143
|
+
* @returns {boolean} true if the endpoint was updated (changed from cached value)
|
|
144
|
+
*/
|
|
145
|
+
updatePeerEndpoint (peer) {
|
|
146
|
+
const existing = this._directory.get(peer.pubkeyHex)
|
|
147
|
+
const endpointChanged = !existing || existing.endpoint !== peer.endpoint
|
|
148
|
+
|
|
149
|
+
// Update the directory entry
|
|
150
|
+
this._directory.set(peer.pubkeyHex, {
|
|
151
|
+
pubkeyHex: peer.pubkeyHex,
|
|
152
|
+
endpoint: peer.endpoint,
|
|
153
|
+
meshId: peer.meshId || existing?.meshId || this._meshId,
|
|
154
|
+
lastSeen: Date.now()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// If endpoint changed, broadcast the update to all connected peers
|
|
158
|
+
if (endpointChanged) {
|
|
159
|
+
const updatedPeer = this._directory.get(peer.pubkeyHex)
|
|
160
|
+
this.peerManager.broadcast({
|
|
161
|
+
type: 'peers',
|
|
162
|
+
peers: [updatedPeer]
|
|
163
|
+
}, peer.pubkeyHex) // exclude the peer we just updated
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return endpointChanged
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @private */
|
|
170
|
+
_handleMessage (pubkeyHex, message) {
|
|
171
|
+
switch (message.type) {
|
|
172
|
+
case 'getpeers':
|
|
173
|
+
this._onGetPeers(pubkeyHex)
|
|
174
|
+
break
|
|
175
|
+
case 'peers':
|
|
176
|
+
this._onPeers(pubkeyHex, message)
|
|
177
|
+
break
|
|
178
|
+
case 'announce':
|
|
179
|
+
this._onAnnounce(pubkeyHex, message)
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @private */
|
|
185
|
+
_onGetPeers (pubkeyHex) {
|
|
186
|
+
const peers = this.getDirectory()
|
|
187
|
+
.filter(p => p.pubkeyHex !== pubkeyHex) // don't send them back to themselves
|
|
188
|
+
.slice(0, this._maxPeersResponse)
|
|
189
|
+
|
|
190
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
191
|
+
if (conn) {
|
|
192
|
+
conn.send({ type: 'peers', peers })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @private */
|
|
197
|
+
_onPeers (pubkeyHex, message) {
|
|
198
|
+
if (!Array.isArray(message.peers)) return
|
|
199
|
+
|
|
200
|
+
for (const peer of message.peers) {
|
|
201
|
+
if (!peer.pubkeyHex || !peer.endpoint) continue
|
|
202
|
+
if (peer.pubkeyHex === this._pubkeyHex) continue // skip self
|
|
203
|
+
|
|
204
|
+
const isNew = !this._directory.has(peer.pubkeyHex)
|
|
205
|
+
|
|
206
|
+
this._directory.set(peer.pubkeyHex, {
|
|
207
|
+
pubkeyHex: peer.pubkeyHex,
|
|
208
|
+
endpoint: peer.endpoint,
|
|
209
|
+
meshId: peer.meshId || 'unknown',
|
|
210
|
+
lastSeen: Date.now()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
if (isNew) {
|
|
214
|
+
this.emit('peer:discovered', {
|
|
215
|
+
pubkeyHex: peer.pubkeyHex,
|
|
216
|
+
endpoint: peer.endpoint,
|
|
217
|
+
meshId: peer.meshId || 'unknown'
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.emit('peers:response', { peers: message.peers, from: pubkeyHex })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @private */
|
|
226
|
+
_onAnnounce (sourcePubkey, message) {
|
|
227
|
+
if (!message.pubkeyHex || !message.endpoint || !message.timestamp || !message.signature) return
|
|
228
|
+
|
|
229
|
+
// Dedup — don't process the same announcement twice
|
|
230
|
+
const announceId = `${message.pubkeyHex}:${message.timestamp}`
|
|
231
|
+
if (this._seenAnnounces.has(announceId)) return
|
|
232
|
+
this._seenAnnounces.add(announceId)
|
|
233
|
+
|
|
234
|
+
// Trim dedup set if it gets too large
|
|
235
|
+
if (this._seenAnnounces.size > 10000) {
|
|
236
|
+
const arr = [...this._seenAnnounces]
|
|
237
|
+
this._seenAnnounces = new Set(arr.slice(arr.length - 5000))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check age
|
|
241
|
+
const age = Date.now() - message.timestamp
|
|
242
|
+
if (age > this._maxAge || age < -30000) return // too old or too far in the future
|
|
243
|
+
|
|
244
|
+
// Verify signature
|
|
245
|
+
const payload = `${message.pubkeyHex}:${message.endpoint}:${message.meshId || ''}:${message.timestamp}`
|
|
246
|
+
const dataHex = Buffer.from(payload, 'utf8').toString('hex')
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const valid = verifyHash(dataHex, message.signature, message.pubkeyHex)
|
|
250
|
+
if (!valid) return
|
|
251
|
+
} catch {
|
|
252
|
+
return // invalid signature format
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Skip self
|
|
256
|
+
if (message.pubkeyHex === this._pubkeyHex) return
|
|
257
|
+
|
|
258
|
+
const isNew = !this._directory.has(message.pubkeyHex)
|
|
259
|
+
|
|
260
|
+
this._directory.set(message.pubkeyHex, {
|
|
261
|
+
pubkeyHex: message.pubkeyHex,
|
|
262
|
+
endpoint: message.endpoint,
|
|
263
|
+
meshId: message.meshId || 'unknown',
|
|
264
|
+
lastSeen: Date.now()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
if (isNew) {
|
|
268
|
+
this.emit('peer:discovered', {
|
|
269
|
+
pubkeyHex: message.pubkeyHex,
|
|
270
|
+
endpoint: message.endpoint,
|
|
271
|
+
meshId: message.meshId || 'unknown'
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Re-broadcast to all peers except source
|
|
276
|
+
this.peerManager.broadcast(message, sourcePubkey)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @private */
|
|
280
|
+
_broadcastAnnounce () {
|
|
281
|
+
const timestamp = Date.now()
|
|
282
|
+
const payload = `${this._pubkeyHex}:${this._endpoint}:${this._meshId}:${timestamp}`
|
|
283
|
+
const dataHex = Buffer.from(payload, 'utf8').toString('hex')
|
|
284
|
+
const signature = signHash(dataHex, this._privKey)
|
|
285
|
+
|
|
286
|
+
const message = {
|
|
287
|
+
type: 'announce',
|
|
288
|
+
pubkeyHex: this._pubkeyHex,
|
|
289
|
+
endpoint: this._endpoint,
|
|
290
|
+
meshId: this._meshId,
|
|
291
|
+
timestamp,
|
|
292
|
+
signature
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Add to our own dedup set
|
|
296
|
+
this._seenAnnounces.add(`${this._pubkeyHex}:${timestamp}`)
|
|
297
|
+
|
|
298
|
+
this.peerManager.broadcast(message)
|
|
299
|
+
}
|
|
300
|
+
}
|
package/lib/peer-manager.js
CHANGED
|
@@ -1,272 +1,278 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events'
|
|
2
|
-
import { WebSocketServer } from 'ws'
|
|
3
|
-
import { PeerConnection } from './peer-connection.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* PeerManager — manages multiple peer connections.
|
|
7
|
-
*
|
|
8
|
-
* Handles:
|
|
9
|
-
* - Outbound connections to discovered peers
|
|
10
|
-
* - Inbound connections from other bridges (via WSS server)
|
|
11
|
-
* - Broadcasting messages to all connected peers
|
|
12
|
-
* - Peer lifecycle (connect, disconnect, reconnect)
|
|
13
|
-
*
|
|
14
|
-
* Events:
|
|
15
|
-
* 'peer:connect' — { pubkeyHex, endpoint }
|
|
16
|
-
* 'peer:disconnect' — { pubkeyHex, endpoint }
|
|
17
|
-
* 'peer:message' — { pubkeyHex, message }
|
|
18
|
-
* 'peer:error' — { pubkeyHex, error }
|
|
19
|
-
*/
|
|
20
|
-
export class PeerManager extends EventEmitter {
|
|
21
|
-
/**
|
|
22
|
-
* @param {object} [opts]
|
|
23
|
-
*/
|
|
24
|
-
constructor () {
|
|
25
|
-
super()
|
|
26
|
-
/** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
|
|
27
|
-
this.peers = new Map()
|
|
28
|
-
this._server = null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Connect to a discovered peer (outbound).
|
|
33
|
-
*
|
|
34
|
-
* @param {object} peer - Peer from buildPeerList()
|
|
35
|
-
* @param {string} peer.pubkeyHex
|
|
36
|
-
* @param {string} peer.endpoint
|
|
37
|
-
* @returns {PeerConnection|null} The connection, or null if already connected
|
|
38
|
-
*/
|
|
39
|
-
connectToPeer (peer) {
|
|
40
|
-
if (this.peers.has(peer.pubkeyHex)) {
|
|
41
|
-
return this.peers.get(peer.pubkeyHex)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const conn = new PeerConnection({
|
|
45
|
-
endpoint: peer.endpoint,
|
|
46
|
-
pubkeyHex: peer.pubkeyHex
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
this._attachPeerEvents(conn)
|
|
50
|
-
this.peers.set(peer.pubkeyHex, conn)
|
|
51
|
-
conn.connect()
|
|
52
|
-
|
|
53
|
-
return conn
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Accept an inbound peer connection.
|
|
58
|
-
*
|
|
59
|
-
* @param {WebSocket} socket - Incoming WebSocket
|
|
60
|
-
* @param {string} pubkeyHex - Peer's pubkey (from handshake)
|
|
61
|
-
* @param {string} endpoint - Peer's advertised endpoint
|
|
62
|
-
* @returns {PeerConnection|null}
|
|
63
|
-
*/
|
|
64
|
-
acceptPeer (socket, pubkeyHex, endpoint) {
|
|
65
|
-
if (this.peers.has(pubkeyHex)) {
|
|
66
|
-
// Already connected — close the duplicate
|
|
67
|
-
socket.close()
|
|
68
|
-
return null
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const conn = new PeerConnection({
|
|
72
|
-
endpoint,
|
|
73
|
-
pubkeyHex,
|
|
74
|
-
socket
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
this._attachPeerEvents(conn)
|
|
78
|
-
this.peers.set(pubkeyHex, conn)
|
|
79
|
-
|
|
80
|
-
return conn
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Disconnect a specific peer.
|
|
85
|
-
*
|
|
86
|
-
* @param {string} pubkeyHex
|
|
87
|
-
*/
|
|
88
|
-
disconnectPeer (pubkeyHex) {
|
|
89
|
-
const conn = this.peers.get(pubkeyHex)
|
|
90
|
-
if (conn) {
|
|
91
|
-
conn.destroy()
|
|
92
|
-
this.peers.delete(pubkeyHex)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Broadcast a message to all connected peers.
|
|
98
|
-
*
|
|
99
|
-
* @param {object} msg - JSON message with `type` field
|
|
100
|
-
* @param {string} [excludePubkey] - Optional pubkey to exclude (e.g. message source)
|
|
101
|
-
* @returns {number} Number of peers the message was sent to
|
|
102
|
-
*/
|
|
103
|
-
broadcast (msg, excludePubkey) {
|
|
104
|
-
let sent = 0
|
|
105
|
-
for (const [pubkeyHex, conn] of this.peers) {
|
|
106
|
-
if (pubkeyHex === excludePubkey) continue
|
|
107
|
-
if (conn.send(msg)) sent++
|
|
108
|
-
}
|
|
109
|
-
return sent
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Get count of currently connected peers.
|
|
114
|
-
* @returns {number}
|
|
115
|
-
*/
|
|
116
|
-
connectedCount () {
|
|
117
|
-
let count = 0
|
|
118
|
-
for (const conn of this.peers.values()) {
|
|
119
|
-
if (conn.connected) count++
|
|
120
|
-
}
|
|
121
|
-
return count
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Start a WebSocket server for inbound connections.
|
|
126
|
-
*
|
|
127
|
-
* @param {object} opts
|
|
128
|
-
* @param {number} opts.port - Port to listen on
|
|
129
|
-
* @param {string} [opts.host='0.0.0.0'] - Host to bind to
|
|
130
|
-
* @returns {Promise<void>}
|
|
131
|
-
*/
|
|
132
|
-
startServer (opts) {
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
this._server = new WebSocketServer({
|
|
135
|
-
port: opts.port,
|
|
136
|
-
host: opts.host || '0.0.0.0'
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
this._server.on('listening', () => resolve())
|
|
140
|
-
this._server.on('error', (err) => reject(err))
|
|
141
|
-
|
|
142
|
-
this._server.on('connection', (ws) => {
|
|
143
|
-
// Inbound connections: full challenge-response handshake if available,
|
|
144
|
-
// otherwise fall back to basic hello exchange.
|
|
145
|
-
const timeout = setTimeout(() => {
|
|
146
|
-
ws.close() // No hello within 10 seconds
|
|
147
|
-
}, 10000)
|
|
148
|
-
|
|
149
|
-
ws.once('message', (data) => {
|
|
150
|
-
clearTimeout(timeout)
|
|
151
|
-
try {
|
|
152
|
-
const msg = JSON.parse(data.toString())
|
|
153
|
-
if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
|
|
154
|
-
ws.close()
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// If cryptographic handshake is available, use it
|
|
159
|
-
if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
|
|
160
|
-
const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
|
|
161
|
-
const result = opts.handshake.handleHello(msg, isSeed ? null : opts.registeredPubkeys)
|
|
162
|
-
if (result.error) {
|
|
163
|
-
ws.send(JSON.stringify({ type: 'error', error: result.error }))
|
|
164
|
-
ws.close()
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Send challenge_response
|
|
169
|
-
ws.send(JSON.stringify(result.message))
|
|
170
|
-
|
|
171
|
-
// Wait for verify
|
|
172
|
-
const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
|
|
173
|
-
ws.once('message', (data2) => {
|
|
174
|
-
clearTimeout(verifyTimeout)
|
|
175
|
-
try {
|
|
176
|
-
const verifyMsg = JSON.parse(data2.toString())
|
|
177
|
-
const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
|
|
178
|
-
if (verifyResult.error) {
|
|
179
|
-
ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
|
|
180
|
-
ws.close()
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Tie-break duplicate connections by pubkey
|
|
185
|
-
const existing = this.peers.get(result.peerPubkey)
|
|
186
|
-
if (existing) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ws.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
this.
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { WebSocketServer } from 'ws'
|
|
3
|
+
import { PeerConnection } from './peer-connection.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PeerManager — manages multiple peer connections.
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Outbound connections to discovered peers
|
|
10
|
+
* - Inbound connections from other bridges (via WSS server)
|
|
11
|
+
* - Broadcasting messages to all connected peers
|
|
12
|
+
* - Peer lifecycle (connect, disconnect, reconnect)
|
|
13
|
+
*
|
|
14
|
+
* Events:
|
|
15
|
+
* 'peer:connect' — { pubkeyHex, endpoint }
|
|
16
|
+
* 'peer:disconnect' — { pubkeyHex, endpoint }
|
|
17
|
+
* 'peer:message' — { pubkeyHex, message }
|
|
18
|
+
* 'peer:error' — { pubkeyHex, error }
|
|
19
|
+
*/
|
|
20
|
+
export class PeerManager extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
*/
|
|
24
|
+
constructor () {
|
|
25
|
+
super()
|
|
26
|
+
/** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
|
|
27
|
+
this.peers = new Map()
|
|
28
|
+
this._server = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Connect to a discovered peer (outbound).
|
|
33
|
+
*
|
|
34
|
+
* @param {object} peer - Peer from buildPeerList()
|
|
35
|
+
* @param {string} peer.pubkeyHex
|
|
36
|
+
* @param {string} peer.endpoint
|
|
37
|
+
* @returns {PeerConnection|null} The connection, or null if already connected
|
|
38
|
+
*/
|
|
39
|
+
connectToPeer (peer) {
|
|
40
|
+
if (this.peers.has(peer.pubkeyHex)) {
|
|
41
|
+
return this.peers.get(peer.pubkeyHex)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const conn = new PeerConnection({
|
|
45
|
+
endpoint: peer.endpoint,
|
|
46
|
+
pubkeyHex: peer.pubkeyHex
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
this._attachPeerEvents(conn)
|
|
50
|
+
this.peers.set(peer.pubkeyHex, conn)
|
|
51
|
+
conn.connect()
|
|
52
|
+
|
|
53
|
+
return conn
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Accept an inbound peer connection.
|
|
58
|
+
*
|
|
59
|
+
* @param {WebSocket} socket - Incoming WebSocket
|
|
60
|
+
* @param {string} pubkeyHex - Peer's pubkey (from handshake)
|
|
61
|
+
* @param {string} endpoint - Peer's advertised endpoint
|
|
62
|
+
* @returns {PeerConnection|null}
|
|
63
|
+
*/
|
|
64
|
+
acceptPeer (socket, pubkeyHex, endpoint) {
|
|
65
|
+
if (this.peers.has(pubkeyHex)) {
|
|
66
|
+
// Already connected — close the duplicate
|
|
67
|
+
socket.close()
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const conn = new PeerConnection({
|
|
72
|
+
endpoint,
|
|
73
|
+
pubkeyHex,
|
|
74
|
+
socket
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
this._attachPeerEvents(conn)
|
|
78
|
+
this.peers.set(pubkeyHex, conn)
|
|
79
|
+
|
|
80
|
+
return conn
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Disconnect a specific peer.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} pubkeyHex
|
|
87
|
+
*/
|
|
88
|
+
disconnectPeer (pubkeyHex) {
|
|
89
|
+
const conn = this.peers.get(pubkeyHex)
|
|
90
|
+
if (conn) {
|
|
91
|
+
conn.destroy()
|
|
92
|
+
this.peers.delete(pubkeyHex)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Broadcast a message to all connected peers.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} msg - JSON message with `type` field
|
|
100
|
+
* @param {string} [excludePubkey] - Optional pubkey to exclude (e.g. message source)
|
|
101
|
+
* @returns {number} Number of peers the message was sent to
|
|
102
|
+
*/
|
|
103
|
+
broadcast (msg, excludePubkey) {
|
|
104
|
+
let sent = 0
|
|
105
|
+
for (const [pubkeyHex, conn] of this.peers) {
|
|
106
|
+
if (pubkeyHex === excludePubkey) continue
|
|
107
|
+
if (conn.send(msg)) sent++
|
|
108
|
+
}
|
|
109
|
+
return sent
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get count of currently connected peers.
|
|
114
|
+
* @returns {number}
|
|
115
|
+
*/
|
|
116
|
+
connectedCount () {
|
|
117
|
+
let count = 0
|
|
118
|
+
for (const conn of this.peers.values()) {
|
|
119
|
+
if (conn.connected) count++
|
|
120
|
+
}
|
|
121
|
+
return count
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Start a WebSocket server for inbound connections.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} opts
|
|
128
|
+
* @param {number} opts.port - Port to listen on
|
|
129
|
+
* @param {string} [opts.host='0.0.0.0'] - Host to bind to
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
startServer (opts) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
this._server = new WebSocketServer({
|
|
135
|
+
port: opts.port,
|
|
136
|
+
host: opts.host || '0.0.0.0'
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
this._server.on('listening', () => resolve())
|
|
140
|
+
this._server.on('error', (err) => reject(err))
|
|
141
|
+
|
|
142
|
+
this._server.on('connection', (ws) => {
|
|
143
|
+
// Inbound connections: full challenge-response handshake if available,
|
|
144
|
+
// otherwise fall back to basic hello exchange.
|
|
145
|
+
const timeout = setTimeout(() => {
|
|
146
|
+
ws.close() // No hello within 10 seconds
|
|
147
|
+
}, 10000)
|
|
148
|
+
|
|
149
|
+
ws.once('message', (data) => {
|
|
150
|
+
clearTimeout(timeout)
|
|
151
|
+
try {
|
|
152
|
+
const msg = JSON.parse(data.toString())
|
|
153
|
+
if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
|
|
154
|
+
ws.close()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// If cryptographic handshake is available, use it
|
|
159
|
+
if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
|
|
160
|
+
const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
|
|
161
|
+
const result = opts.handshake.handleHello(msg, isSeed ? null : opts.registeredPubkeys)
|
|
162
|
+
if (result.error) {
|
|
163
|
+
ws.send(JSON.stringify({ type: 'error', error: result.error }))
|
|
164
|
+
ws.close()
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Send challenge_response
|
|
169
|
+
ws.send(JSON.stringify(result.message))
|
|
170
|
+
|
|
171
|
+
// Wait for verify
|
|
172
|
+
const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
|
|
173
|
+
ws.once('message', (data2) => {
|
|
174
|
+
clearTimeout(verifyTimeout)
|
|
175
|
+
try {
|
|
176
|
+
const verifyMsg = JSON.parse(data2.toString())
|
|
177
|
+
const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
|
|
178
|
+
if (verifyResult.error) {
|
|
179
|
+
ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
|
|
180
|
+
ws.close()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tie-break duplicate connections by pubkey
|
|
185
|
+
const existing = this.peers.get(result.peerPubkey)
|
|
186
|
+
if (existing) {
|
|
187
|
+
// If existing connection is dead (reconnecting), prefer new working connection
|
|
188
|
+
if (!existing.connected) {
|
|
189
|
+
existing._shouldReconnect = false
|
|
190
|
+
existing.destroy()
|
|
191
|
+
this.peers.delete(result.peerPubkey)
|
|
192
|
+
} else if (opts.pubkeyHex < result.peerPubkey) {
|
|
193
|
+
// Lower pubkey keeps outbound — reject this inbound
|
|
194
|
+
ws.close()
|
|
195
|
+
return
|
|
196
|
+
} else {
|
|
197
|
+
// Higher pubkey keeps inbound — drop existing outbound
|
|
198
|
+
existing._shouldReconnect = false
|
|
199
|
+
existing.destroy()
|
|
200
|
+
this.peers.delete(result.peerPubkey)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Handshake complete — accept peer
|
|
205
|
+
// Learn seed pubkeys so they pass registry check on future connections
|
|
206
|
+
if (isSeed && opts.registeredPubkeys && !opts.registeredPubkeys.has(result.peerPubkey)) {
|
|
207
|
+
opts.registeredPubkeys.add(result.peerPubkey)
|
|
208
|
+
}
|
|
209
|
+
const conn = this.acceptPeer(ws, result.peerPubkey, msg.endpoint)
|
|
210
|
+
if (conn) {
|
|
211
|
+
this.emit('peer:connect', { pubkeyHex: result.peerPubkey, endpoint: msg.endpoint })
|
|
212
|
+
conn.emit('message', msg)
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
ws.close()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
} else {
|
|
219
|
+
// Legacy hello — accept without crypto verification
|
|
220
|
+
const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
|
|
221
|
+
if (conn) {
|
|
222
|
+
if (opts.pubkeyHex && opts.endpoint) {
|
|
223
|
+
ws.send(JSON.stringify({
|
|
224
|
+
type: 'hello',
|
|
225
|
+
pubkey: opts.pubkeyHex,
|
|
226
|
+
endpoint: opts.endpoint
|
|
227
|
+
}))
|
|
228
|
+
}
|
|
229
|
+
this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
|
|
230
|
+
conn.emit('message', msg)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
ws.close()
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Stop the WebSocket server and disconnect all peers.
|
|
243
|
+
*/
|
|
244
|
+
async shutdown () {
|
|
245
|
+
for (const [pubkeyHex, conn] of this.peers) {
|
|
246
|
+
conn.destroy()
|
|
247
|
+
}
|
|
248
|
+
this.peers.clear()
|
|
249
|
+
|
|
250
|
+
if (this._server) {
|
|
251
|
+
await new Promise(resolve => this._server.close(resolve))
|
|
252
|
+
this._server = null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_attachPeerEvents (conn) {
|
|
257
|
+
conn.on('message', (msg) => {
|
|
258
|
+
this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
conn.on('close', () => {
|
|
262
|
+
// Only remove from map if the connection won't auto-reconnect.
|
|
263
|
+
// Keeping reconnecting peers in the map prevents gossip from
|
|
264
|
+
// creating duplicate PeerConnections for the same pubkey.
|
|
265
|
+
if (!conn._shouldReconnect || conn._destroyed) {
|
|
266
|
+
// Only delete if the map still holds THIS connection (not a replacement)
|
|
267
|
+
if (this.peers.get(conn.pubkeyHex) === conn) {
|
|
268
|
+
this.peers.delete(conn.pubkeyHex)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
conn.on('error', (err) => {
|
|
275
|
+
this.emit('peer:error', { pubkeyHex: conn.pubkeyHex, error: err })
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|