UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

1,556 lines (1,280 loc) 77.7 kB
/** * @module network * @status Experimental * * This module provides primitives for creating a p2p network. */ import { isBufferLike } from '../util.js' import { Buffer } from '../buffer.js' import { sodium, randomBytes } from '../crypto.js' import { Encryption } from './encryption.js' import { Cache } from './cache.js' import * as NAT from './nat.js' import { Packet, PacketPing, PacketPong, PacketIntro, PacketPublish, PacketStream, PacketSync, PacketJoin, PacketQuery, sha256, VERSION } from './packets.js' export { Packet, sha256, Cache, Encryption, NAT } /** * Retry delay in milliseconds for ping. * @type {number} */ export const PING_RETRY = 500 /** * Probe wait timeout in milliseconds. * @type {number} */ export const PROBE_WAIT = 512 /** * Default keep alive timeout. * @type {number} */ export const DEFAULT_KEEP_ALIVE = 30_000 /** * Default rate limit threshold in milliseconds. * @type {number} */ export const DEFAULT_RATE_LIMIT_THRESHOLD = 8000 const PRIV_PORTS = 1024 const MAX_PORTS = 65535 - PRIV_PORTS const MAX_BANDWIDTH = 1024 * 32 const PEERID_REGEX = /^([A-Fa-f0-9]{2}){32}$/ /** * Port generator factory function. * @param {object} ports - the cache to use (a set) * @param {number?} p - initial port * @return {number} */ export const getRandomPort = (ports = new Set(), p) => { do { p = Math.max(1024, Math.ceil(Math.random() * 0xffff)) } while (ports.has(p) && ports.size < MAX_PORTS) ports.add(p) return p } const isReplicatable = type => ( type === PacketPublish.type || type === PacketJoin.type ) /** * Computes rate limit predicate value for a port and address pair for a given * threshold updating an input rates map. This method is accessed concurrently, * the rates object makes operations atomic to avoid race conditions. * * @param {Map} rates * @param {number} type * @param {number} port * @param {string} address * @return {boolean} */ export function rateLimit (rates, type, port, address, subclusterIdQuota) { const R = isReplicatable(type) const key = (R ? 'R' : 'C') + ':' + address + ':' + port const quota = subclusterIdQuota || (R ? 1024 : 1024 * 1024) const time = Math.floor(Date.now() / 60000) const rate = rates.get(key) || { time, quota, used: 0 } rate.mtime = Date.now() // checked by mainLoop for garabge collection if (time !== rate.time) { rate.time = time if (rate.used > rate.quota) rate.quota -= 1 else if (rate.used < quota) rate.quota += 1 rate.used = 0 } rate.used += 1 rates.set(key, rate) if (rate.used >= rate.quota) return true } /** * A `RemotePeer` represents an initial, discovered, or connected remote peer. * Typically, you will not need to create instances of this class directly. */ export class RemotePeer { peerId = null address = null port = 0 natType = null clusters = {} pingId = null distance = 0 connected = false opening = 0 probed = 0 proxy = null clock = 0 uptime = 0 lastUpdate = 0 lastRequest = 0 localPeer = null /** * `RemotePeer` class constructor. * @param {{ * peerId?: string, * address?: string, * port?: number, * natType?: number, * clusters: object, * reflectionId?: string, * distance?: number, * publicKey?: string, * privateKey?: string, * clock?: number, * lastUpdate?: number, * lastRequest?: number * }} o */ constructor (o, peer) { this.localPeer = peer if (!o.peerId) throw new Error('expected .peerId') if (o.indexed) o.natType = NAT.UNRESTRICTED if (o.natType && !NAT.isValid(o.natType)) throw new Error(`invalid .natType (${o.natType})`) const cid = Buffer.from(o.clusterId || '').toString('base64') const scid = Buffer.from(o.subclusterId || '').toString('base64') if (cid && scid) { this.clusters[cid] = { [scid]: { rateLimit: MAX_BANDWIDTH } } } Object.assign(this, o) } async write (sharedKey, args) { let rinfo = this if (this.proxy) rinfo = this.proxy const keys = await Encryption.createKeyPair(sharedKey) args.subclusterId = keys.publicKey args.clusterId = this.localPeer.clusterId args.usr3 = Buffer.from(this.peerId, 'hex') args.usr4 = Buffer.from(this.localPeer.peerId, 'hex') args.message = this.localPeer.encryption.seal(args.message, keys) const cache = new Map() const packets = await this.localPeer._message2packets(PacketStream, args.message, args) if (this.proxy) { this.localPeer._onDebug(`>> WRITE STREAM HAS PROXY ${this.proxy.address}:${this.proxy.port}`) } for (const packet of packets) { const from = this.localPeer.peerId.slice(0, 6) const to = this.peerId.slice(0, 6) this.localPeer._onDebug(`>> WRITE STREAM (from=${from}, to=${to}, via=${rinfo.address}:${rinfo.port})`) const pid = packet.packetId.toString('hex') cache.set(pid, packet) this.localPeer.gate.set(pid, 1) await this.localPeer.send(await Packet.encode(packet), rinfo.port, rinfo.address, this.socket) } const head = packets.find(p => p.index === 0) // has a head, should compose const p = await this.localPeer.cache.compose(head, cache) return [p] } } /** * `Peer` class factory. * @param {{ createSocket: function('udp4', null, object?): object }} options */ export class Peer { port = null address = null natType = NAT.UNKNOWN nextNatType = NAT.UNKNOWN clusters = {} syncs = {} reflectionId = null reflectionTimeout = null reflectionStage = 0 reflectionRetry = 1 reflectionFirstResponder = null peerId = '' isListening = false ctime = Date.now() lastUpdate = 0 lastSync = 0 closing = false clock = 0 unpublished = {} cache = null uptime = 0 maxHops = 16 bdpCache = /** @type {number[]} */ ([]) dgram = null onListening = null onDelete = null sendQueue = [] firewall = null rates = new Map() streamBuffer = new Map() gate = new Map() returnRoutes = new Map() metrics = { i: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, DROPPED: 0 }, o: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 } } peers = JSON.parse(/* snapshot_start=1691579150299, filter=easy,static */` [{"address":"44.213.42.133","port":10885,"peerId":"4825fe0475c44bc0222e76c5fa7cf4759cd5ef8c66258c039653f06d329a9af5","natType":31,"indexed":true},{"address":"107.20.123.15","port":31503,"peerId":"2de8ac51f820a5b9dc8a3d2c0f27ccc6e12a418c9674272a10daaa609eab0b41","natType":31,"indexed":true},{"address":"54.227.171.107","port":43883,"peerId":"7aa3d21ceb527533489af3888ea6d73d26771f30419578e85fba197b15b3d18d","natType":31,"indexed":true},{"address":"54.157.134.116","port":34420,"peerId":"1d2315f6f16e5f560b75fbfaf274cad28c12eb54bb921f32cf93087d926f05a9","natType":31,"indexed":true},{"address":"184.169.205.9","port":52489,"peerId":"db00d46e23d99befe42beb32da65ac3343a1579da32c3f6f89f707d5f71bb052","natType":31,"indexed":true},{"address":"35.158.123.13","port":31501,"peerId":"4ba1d23266a2d2833a3275c1d6e6f7ce4b8657e2f1b8be11f6caf53d0955db88","natType":31,"indexed":true},{"address":"3.68.89.3","port":22787,"peerId":"448b083bd8a495ce684d5837359ce69d0ff8a5a844efe18583ab000c99d3a0ff","natType":31,"indexed":true},{"address":"3.76.100.161","port":25761,"peerId":"07bffa90d89bf74e06ff7f83938b90acb1a1c5ce718d1f07854c48c6c12cee49","natType":31,"indexed":true},{"address":"3.70.241.230","port":61926,"peerId":"1d7ee8d965794ee286ac425d060bab27698a1de92986dc6f4028300895c6aa5c","natType":31,"indexed":true},{"address":"3.70.160.181","port":41141,"peerId":"707c07171ac9371b2f1de23e78dad15d29b56d47abed5e5a187944ed55fc8483","natType":31,"indexed":true},{"address":"3.122.250.236","port":64236,"peerId":"a830615090d5cdc3698559764e853965a0d27baad0e3757568e6c7362bc6a12a","natType":31,"indexed":true},{"address":"18.130.98.23","port":25111,"peerId":"ba483c1477ab7a99de2d9b60358d9641ff6a6dc6ef4e3d3e1fc069b19ac89da4","natType":31,"indexed":true},{"address":"13.42.10.247","port":2807,"peerId":"032b79de5b4581ee39c6d15b12908171229a8eb1017cf68fd356af6bbbc21892","natType":31,"indexed":true},{"address":"18.229.140.216","port":36056,"peerId":"73d726c04c05fb3a8a5382e7a4d7af41b4e1661aadf9020545f23781fefe3527","natType":31,"indexed":true}] `/* snapshot_end=1691579150299 */).map((/** @type {object} */ o) => new RemotePeer({ ...o, indexed: true }, this)) /** * `Peer` class constructor. * @param {object=} opts - Options * @param {Buffer} opts.peerId - A 32 byte buffer (ie, `Encryption.createId()`). * @param {Buffer} opts.clusterId - A 32 byte buffer (ie, `Encryption.createClusterId()`). * @param {number=} opts.port - A port number. * @param {number=} opts.probeInternalPort - An internal port number (semi-private for testing). * @param {number=} opts.probeExternalPort - An external port number (semi-private for testing). * @param {number=} opts.natType - A nat type. * @param {string=} opts.address - An ipv4 address. * @param {number=} opts.keepalive - The interval of the main loop. * @param {function=} opts.siblingResolver - A function that can be used to determine canonical data in case two packets have concurrent clock values. * @param {object} dgram - A nodejs compatible implementation of the dgram module (sans multicast). */ constructor (persistedState = {}, dgram) { if (!dgram) { throw new Error('dgram implementation required in constructor as second argument') } this.dgram = dgram const config = persistedState?.config ?? persistedState ?? {} this.encryption = new Encryption() if (!config.peerId) throw new Error('constructor expected .peerId') if (!Peer.isValidPeerId(config.peerId)) throw new Error(`invalid .peerId (${config.peerId})`) // // The purpose of this.config is to seperate transitioned state from initial state. // this.config = { // TODO(@heapwolf): Object.freeze this maybe keepalive: DEFAULT_KEEP_ALIVE, ...config } let cacheData if (persistedState?.data?.length > 0) { cacheData = new Map(persistedState.data) } this.cache = new Cache(cacheData, config.siblingResolver) this.cache.onEjected = p => this.mcast(p) this.unpublished = persistedState?.unpublished || {} this._onError = err => this.onError && this.onError(err) Object.assign(this, config) if (!this.indexed && !this.clusterId) throw new Error('constructor expected .clusterId') if (typeof this.peerId !== 'string') throw new Error('peerId should be of type string') this.port = config.port || null this.natType = config.natType || null this.address = config.address || null this.socket = this.dgram.createSocket('udp4', null, this) this.probeSocket = this.dgram.createSocket('udp4', null, this).unref() const isRecoverable = err => err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EADDRINUSE' || err.code === 'ETIMEDOUT' this.socket.on('error', err => isRecoverable(err) && this._listen()) this.probeSocket.on('error', err => isRecoverable(err) && this._listen()) } /** * An implementation for clearning an interval that can be overridden by the test suite * @param Number the number that identifies the timer * @return {undefined} * @ignore */ _clearInterval (tid) { clearInterval(tid) } /** * An implementation for clearning a timeout that can be overridden by the test suite * @param Number the number that identifies the timer * @return {undefined} * @ignore */ _clearTimeout (tid) { clearTimeout(tid) } /** * An implementation of an internal timer that can be overridden by the test suite * @return {Number} * @ignore */ _setInterval (fn, t) { return setInterval(fn, t) } /** * An implementation of an timeout timer that can be overridden by the test suite * @return {Number} * @ignore */ _setTimeout (fn, t) { return setTimeout(fn, t) } _onDebug (...args) { if (this.onDebug) this.onDebug(this.peerId, ...args) } /** * A method that encapsulates the listing procedure * @return {undefined} * @ignore */ async _listen () { await sodium.ready this.socket.removeAllListeners() this.probeSocket.removeAllListeners() this.socket.on('message', (...args) => this._onMessage(...args)) this.socket.on('error', (...args) => this._onError(...args)) this.probeSocket.on('message', (...args) => this._onProbeMessage(...args)) this.probeSocket.on('error', (...args) => this._onError(...args)) this.socket.setMaxListeners(2048) this.probeSocket.setMaxListeners(2048) const listening = Promise.all([ new Promise(resolve => this.socket.on('listening', resolve)), new Promise(resolve => this.probeSocket.on('listening', resolve)) ]) this.socket.bind(this.config.port || 0) this.probeSocket.bind(this.config.probeInternalPort || 0) await listening this.config.port = this.socket.address().port this.config.probeInternalPort = this.probeSocket.address().port if (this.onListening) this.onListening() this.isListening = true this._onDebug(`++ INIT (config.internalPort=${this.config.port}, config.probeInternalPort=${this.config.probeInternalPort})`) } /* * This method will bind the sockets, begin pinging known peers, and start * the main program loop. * @return {Any} */ async init (cb) { if (!this.isListening) await this._listen() if (cb) this.onReady = cb this._mainLoop(Date.now()) this.mainLoopTimer = this._setInterval(ts => this._mainLoop(ts), this.config.keepalive) if (this.indexed && this.onReady) return this.onReady(await this.getInfo()) } /** * Continuously evaluate the state of the peer and its network * @return {undefined} * @ignore */ async _mainLoop (ts) { if (this.closing) return this._clearInterval(this.mainLoopTimer) if (!Peer.onLine()) { if (this.onConnecting) this.onConnecting({ code: -2, status: 'Offline' }) return true } if (!this.reflectionId) this.requestReflection() if (this.onInterval) this.onInterval() this.uptime += this.config.keepalive // heartbeat for (const [, peer] of Object.entries(this.peers)) { this.ping(peer, false, { message: { requesterPeerId: this.peerId, natType: this.natType } }) } // wait for nat type to be discovered if (!NAT.isValid(this.natType)) return true for (const [k, packet] of [...this.cache.data]) { const p = Packet.from(packet) if (!p) continue if (!p.timestamp) p.timestamp = ts const clusterId = p.clusterId.toString('base64') const mult = this.clusters[clusterId] ? 2 : 1 const ttl = (p.ttl < Packet.ttl) ? p.ttl : Packet.ttl * mult const deadline = p.timestamp + ttl if (deadline <= ts) { if (p.hops < this.maxHops) this.mcast(p) this.cache.delete(k) this._onDebug('-- DELETE', k, this.cache.size) if (this.onDelete) this.onDelete(p) } } for (let [k, v] of this.gate.entries()) { v -= 1 if (!v) this.gate.delete(k) else this.gate.set(k, v) } for (let [k, v] of this.returnRoutes.entries()) { v -= 1 if (!v) this.returnRoutes.delete(k) else this.returnRoutes.set(k, v) } // prune peer list for (const [i, peer] of Object.entries(this.peers)) { if (peer.indexed) continue const expired = (peer.lastUpdate + this.config.keepalive) < Date.now() if (expired) { // || !NAT.isValid(peer.natType)) { const p = this.peers.splice(i, 1) if (this.onDisconnection) this.onDisconnection(p) continue } } // if this peer has previously tried to join any clusters, multicast a // join messages for each into the network so we are always searching. for (const cluster of Object.values(this.clusters)) { for (const subcluster of Object.values(cluster)) { this.join(subcluster.sharedKey, subcluster) } } return true } /** * Enqueue packets to be sent to the network * @param {Buffer} data - An encoded packet * @param {number} port - The desination port of the remote host * @param {string} address - The destination address of the remote host * @param {Socket=this.socket} socket - The socket to send on * @return {undefined} * @ignore */ send (data, port, address, socket = this.socket) { this.sendQueue.push({ data, port, address, socket }) this._scheduleSend() } /** * @private */ async stream (peerId, sharedKey, args) { const p = this.peers.find(p => p.peerId === peerId) if (p) return p.write(sharedKey, args) } /** * @private */ _scheduleSend () { if (this.sendTimeout) this._clearTimeout(this.sendTimeout) this.sendTimeout = this._setTimeout(() => { this._dequeue() }) } /** * @private */ _dequeue () { if (!this.sendQueue.length) return const { data, port, address, socket } = this.sendQueue.shift() socket.send(data, port, address, err => { if (this.sendQueue.length) this._scheduleSend() if (err) return this._onError(err) const packet = Packet.decode(data) if (!packet) return this.metrics.o[packet.type]++ delete this.unpublished[packet.packetId.toString('hex')] if (this.onSend && packet.type) this.onSend(packet, port, address) this._onDebug(`>> SEND (from=${this.address}:${this.port}, to=${address}:${port}, type=${packet.type})`) }) } /** * Send any unpublished packets * @return {undefined} * @ignore */ async sendUnpublished () { for (const [packetId] of Object.entries(this.unpublished)) { const packet = this.cache.get(packetId) if (!packet) { // it may have been purged already delete this.unpublished[packetId] continue } await this.mcast(packet) this._onDebug(`-> RESEND (packetId=${packetId})`) if (this.onState) this.onState() } } /** * Get the serializable state of the peer (can be passed to the constructor or create method) * @return {undefined} */ getState () { this.config.clock = this.clock // save off the clock const peers = this.peers.map(p => { p = { ...p } delete p.localPeer return p }) return { peers, syncs: this.syncs, config: this.config, data: [...this.cache.data.entries()], unpublished: this.unpublished } } async getInfo () { return { address: this.address, port: this.port, clock: this.clock, uptime: this.uptime, natType: this.natType, natName: NAT.toString(this.natType), peerId: this.peerId } } async cacheInsert (packet) { const p = Packet.from(packet) this.cache.insert(p.packetId.toString('hex'), p) } async addIndexedPeer (info) { if (!info.peerId) throw new Error('options.peerId required') if (!info.address) throw new Error('options.address required') if (!info.port) throw new Error('options.port required') info.indexed = true this.peers.push(new RemotePeer(info)) } async reconnect () { for (const cluster of Object.values(this.clusters)) { for (const subcluster of Object.values(cluster)) { this.join(subcluster.sharedKey, subcluster) } } } async disconnect () { this.natType = null this.reflectionStage = 0 this.reflectionId = null this.reflectionTimeout = null this.probeReflectionTimeout = null } async sealUnsigned (...args) { return this.encryption.sealUnsigned(...args) } async openUnsigned (...args) { return this.encryption.openUnsigned(...args) } async seal (...args) { return this.encryption.seal(...args) } async open (...args) { return this.encryption.open(...args) } async addEncryptionKey (...args) { return this.encryption.add(...args) } /** * Get a selection of known peers * @return {Array<RemotePeer>} * @ignore */ getPeers (packet, peers, ignorelist, filter = o => o) { const rand = () => Math.random() - 0.5 const base = p => { if (ignorelist.findIndex(ilp => (ilp.port === p.port) && (ilp.address === p.address)) > -1) return false if (p.lastUpdate === 0) return false if (p.lastUpdate < Date.now() - (this.config.keepalive * 4)) return false if (this.peerId === p.peerId) return false // same as me if (packet.message.requesterPeerId === p.peerId) return false // same as requester - @todo: is this true in all cases? if (!p.port || !NAT.isValid(p.natType)) return false return true } const candidates = peers .filter(filter) .filter(base) .sort(rand) const list = candidates.slice(0, 3) if (!list.some(p => p.indexed)) { const indexed = candidates.filter(p => p.indexed && !list.includes(p)) if (indexed.length) list.push(indexed[0]) } const clusterId = packet.clusterId.toString('base64') const friends = candidates.filter(p => p.clusters && p.clusters[clusterId] && !list.includes(p)) if (friends.length) { list.unshift(friends[0]) list.unshift(...candidates.filter(c => c.address === friends[0].address && c.peerId === friends[0].peerId)) } return list } /** * Send an eventually consistent packet to a selection of peers (fanout) * @return {undefined} * @ignore */ async mcast (packet, ignorelist = []) { const peers = this.getPeers(packet, this.peers, ignorelist) const pid = packet.packetId.toString('hex') packet.hops += 1 for (const peer of peers) { this.send(await Packet.encode(packet), peer.port, peer.address) } if (this.onMulticast) this.onMulticast(packet) if (this.gate.has(pid)) return this.gate.set(pid, 1) } /** * The process of determining this peer's NAT behavior (firewall and dependentness) * @return {undefined} * @ignore */ async requestReflection () { if (this.closing || this.indexed || this.reflectionId) { this._onDebug('<> REFLECT ABORTED', this.reflectionId) return } if (this.natType && (this.lastUpdate > 0 && (Date.now() - this.config.keepalive) < this.lastUpdate)) { this._onDebug(`<> REFLECT NOT NEEDED (last-recv=${Date.now() - this.lastUpdate}ms)`) return } this._onDebug('-> REQ REFLECT', this.reflectionId, this.reflectionStage) if (this.onConnecting) this.onConnecting({ code: -1, status: `Entering reflection (lastUpdate ${Date.now() - this.lastUpdate}ms)` }) const peers = [...this.peers] .filter(p => p.lastUpdate !== 0) .filter(p => p.natType === NAT.UNRESTRICTED || p.natType === NAT.ADDR_RESTRICTED || p.indexed) if (peers.length < 2) { if (this.onConnecting) this.onConnecting({ code: -1, status: 'Not enough pingable peers' }) this._onDebug('XX REFLECT NOT ENOUGH PINGABLE PEERS - RETRYING', peers) // tell all well-known peers that we would like to hear from them, if // we hear from any we can ask for the reflection information we need. for (const peer of this.peers.filter(p => p.indexed).sort(() => Math.random() - 0.5).slice(0, 32)) { await this.ping(peer, false, { message: { isConnection: true, requesterPeerId: this.peerId } }) } if (++this.reflectionRetry > 16) this.reflectionRetry = 1 return this._setTimeout(() => this.requestReflection(), this.reflectionRetry * 256) } this.reflectionRetry = 1 const requesterPeerId = this.peerId const opts = { requesterPeerId, isReflection: true } this.reflectionId = opts.reflectionId = randomBytes(6).toString('hex').padStart(12, '0') if (this.onConnecting) { this.onConnecting({ code: 0.5, status: `Found ${peers.length} elegible peers for reflection` }) } // // # STEP 1 // The purpose of this step is strictily to discover the external port of // the probe socket. // if (this.reflectionStage === 0) { if (this.onConnecting) this.onConnecting({ code: 1, status: 'Discover External Port' }) // start refelection with an zeroed NAT type if (this.reflectionTimeout) this._clearTimeout(this.reflectionTimeout) this.reflectionStage = 1 this._onDebug('-> NAT REFLECT - STAGE1: A', this.reflectionId) const list = peers.filter(p => p.probed).sort(() => Math.random() - 0.5) const peer = list.length ? list[0] : peers[0] peer.probed = Date.now() // mark this peer as being used to provide port info this.ping(peer, false, { message: { ...opts, isProbe: true } }, this.probeSocket) // we expect onMessageProbe to fire and clear this timer or it will timeout this.probeReflectionTimeout = this._setTimeout(() => { this.probeReflectionTimeout = null if (this.reflectionStage !== 1) return this._onDebug('XX NAT REFLECT - STAGE1: C - TIMEOUT', this.reflectionId) if (this.onConnecting) this.onConnecting({ code: 1, status: 'Timeout' }) this.reflectionStage = 1 this.reflectionId = null this.requestReflection() }, 1024) this._onDebug('-> NAT REFLECT - STAGE1: B', this.reflectionId) return } // // # STEP 2 // // The purpose of step 2 is twofold: // // 1) ask two different peers for the external port and address for our primary socket. // If they are different, we can determine that our NAT is a `ENDPOINT_DEPENDENT`. // // 2) ask the peers to also reply to our probe socket from their probe socket. // These packets will both be dropped for `FIREWALL_ALLOW_KNOWN_IP_AND_PORT` and will both // arrive for `FIREWALL_ALLOW_ANY`. If one packet arrives (which will always be from the peer // which was previously probed), this indicates `FIREWALL_ALLOW_KNOWN_IP`. // if (this.reflectionStage === 1) { this.reflectionStage = 2 const { probeExternalPort } = this.config if (this.onConnecting) this.onConnecting({ code: 1.5, status: 'Discover NAT' }) // peer1 is the most recently probed (likely the same peer used in step1) // using the most recent guarantees that the the NAT mapping is still open const peer1 = peers.filter(p => p.probed).sort((a, b) => b.probed - a.probed)[0] // peer has NEVER previously been probed const peer2 = peers.filter(p => !p.probed).sort(() => Math.random() - 0.5)[0] if (!peer1 || !peer2) { this._onDebug('XX NAT REFLECT - STAGE2: INSUFFICENT PEERS - RETRYING') if (this.onConnecting) this.onConnecting({ code: 1.5, status: 'Insufficent Peers' }) return this._setTimeout(() => this.requestReflection(), 256) } this._onDebug('-> NAT REFLECT - STAGE2: START', this.reflectionId) // reset reflection variables to defaults this.nextNatType = NAT.UNKNOWN this.reflectionFirstResponder = null this.ping(peer1, false, { message: { ...opts, probeExternalPort } }) this.ping(peer2, false, { message: { ...opts, probeExternalPort } }) if (this.onConnecting) { this.onConnecting({ code: 2, status: `Requesting reflection from ${peer1.address}` }) this.onConnecting({ code: 2, status: `Requesting reflection from ${peer2.address}` }) } if (this.reflectionTimeout) { this._clearTimeout(this.reflectionTimeout) this.reflectionTimeout = null } this.reflectionTimeout = this._setTimeout(ts => { this.reflectionTimeout = null if (this.reflectionStage !== 2) return if (this.onConnecting) this.onConnecting({ code: 2, status: 'Timeout' }) this.reflectionStage = 1 this.reflectionId = null this._onDebug('XX NAT REFLECT - STAGE2: TIMEOUT', this.reflectionId) return this.requestReflection() }, 2048) } } /** * Ping another peer * @return {PacketPing} * @ignore */ async ping (peer, withRetry, props, socket) { if (!peer) { return } props.message.requesterPeerId = this.peerId props.message.uptime = this.uptime props.message.timestamp = Date.now() props.clusterId = this.config.clusterId const packet = new PacketPing(props) const data = await Packet.encode(packet) const send = async () => { if (this.closing) return false const p = this.peers.find(p => p.peerId === peer.peerId) // if (p?.reflectionId && p.reflectionId === packet.message.reflectionId) { // return false // } this.send(data, peer.port, peer.address, socket) if (p) p.lastRequest = Date.now() } send() if (withRetry) { this._setTimeout(send, PING_RETRY) this._setTimeout(send, PING_RETRY * 4) } return packet } /** * Get a peer * @return {RemotePeer} * @ignore */ getPeer (id) { return this.peers.find(p => p.peerId === id) } /** * This should be called at least once when an app starts to multicast * this peer, and starts querying the network to discover peers. * @param {object} keys - Created by `Encryption.createKeyPair()`. * @param {object=} args - Options * @param {number=MAX_BANDWIDTH} args.rateLimit - How many requests per second to allow for this subclusterId. * @return {RemotePeer} */ async join (sharedKey, args = { rateLimit: MAX_BANDWIDTH }) { const keys = await Encryption.createKeyPair(sharedKey) this.encryption.add(keys.publicKey, keys.privateKey) if (!this.port || !this.natType) return args.sharedKey = sharedKey const clusterId = args.clusterId || this.config.clusterId const subclusterId = keys.publicKey const cid = Buffer.from(clusterId || '').toString('base64') const scid = Buffer.from(subclusterId || '').toString('base64') this.clusters[cid] ??= {} this.clusters[cid][scid] = args this.clock += 1 const packet = new PacketJoin({ clock: this.clock, clusterId, subclusterId, message: { requesterPeerId: this.peerId, natType: this.natType, address: this.address, port: this.port, key: [cid, scid].join(':') } }) this._onDebug(`-> JOIN (clusterId=${cid.slice(0, 6)}, subclusterId=${scid.slice(0, 6)}, clock=${packet.clock}/${this.clock})`) if (this.onState) this.onState() this.mcast(packet) this.gate.set(packet.packetId.toString('hex'), 1) } /** * @param {Packet} T - The constructor to be used to create packets. * @param {Any} message - The message to be split and packaged. * @return {Array<Packet<T>>} * @ignore */ async _message2packets (T, message, args) { const { clusterId, subclusterId, packet, nextId, meta = {}, usr1, usr2, sig } = args let messages = [message] const len = message?.byteLength ?? message?.length ?? 0 let clock = packet?.clock || 0 const siblings = packet && [...this.cache.data.values()] .filter(Boolean) .filter(p => { if (!p.previousId || !packet.packetId) return false return Buffer.from(p.previousId).compare(Buffer.from(packet.packetId)) === 0 }) if (siblings?.length) { // if there are siblings of the previous packet // pick the highest clock value, the parent packet or the sibling const sort = (a, b) => a.clock - b.clock const sib = siblings.sort(sort).reverse()[0] clock = Math.max(clock, sib.clock) + 1 } clock += 1 if (len > 1024) { // Split packets that have messages bigger than Packet.maxLength messages = [{ meta, ts: Date.now(), size: message.length, indexes: Math.ceil(message.length / 1024) }] let pos = 0 while (pos < message.length) messages.push(message.slice(pos, pos += 1024)) } // turn each message into an actual packet const packets = messages.map(message => new T({ ...args, clusterId, subclusterId, clock, message, usr1, usr2, usr3: args.usr3, usr4: args.usr4, sig })) if (packet) packets[0].previousId = Buffer.from(packet.packetId) if (nextId) packets[packets.length - 1].nextId = Buffer.from(nextId) // set the .packetId (any maybe the .previousId and .nextId) for (let i = 0; i < packets.length; i++) { if (packets.length > 1) packets[i].index = i if (i === 0) { packets[0].packetId = await sha256(packets[0].message, { bytes: true }) } else { // all fragments will have the same previous packetId // the index is used to stitch them back together in order. packets[i].previousId = Buffer.from(packets[0].packetId) } if (packets[i + 1]) { packets[i + 1].packetId = await sha256( Buffer.concat([ await sha256(packets[i].packetId, { bytes: true }), await sha256(packets[i + 1].message, { bytes: true }) ]), { bytes: true } ) packets[i].nextId = Buffer.from(packets[i + 1].packetId) } } return packets } /** * Sends a packet into the network that will be replicated and buffered. * Each peer that receives it will buffer it until TTL and then replicate * it provided it has has not exceeded their maximum number of allowed hops. * * @param {object} keys - the public and private key pair created by `Encryption.createKeyPair()`. * @param {object} args - The arguments to be applied. * @param {Buffer} args.message - The message to be encrypted by keys and sent. * @param {Packet<T>=} args.packet - The previous packet in the packet chain. * @param {Buffer} args.usr1 - 32 bytes of arbitrary clusterId in the protocol framing. * @param {Buffer} args.usr2 - 32 bytes of arbitrary clusterId in the protocol framing. * @return {Array<PacketPublish>} */ async publish (sharedKey, args) { // wtf to do here, we need subclusterId and the actual user keys if (!sharedKey) throw new Error('.publish() expected "sharedKey" argument in first position') if (!isBufferLike(args.message)) throw new Error('.publish() will only accept a message of type buffer') const keys = await Encryption.createKeyPair(sharedKey) args.subclusterId = keys.publicKey args.clusterId = args.clusterId || this.config.clusterId const cache = new Map() const message = this.encryption.seal(args.message, keys) const packets = await this._message2packets(PacketPublish, message, args) for (let packet of packets) { packet = Packet.from(packet) cache.set(packet.packetId.toString('hex'), packet) this.cacheInsert(packet) if (this.onPacket && packet.index === -1) { this.onPacket(packet, this.port, this.address, true) } this.unpublished[packet.packetId.toString('hex')] = Date.now() if (!Peer.onLine()) continue this.mcast(packet) } const head = [...cache.values()][0] // if there is a head, we can recompose the packets, this gives this // peer a consistent view of the data as it has been published. if (this.onPacket && head && head.index === 0) { const p = await this.cache.compose(head, cache) if (p) { this.onPacket(p, this.port, this.address, true) this._onDebug(`-> PUBLISH (multicasted=true, packetId=${p.packetId.toString('hex').slice(0, 8)})`) return [p] } } return packets } /** * @return {undefined} */ async sync (peer, ptime = Date.now()) { if (typeof peer === 'string') { peer = this.peers.find(p => p.peerId === peer) } const rinfo = peer?.proxy || peer this.lastSync = Date.now() const summary = await this.cache.summarize('', this.cachePredicate(ptime)) this._onDebug(`-> SYNC START (dest=${peer.peerId.slice(0, 8)}, to=${rinfo.address}:${rinfo.port})`) if (this.onSyncStart) this.onSyncStart(peer, rinfo.port, rinfo.address) // if we are out of sync send our cache summary const data = await Packet.encode(new PacketSync({ message: Cache.encodeSummary(summary), usr4: Buffer.from(String(ptime)) })) this.send(data, rinfo.port, rinfo.address, peer.socket) } close () { this._clearInterval(this.mainLoopTimer) if (this.closing) return this.closing = true this.socket.close() this.probeSocket.close() if (this.onClose) this.onClose() } /** * Deploy a query into the network * @return {undefined} * */ async query (query) { const packet = new PacketQuery({ message: query, usr1: Buffer.from(String(Date.now())), usr3: Buffer.from(randomBytes(32)), usr4: Buffer.from(String(1)) }) const data = await Packet.encode(packet) const p = Packet.decode(data) // finalize a packet const pid = p.packetId.toString('hex') if (this.gate.has(pid)) return this.returnRoutes.set(p.usr3.toString('hex'), {}) this.gate.set(pid, 1) // prevent accidental spam this._onDebug(`-> QUERY (type=question, query=${query}, packet=${pid.slice(0, 8)})`) await this.mcast(p) } /** * * This is a default implementation for deciding what to summarize * from the cache when receiving a request to sync. that can be overridden * */ cachePredicate (ts) { const max = Date.now() - Packet.ttl const T = Math.min(ts || max, max) return packet => { return packet.version === VERSION && packet.timestamp > T } } /** * A connection was made, add the peer to the local list of known * peers and call the onConnection if it is defined by the user. * * @return {undefined} * @ignore */ async _onConnection (packet, peerId, port, address, proxy, socket) { if (this.closing) return const natType = packet.message.natType if (!NAT.isValid(natType)) return if (!Peer.isValidPeerId(peerId)) return if (peerId === this.peerId) return const cid = packet.clusterId.toString('base64') const scid = packet.subclusterId.toString('base64') let peer = this.getPeer(peerId) const firstContact = !peer if (firstContact) { peer = new RemotePeer({ peerId }) if (this.peers.length >= 256) { // TODO evicting an older peer definitely needs some more thought. const oldPeerIndex = this.peers.findIndex(p => !p.lastUpdate && !p.indexed) if (oldPeerIndex > -1) this.peers.splice(oldPeerIndex, 1) } this._onDebug(`<- CONNECTION ADDING PEER (id=${peer.peerId}, address=${address}:${port})`) this.peers.push(peer) } peer.connected = true peer.lastUpdate = Date.now() peer.port = port peer.natType = natType peer.address = address if (proxy) peer.proxy = proxy if (socket) peer.socket = socket if (cid) peer.clusters[cid] ??= {} if (cid && scid) { const cluster = peer.clusters[cid] cluster[scid] = { rateLimit: MAX_BANDWIDTH } } if (!peer.localPeer) peer.localPeer = this this._onDebug('<- CONNECTION (' + `peerId=${peer.peerId.slice(0, 6)}, ` + `address=${address}:${port}, ` + `type=${packet.type}, ` + `clusterId=${cid.slice(0, 6)}, ` + `subclusterId=${scid.slice(0, 6)})` ) if (this.onJoin && this.clusters[cid]) { this.onJoin(packet, peer, port, address) } if (firstContact && this.onConnection) { this.onConnection(packet, peer, port, address) const now = Date.now() const key = [peer.address, peer.port].join(':') let first = false // // If you've never sync'd before, you can ask for 6 hours of data from // other peers. If we have synced with a peer before we can just ask for // data that they have seen since then, this will avoid the risk of // spamming them and getting rate-limited. // if (!this.syncs[key]) { this.syncs[key] = now - Packet.ttl first = true } const lastSyncSeconds = (now - this.syncs[key]) / 1000 const syncWindow = this.config.syncWindow ?? 6000 if (first || now - this.syncs[key] > syncWindow) { this.sync(peer.peerId, this.syncs[key]) this._onDebug(`-> SYNC SEND (peerId=${peer.peerId.slice(0, 6)}, address=${key}, since=${lastSyncSeconds} seconds ago)`) this.syncs[key] = now } } } /** * Received a Sync Packet * @return {undefined} * @ignore */ async _onSync (packet, port, address) { this.metrics.i[packet.type]++ this.lastSync = Date.now() const pid = packet.packetId.toString('hex') let ptime = Date.now() if (packet.usr4.byteLength > 8 || packet.usr4.byteLength < 16) { const usr4 = parseInt(Buffer.from(packet.usr4).toString(), 10) ptime = Math.min(ptime - Packet.ttl, usr4) } if (!isBufferLike(packet.message)) return if (this.gate.has(pid)) return this.gate.set(pid, 1) const remote = Cache.decodeSummary(packet.message) const local = await this.cache.summarize(remote.prefix, this.cachePredicate(ptime)) if (!remote || !remote.hash || !local || !local.hash || local.hash === remote.hash) { if (this.onSyncFinished) this.onSyncFinished(packet, port, address) return } if (this.onSync) this.onSync(packet, port, address, { remote, local }) const remoteBuckets = remote.buckets.filter(Boolean).length this._onDebug(`<- ON SYNC (from=${address}:${port}, local=${local.hash.slice(0, 8)}, remote=${remote.hash.slice(0, 8)} remote-buckets=${remoteBuckets})`) for (let i = 0; i < local.buckets.length; i++) { // // nothing to send/sync, expect peer to send everything they have // if (!local.buckets[i] && !remote.buckets[i]) continue // // you dont have any of these, im going to send them to you // if (!remote.buckets[i]) { for (const [key, p] of this.cache.data.entries()) { if (!key.startsWith(local.prefix + i.toString(16))) continue const packet = Packet.from(p) if (!this.cachePredicate(ptime)(packet)) continue const pid = packet.packetId.toString('hex') this._onDebug(`-> SYNC SEND PACKET (type=data, packetId=${pid.slice(0, 8)}, to=${address}:${port})`) this.send(await Packet.encode(packet), port, address) } } else { // // need more details about what exactly isn't synce'd // const nextLevel = await this.cache.summarize(local.prefix + i.toString(16), this.cachePredicate(ptime)) const data = await Packet.encode(new PacketSync({ message: Cache.encodeSummary(nextLevel), usr4: Buffer.from(String(Date.now())) })) this.send(data, port, address) } } } /** * Received a Query Packet * * a -> b -> c -> (d) -> c -> b -> a * * @return {undefined} * @example * * ```js * peer.onQuery = (packet) => { * // * // read a database or something * // * return { * message: Buffer.from('hello'), * publicKey: '', * privateKey: '' * } * } * ``` */ async _onQuery (packet, port, address) { this.metrics.i[packet.type]++ const pid = packet.packetId.toString('hex') const queryId = packet.usr3.toString('hex') const queryTimestamp = parseInt(packet.usr1.toString(), 10) const queryType = parseInt(packet.usr4.toString(), 10) // if the timestamp in usr1 is older than now - 2s, bail if (queryTimestamp < (Date.now() - 2048)) return const type = queryType === 1 ? 'question' : 'answer' this._onDebug(`<- QUERY (type=${type}, from=${address}:${port}, packet=${pid.slice(0, 8)})`) let rinfo = { port, address } // // receiving an answer // if (this.returnRoutes.has(queryId) && type === 'answer') { rinfo = this.returnRoutes.get(queryId) let p = packet.copy() if (p.index > -1) p = await this.cache.compose(p) if (p?.index === -1) { this.returnRoutes.delete(p.previousId.toString('hex')) p.type = PacketPublish.type delete p.usr3 delete p.usr4 if (this.onAnswer) return this.onAnswer(p.message, p, port, address) } if (!rinfo.address) return } else if (type === 'question') { if (this.gate.has(pid)) return // // receiving a query // this.returnRoutes.set(queryId, { address, port }) const query = packet.message const packets = [] // // The requestor is looking for an exact packetId. In this case, // the peer has a packet with a previousId or nextId that points // to a packetId they don't have. There is no need to specify the // index in the query, split packets will have a nextId. // // if cache packet = { nextId: 'deadbeef...' } // then query = { packetId: packet.nextId } // or query = { packetId: packet.previousId } // if (query.packetId && this.cache.has(query.packetId)) { const p = this.cache.get(query.packetId) if (p) packets.push(p) } else if (this.onQuery) { const q = await this.onQuery(query) if (q) packets.push(...await this._message2packets(PacketQuery, q.message, q)) } if (packets.length) { for (const p of packets) { p.type = PacketQuery.type // convert the type during transport p.usr3 = packet.usr3 // ensure the packet has the queryId p.usr4 = Buffer.from(String(2)) // mark it as an answer packet this.send(await Packet.encode(p), rinfo.port, rinfo.address) this.gate.set(pid, 1) } return } } if (packet.hops >= this.maxHops) return if (this.gate.has(pid)) return this._onDebug('>> QUERY RELAY', port, address) await this.mcast(packet) } /** * Received a Ping Packet * @return {undefined} * @ignore */ async _onPing (packet, port, address) { this.metrics.i[packet.type]++ this.lastUpdate = Date.now() const { reflectionId, isReflection, isConnection, requesterPeerId, natType } = packet.message if (requesterPeerId === this.peerId) return // from self? const { probeExternalPort, isProbe, pingId } = packet.message // if (peer && reflectionId) peer.reflectionId = reflectionId if (!port) port = packet.message.port if (!address) address = packet.message.address const message = { cacheSize: this.cache.size, uptime: this.uptime, responderPeerId: this.peerId, requesterPeerId, port, isProbe, address } if (reflectionId) message.reflectionId = reflectionId if (pingId) message.pingId = pingId if (isReflection) { message.isReflection = true message.port = port message.address = address } else { message.natType = this.natType } if (isConnection && natType) { this._onDebug('<- CONNECTION (source=ping)') this._onConnection(packet, requesterPeerId, port, address) message.isConnection = true delete message.address delete message.port delete message.isProbe } const { hash } = await this.cache.summarize('', this.cachePredicate()) message.cacheSummaryHash = hash const packetPong = new PacketPong({ message }) const buf = await Packet.encode(packetPong) this.send(buf, port, address) if (probeExternalPort) { message.port = probeExternalPort const packetPong = new PacketPong({ message }) const buf = await Packet.encode(packetPong) this.send(buf, probeExternalPort, address, this.probeSocket) } } /** * Received a Pong Packet * @return {undefined} * @ignore */ async _onPong (packet, port, address) { this.metrics.i[packet.type]++ this.lastUpdate = Date.now() const { reflectionId, pingId, isReflection, responderPeerId } = packet.message if (responderPeerId === this.peerId) return // from self? this._onDebug(`<- PONG (from=${address}:${port}, hash=${packet.message.cacheSummaryHash}, isConnection=${!!packet.message.isConnection})`) const peer = this.getPeer(responderPeerId) if (packet.message.isConnection) { if (pingId) peer.pingId = pingId this._onDebug('<- CONNECTION (source=pong)') this._onConnection(packet, responderPeerId, port, address) return } if (!peer) return if (isReflection && !this.indexed) { if (reflectionId !== this.reflectionId) return this._clearTimeout(this.reflectionTimeout) if (!this.reflectionFirstResponder) { this.reflectionFirstResponder = { port, address, responderPeerId, reflectionId, packet } if (this.onConnecting) this.onConnecting({ code: 2.5, status: `Received reflection from ${address}:${port}` }) this._onDebug('<- NAT REFLECT - STAGE2: FIRST RESPONSE', port, address, this.reflectionId) this.reflectionFirstReponderTimeout = this._setTimeout(() => { this.reflectionStage = 0 this.lastUpdate = 0 this.reflectionId = null this._onDebug('<- NAT REFLECT FAILED TO ACQUIRE SECOND RESPONSE', this.reflectionId) this.requestReflection() }, PROBE_WAIT) } else { this._clearTimeout(this.reflectionFirstReponderTimeout) if (this.onConnecting) this.onConnecting({ code: 2.5, status: `Received reflection from ${address}:${port}` }) this._onDebug('<- NAT REFLECT - STAGE2: SECOND RESPONSE', port, address, this.reflectionId) if (packet.message.address !== this.address) return this.nextNatType |= ( packet.message.port === this.reflectionF