@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/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: 'indelible',
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
- maxPeers: 20
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 })
@@ -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
+ }
@@ -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
+ }