@relay-federation/bridge 0.3.9 → 0.3.11
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 +127 -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'
|
|
@@ -11,6 +11,112 @@ import { StatusServer } from './lib/status-server.js'
|
|
|
11
11
|
|
|
12
12
|
const command = process.argv[2]
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Scan historical beacon registrations on startup.
|
|
16
|
+
* Fetches beacon address history from WhatsOnChain and parses each tx
|
|
17
|
+
* to populate the registeredPubkeys Set with all valid registrations.
|
|
18
|
+
*
|
|
19
|
+
* @param {Set<string>} registeredPubkeys - Set to populate with registered pubkeys
|
|
20
|
+
* @param {string} selfPubkey - Our own pubkey (skip self-registration)
|
|
21
|
+
*/
|
|
22
|
+
async function backfillBeaconRegistry (registeredPubkeys, selfPubkey) {
|
|
23
|
+
const { BEACON_ADDRESS, PROTOCOL_PREFIX } = await import('@relay-federation/common/protocol')
|
|
24
|
+
const { extractOpReturnData, decodePayload } = await import('@relay-federation/registry/lib/cbor.js')
|
|
25
|
+
const { Transaction } = await import('@bsv/sdk')
|
|
26
|
+
|
|
27
|
+
console.log(` Beacon backfill: scanning ${BEACON_ADDRESS}...`)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Fetch confirmed history (registrations should be confirmed)
|
|
31
|
+
const historyResp = await fetch(
|
|
32
|
+
`https://api.whatsonchain.com/v1/bsv/main/address/${BEACON_ADDRESS}/confirmed/history`,
|
|
33
|
+
{ signal: AbortSignal.timeout(30000) }
|
|
34
|
+
)
|
|
35
|
+
if (!historyResp.ok) {
|
|
36
|
+
console.log(` Beacon backfill: WoC returned ${historyResp.status}, skipping`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await historyResp.json()
|
|
41
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
42
|
+
if (history.length === 0) {
|
|
43
|
+
console.log(` Beacon backfill: no history found`)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(` Beacon backfill: found ${history.length} transactions`)
|
|
48
|
+
|
|
49
|
+
// Track registrations and deregistrations to handle order correctly
|
|
50
|
+
// Process oldest first (history is typically newest-first from WoC)
|
|
51
|
+
const sortedHistory = [...history].sort((a, b) => (a.height || 0) - (b.height || 0))
|
|
52
|
+
|
|
53
|
+
let registered = 0
|
|
54
|
+
let deregistered = 0
|
|
55
|
+
let skipped = 0
|
|
56
|
+
|
|
57
|
+
for (const item of sortedHistory) {
|
|
58
|
+
const txid = item.tx_hash
|
|
59
|
+
if (!txid) continue
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Rate limit to avoid WoC throttling
|
|
63
|
+
await new Promise(r => setTimeout(r, 100))
|
|
64
|
+
|
|
65
|
+
const txResp = await fetch(
|
|
66
|
+
`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`,
|
|
67
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
68
|
+
)
|
|
69
|
+
if (!txResp.ok) {
|
|
70
|
+
skipped++
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawHex = await txResp.text()
|
|
75
|
+
const tx = Transaction.fromHex(rawHex)
|
|
76
|
+
|
|
77
|
+
// Find OP_RETURN output
|
|
78
|
+
const opReturnOutput = tx.outputs.find(out =>
|
|
79
|
+
out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
|
|
80
|
+
)
|
|
81
|
+
if (!opReturnOutput) {
|
|
82
|
+
skipped++
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
|
|
87
|
+
if (prefix !== PROTOCOL_PREFIX) {
|
|
88
|
+
skipped++
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const entry = decodePayload(cborBytes)
|
|
93
|
+
if (!entry || !entry.pubkey) {
|
|
94
|
+
skipped++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pubHex = Buffer.from(entry.pubkey).toString('hex')
|
|
99
|
+
if (pubHex === selfPubkey) continue // skip self
|
|
100
|
+
|
|
101
|
+
if (entry.action === 'register') {
|
|
102
|
+
registeredPubkeys.add(pubHex)
|
|
103
|
+
registered++
|
|
104
|
+
} else if (entry.action === 'deregister') {
|
|
105
|
+
registeredPubkeys.delete(pubHex)
|
|
106
|
+
deregistered++
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
skipped++
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(` Beacon backfill: +${registered} registered, -${deregistered} deregistered, ${skipped} skipped`)
|
|
114
|
+
console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys after backfill`)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.log(` Beacon backfill failed: ${err.message}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
14
120
|
switch (command) {
|
|
15
121
|
case 'init':
|
|
16
122
|
await cmdInit()
|
|
@@ -325,7 +431,12 @@ async function cmdStart () {
|
|
|
325
431
|
peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
|
|
326
432
|
peerHealth.recordSeen(pubkeyHex)
|
|
327
433
|
scorer.setStakeAge(pubkeyHex, 7)
|
|
328
|
-
if (endpoint)
|
|
434
|
+
if (endpoint) {
|
|
435
|
+
const updated = gossipManager.updatePeerEndpoint({ pubkeyHex, endpoint, meshId: config.meshId })
|
|
436
|
+
if (updated) {
|
|
437
|
+
console.log(` Endpoint updated: ${pubkeyHex.slice(0, 16)}... → ${endpoint}`)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
329
440
|
})
|
|
330
441
|
|
|
331
442
|
peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
|
|
@@ -429,6 +540,9 @@ async function cmdStart () {
|
|
|
429
540
|
}
|
|
430
541
|
console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
|
|
431
542
|
|
|
543
|
+
// ── 4a-2. Beacon backfill — scan historical registrations on startup ──
|
|
544
|
+
await backfillBeaconRegistry(registeredPubkeys, config.pubkeyHex)
|
|
545
|
+
|
|
432
546
|
// ── 4b. Beacon address watcher — detect on-chain registrations ──
|
|
433
547
|
const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('@relay-federation/registry/lib/cbor.js')
|
|
434
548
|
const { Transaction: BsvTx } = await import('@bsv/sdk')
|
|
@@ -501,17 +615,23 @@ async function cmdStart () {
|
|
|
501
615
|
// Tie-break duplicate connections (inbound may have been accepted during handshake)
|
|
502
616
|
const existing = peerManager.peers.get(result.peerPubkey)
|
|
503
617
|
if (existing && existing !== conn) {
|
|
504
|
-
|
|
618
|
+
// If existing connection is dead, prefer new working connection
|
|
619
|
+
if (!existing.connected) {
|
|
620
|
+
console.log(` Replacing dead connection to ${result.peerPubkey.slice(0, 16)}...`)
|
|
621
|
+
existing._shouldReconnect = false
|
|
622
|
+
existing.destroy()
|
|
623
|
+
} else if (config.pubkeyHex > result.peerPubkey) {
|
|
505
624
|
// Higher pubkey drops outbound — keep existing inbound
|
|
506
625
|
console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
|
|
507
626
|
conn._shouldReconnect = false
|
|
508
627
|
conn.destroy()
|
|
509
628
|
return
|
|
629
|
+
} else {
|
|
630
|
+
// Lower pubkey keeps outbound — drop existing inbound
|
|
631
|
+
console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
|
|
632
|
+
existing._shouldReconnect = false
|
|
633
|
+
existing.destroy()
|
|
510
634
|
}
|
|
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
635
|
}
|
|
516
636
|
|
|
517
637
|
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
|
+
}
|