bittorrent-dht
Version:
Simple, robust, BitTorrent DHT implementation
799 lines (657 loc) • 21.2 kB
JavaScript
import { EventEmitter } from 'events'
import bencode from 'bencode'
import Debug from 'debug'
import KBucket from 'k-bucket'
import krpc from 'k-rpc'
import low from 'last-one-wins'
import LRU from 'lru'
import randombytes from 'randombytes'
import records from 'record-cache'
import crypto from 'crypto'
const debug = Debug('bittorrent-dht')
const ROTATE_INTERVAL = 5 * 60 * 1000 // rotate secrets every 5 minutes
const BUCKET_OUTDATED_TIMESPAN = 15 * 60 * 1000 // check nodes in bucket in 15 minutes old buckets
class DHT extends EventEmitter {
constructor (opts = {}) {
super()
this._tables = new LRU({ maxAge: ROTATE_INTERVAL, max: opts.maxTables || 1000 })
this._values = new LRU(opts.maxValues || 1000)
this._peers = records({
maxAge: opts.maxAge || 0,
maxSize: opts.maxPeers || 10000
})
this._secrets = null
this._hash = opts.hash || sha1
this._hashLength = this._hash(Buffer.from('')).length
this._rpc = opts.krpc || krpc(Object.assign({ idLength: this._hashLength }, opts))
this._rpc.on('query', onquery)
this._rpc.on('node', onnode)
this._rpc.on('warning', onwarning)
this._rpc.on('error', onerror)
this._rpc.on('listening', onlistening)
this._rotateSecrets()
this._verify = opts.verify || null
this._host = opts.host || null
this._interval = setInterval(rotateSecrets, ROTATE_INTERVAL)
this._runningBucketCheck = false
this._bucketCheckTimeout = null
this._bucketOutdatedTimeSpan = opts.timeBucketOutdated || BUCKET_OUTDATED_TIMESPAN
this.listening = false
this.destroyed = false
this.nodeId = this._rpc.id
this.nodes = this._rpc.nodes
// ensure only *one* ping it running at the time to avoid infinite async
// ping recursion, and make the latest one is always ran, but inbetween ones
// are disregarded
const onping = low(ping)
this._rpc.on('ping', (older, swap) => {
onping({ older, swap })
})
process.nextTick(bootstrap)
this._debug('new DHT %s', this.nodeId)
const self = this
function ping (opts, cb) {
const older = opts.older
const swap = opts.swap
self._debug('received ping', older)
self._checkNodes(older, false, (_, deadNode) => {
if (deadNode) {
self._debug('swaping dead node with newer', deadNode)
swap(deadNode)
return cb()
}
self._debug('no node added, all other nodes ok')
cb()
})
}
function onlistening () {
self.listening = true
self._debug('listening %d', self.address().port)
self.updateBucketTimestamp()
self._setBucketCheckInterval()
self.emit('listening')
}
function onquery (query, peer) {
self._onquery(query, peer)
}
function rotateSecrets () {
self._rotateSecrets()
}
function bootstrap () {
if (!self.destroyed) self._bootstrap(opts.bootstrap !== false)
}
function onwarning (err) {
self.emit('warning', err)
}
function onerror (err) {
self.emit('error', err)
}
function onnode (node) {
self.emit('node', node)
}
}
_setBucketCheckInterval () {
const self = this
const interval = 1 * 60 * 1000 // check age of bucket every minute
this._runningBucketCheck = true
queueNext()
function checkBucket () {
const diff = Date.now() - self._rpc.nodes.metadata.lastChange
if (diff < self._bucketOutdatedTimeSpan) return queueNext()
self._pingAll(() => {
if (self.destroyed) return
if (self.nodes.toArray().length < 1) {
// node is currently isolated,
// retry with initial bootstrap nodes
self._bootstrap(true)
}
queueNext()
})
}
function queueNext () {
if (!self._runningBucketCheck || self.destroyed) return
const nextTimeout = Math.floor(Math.random() * interval + interval / 2)
self._bucketCheckTimeout = setTimeout(checkBucket, nextTimeout)
}
}
_pingAll (cb) {
this._checkAndRemoveNodes(this.nodes.toArray(), cb)
}
removeBucketCheckInterval () {
this._runningBucketCheck = false
clearTimeout(this._bucketCheckTimeout)
}
updateBucketTimestamp () {
this._rpc.nodes.metadata.lastChange = Date.now()
}
_checkAndRemoveNodes (nodes, cb) {
const self = this
this._checkNodes(nodes, true, (_, node) => {
if (node) self.removeNode(node.id)
cb(null, node)
})
}
_checkNodes (nodes, force, cb) {
const self = this
test(nodes)
function test (acc) {
let current = null
while (acc.length) {
current = acc.pop()
if (!current.id || force) break
if (Date.now() - (current.seen || 0) > 10000) break // not pinged within 10s
current = null
}
if (!current) return cb(null)
self._sendPing(current, err => {
if (!err) {
self.updateBucketTimestamp()
return test(acc)
}
cb(null, current)
})
}
}
addNode (node) {
const self = this
if (node.id) {
node.id = toBuffer(node.id)
const old = !!this._rpc.nodes.get(node.id)
this._rpc.nodes.add(node)
if (!old) {
this.emit('node', node)
this.updateBucketTimestamp()
}
return
}
this._sendPing(node, (_, node) => {
if (node) self.addNode(node)
})
}
removeNode (id) {
this._rpc.nodes.remove(toBuffer(id))
}
_sendPing (node, cb) {
const self = this
const expectedId = node.id
this._rpc.query(node, { q: 'ping' }, (err, pong, node) => {
if (err) return cb(err)
if (!pong.r || !pong.r.id || !Buffer.isBuffer(pong.r.id) || pong.r.id.length !== self._hashLength) {
return cb(new Error('Bad reply'))
}
if (Buffer.isBuffer(expectedId) && !expectedId.equals(pong.r.id)) {
return cb(new Error('Unexpected node id'))
}
self.updateBucketTimestamp()
cb(null, {
id: pong.r.id,
host: node.host || node.address,
port: node.port
})
})
}
toJSON () {
const self = this
const values = {}
Object.keys(this._values.cache).forEach(key => {
const value = self._values.cache[key].value
values[key] = {
v: value.v.toString('hex'),
id: value.id.toString('hex')
}
if (value.seq != null) values[key].seq = value.seq
if (value.sig != null) values[key].sig = value.sig.toString('hex')
if (value.k != null) values[key].k = value.k.toString('hex')
})
return {
nodes: this._rpc.nodes.toArray().map(toNode),
values
}
}
put (opts, cb) {
if (Buffer.isBuffer(opts) || typeof opts === 'string') opts = { v: opts }
const isMutable = !!opts.k
if (opts.v === undefined) {
throw new Error('opts.v not given')
}
if (opts.v.length >= 1000) {
throw new Error('v must be less than 1000 bytes in put()')
}
if (isMutable && opts.cas !== undefined && typeof opts.cas !== 'number') {
throw new Error('opts.cas must be an integer if provided')
}
if (isMutable && opts.k.length !== 32) {
throw new Error('opts.k ed25519 public key must be 32 bytes')
}
if (isMutable && typeof opts.sign !== 'function' && !Buffer.isBuffer(opts.sig)) {
throw new Error('opts.sign function or options.sig signature is required for mutable put')
}
if (isMutable && opts.salt && opts.salt.length > 64) {
throw new Error('opts.salt is > 64 bytes long')
}
if (isMutable && opts.seq === undefined) {
throw new Error('opts.seq not provided for a mutable update')
}
if (isMutable && typeof opts.seq !== 'number') {
throw new Error('opts.seq not an integer')
}
return this._put(opts, cb)
}
_put (opts, cb) {
if (!cb) cb = noop
const isMutable = !!opts.k
const v = typeof opts.v === 'string' ? Buffer.from(opts.v) : opts.v
const key = isMutable
? this._hash(opts.salt ? Buffer.concat([opts.k, opts.salt]) : opts.k)
: this._hash(bencode.encode(v))
const table = this._tables.get(key.toString('hex'))
if (!table) return this._preput(key, opts, cb)
const message = {
q: 'put',
a: {
id: this._rpc.id,
token: null, // queryAll sets this
v
}
}
if (isMutable) {
if (typeof opts.cas === 'number') message.a.cas = opts.cas
if (opts.salt) message.a.salt = opts.salt
message.a.k = opts.k
message.a.seq = opts.seq
if (typeof opts.sign === 'function') message.a.sig = opts.sign(encodeSigData(message.a))
else if (Buffer.isBuffer(opts.sig)) message.a.sig = opts.sig
} else {
this._values.set(key.toString('hex'), message.a)
}
this._rpc.queryAll(table.closest(key), message, null, (err, n) => {
if (err) return cb(err, key, n)
cb(null, key, n)
})
return key
}
_preput (key, opts, cb) {
const self = this
this._closest(key, {
q: 'get',
a: {
id: this._rpc.id,
target: key
}
}, null, (err, n) => {
if (err) return cb(err)
self.put(opts, cb)
})
return key
}
get (key, opts, cb) {
key = toBuffer(key)
if (typeof opts === 'function') {
cb = opts
opts = null
}
if (!opts) opts = {}
const verify = opts.verify || this._verify
const hash = this._hash
let value = this._values.get(key.toString('hex')) || null
if (value && (opts.cache !== false)) {
value = createGetResponse(this._rpc.id, null, value)
return process.nextTick(done)
}
this._closest(key, {
q: 'get',
a: {
id: this._rpc.id,
target: key
}
}, onreply, done)
function done (err) {
if (err) return cb(err)
cb(null, value)
}
function onreply (message) {
const r = message.r
if (!r || !r.v) return true
const isMutable = r.k || r.sig
if (opts.salt) r.salt = Buffer.from(opts.salt)
if (isMutable) {
if (!verify || !r.sig || !r.k) return true
if (!verify(r.sig, encodeSigData(r), r.k)) return true
if (hash(r.salt ? Buffer.concat([r.k, r.salt]) : r.k).equals(key)) {
if (!value || r.seq > value.seq) value = r
}
} else {
if (hash(bencode.encode(r.v)).equals(key)) {
value = r
return false
}
}
return true
}
}
announce (infoHash, port, cb) {
if (typeof port === 'function') return this.announce(infoHash, 0, port)
infoHash = toBuffer(infoHash)
if (!cb) cb = noop
const table = this._tables.get(infoHash.toString('hex'))
if (!table) return this._preannounce(infoHash, port, cb)
if (this._host) {
const dhtPort = this.listening ? this.address().port : 0
this._addPeer(
{ host: this._host, port: port || dhtPort },
infoHash,
{ host: this._host, port: dhtPort }
)
}
const message = {
q: 'announce_peer',
a: {
id: this._rpc.id,
token: null, // queryAll sets this
info_hash: infoHash,
port,
implied_port: port ? 0 : 1
}
}
this._debug('announce %s %d', infoHash, port)
this._rpc.queryAll(table.closest(infoHash), message, null, cb)
}
_preannounce (infoHash, port, cb) {
const self = this
this.lookup(infoHash, err => {
if (self.destroyed) return cb(new Error('dht is destroyed'))
if (err) return cb(err)
self.announce(infoHash, port, cb)
})
}
lookup (infoHash, cb) {
infoHash = toBuffer(infoHash)
if (!cb) cb = noop
const self = this
let aborted = false
this._debug('lookup %s', infoHash)
process.nextTick(emit)
this._closest(infoHash, {
q: 'get_peers',
a: {
id: this._rpc.id,
info_hash: infoHash
}
}, onreply, cb)
function emit (values, from) {
if (!values) values = self._peers.get(infoHash.toString('hex'), 100)
const peers = decodePeers(values)
for (let i = 0; i < peers.length; i++) {
self.emit('peer', peers[i], infoHash, from || null)
}
}
function onreply (message, node) {
if (aborted) return false
if (message.r.values) emit(message.r.values, node)
}
return function abort () { aborted = true }
}
address () {
return this._rpc.address()
}
// listen([port], [address], [onlistening])
listen (...args) {
this._rpc.bind(...args)
}
destroy (cb) {
if (this.destroyed) {
if (cb) process.nextTick(cb)
return
}
this.destroyed = true
const self = this
clearInterval(this._interval)
this.removeBucketCheckInterval()
this._peers.destroy()
this._debug('destroying')
this._rpc.destroy(() => {
self.emit('close')
if (cb) cb()
})
}
_onquery (query, peer) {
if (query.q === undefined || query.q === null) return
const q = query.q.toString()
this._debug('received %s query from %s:%d', q, peer.address, peer.port)
if (!query.a) return
switch (q) {
case 'ping':
return this._rpc.response(peer, query, { id: this._rpc.id })
case 'find_node':
return this._onfindnode(query, peer)
case 'get_peers':
return this._ongetpeers(query, peer)
case 'announce_peer':
return this._onannouncepeer(query, peer)
case 'get':
return this._onget(query, peer)
case 'put':
return this._onput(query, peer)
}
}
_onfindnode (query, peer) {
const target = query.a.target
if (!target) return this._rpc.error(peer, query, [203, '`find_node` missing required `a.target` field'])
this.emit('find_node', target)
const nodes = this._rpc.nodes.closest(target)
this._rpc.response(peer, query, { id: this._rpc.id }, nodes)
}
_ongetpeers (query, peer) {
const host = peer.address || peer.host
const infoHash = query.a.info_hash
if (!infoHash) return this._rpc.error(peer, query, [203, '`get_peers` missing required `a.info_hash` field'])
this.emit('get_peers', infoHash)
const r = { id: this._rpc.id, token: this._generateToken(host) }
const peers = this._peers.get(infoHash.toString('hex'))
if (peers.length) {
r.values = peers
this._rpc.response(peer, query, r)
} else {
this._rpc.response(peer, query, r, this._rpc.nodes.closest(infoHash))
}
}
_onannouncepeer (query, peer) {
const host = peer.address || peer.host
const port = query.a.implied_port ? peer.port : query.a.port
if (!port || typeof port !== 'number' || port <= 0 || port > 65535) return
const infoHash = query.a.info_hash
const token = query.a.token
if (!infoHash || !token) return
if (!this._validateToken(host, token)) {
return this._rpc.error(peer, query, [203, 'cannot `announce_peer` with bad token'])
}
this.emit('announce_peer', infoHash, { host, port: peer.port })
this._addPeer({ host, port }, infoHash, { host, port: peer.port })
this._rpc.response(peer, query, { id: this._rpc.id })
}
_addPeer (peer, infoHash, from) {
this._peers.add(infoHash.toString('hex'), encodePeer(peer.host, peer.port))
this.emit('announce', peer, infoHash, from)
}
_onget (query, peer) {
const host = peer.address || peer.host
const target = query.a.target
if (!target) return
const token = this._generateToken(host)
const value = this._values.get(target.toString('hex'))
this.emit('get', target, value)
if (!value) {
const nodes = this._rpc.nodes.closest(target)
this._rpc.response(peer, query, { id: this._rpc.id, token }, nodes)
} else {
this._rpc.response(peer, query, createGetResponse(this._rpc.id, token, value))
}
}
_onput (query, peer) {
const host = peer.address || peer.host
const a = query.a
if (!a) return
const v = query.a.v
if (!v) return
const id = query.a.id
if (!id) return
const token = a.token
if (!token) return
if (!this._validateToken(host, token)) {
return this._rpc.error(peer, query, [203, 'cannot `put` with bad token'])
}
if (v.length > 1000) {
return this._rpc.error(peer, query, [205, 'data payload too large'])
}
const isMutable = !!(a.k || a.sig)
if (isMutable && !a.k && !a.sig) return
const key = isMutable
? this._hash(a.salt ? Buffer.concat([a.k, a.salt]) : a.k)
: this._hash(bencode.encode(v))
const keyHex = key.toString('hex')
this.emit('put', key, v)
if (isMutable) {
if (!this._verify) return this._rpc.error(peer, query, [400, 'verification not supported'])
if (!this._verify(a.sig, encodeSigData(a), a.k)) return
const prev = this._values.get(keyHex)
if (prev && typeof a.cas === 'number' && prev.seq !== a.cas) {
return this._rpc.error(peer, query, [301, 'CAS mismatch, re-read and try again'])
}
if (prev && typeof prev.seq === 'number' && !(a.seq > prev.seq)) {
return this._rpc.error(peer, query, [302, 'sequence number less than current'])
}
this._values.set(keyHex, { v, k: a.k, salt: a.salt, sig: a.sig, seq: a.seq, id })
} else {
this._values.set(keyHex, { v, id })
}
this._rpc.response(peer, query, { id: this._rpc.id })
}
_bootstrap (populate) {
const self = this
if (!populate) return process.nextTick(ready)
this._rpc.populate(self._rpc.id, {
q: 'find_node',
a: {
id: self._rpc.id,
target: self._rpc.id
}
}, ready)
function ready () {
if (self.ready) return
self._debug('emit ready')
self.ready = true
self.emit('ready')
}
}
_closest (target, message, onmessage, cb) {
const self = this
const table = new KBucket({
localNodeId: target,
numberOfNodesPerKBucket: this._rpc.k
})
this._rpc.closest(target, message, onreply, done)
function done (err, n) {
if (err) return cb(err)
self._tables.set(target.toString('hex'), table)
self._debug('visited %d nodes', n)
cb(null, n)
}
function onreply (message, node) {
if (!message.r) return true
if (message.r.token && message.r.id && Buffer.isBuffer(message.r.id) && message.r.id.length === self._hashLength) {
self._debug('found node %s (target: %s)', message.r.id, target)
table.add({
id: message.r.id,
host: node.host || node.address,
port: node.port,
token: message.r.token
})
}
if (!onmessage) return true
return onmessage(message, node)
}
}
_debug () {
if (!debug.enabled) return
const args = [].slice.call(arguments)
args[0] = `[${this.nodeId.toString('hex').substring(0, 7)}] ${args[0]}`
for (let i = 1; i < args.length; i++) {
if (Buffer.isBuffer(args[i])) args[i] = args[i].toString('hex')
}
debug(...args)
}
_validateToken (host, token) {
const tokenA = this._generateToken(host, this._secrets[0])
const tokenB = this._generateToken(host, this._secrets[1])
return token.equals(tokenA) || token.equals(tokenB)
}
_generateToken (host, secret) {
if (!secret) secret = this._secrets[0]
return this._hash(Buffer.concat([Buffer.from(host), secret]))
}
_rotateSecrets () {
if (!this._secrets) {
this._secrets = [randombytes(this._hashLength), randombytes(this._hashLength)]
} else {
this._secrets[1] = this._secrets[0]
this._secrets[0] = randombytes(this._hashLength)
}
}
}
function noop () {}
function sha1 (buf) {
return crypto.createHash('sha1').update(buf).digest()
}
function createGetResponse (id, token, value) {
const r = { id, token, v: value.v }
if (value.sig) {
r.sig = value.sig
r.k = value.k
if (typeof value.seq === 'number') r.seq = value.seq
}
return r
}
function encodePeer (host, port) {
const buf = Buffer.allocUnsafe(6)
const ip = host.split('.')
for (let i = 0; i < 4; i++) buf[i] = parseInt(ip[i] || 0, 10)
buf.writeUInt16BE(port, 4)
return buf
}
function decodePeers (buf) {
const peers = []
try {
for (let i = 0; i < buf.length; i++) {
const port = buf[i].readUInt16BE(4)
if (!port) continue
peers.push({
host: parseIp(buf[i], 0),
port
})
}
} catch (err) {
// do nothing
}
return peers
}
function parseIp (buf, offset) {
return `${buf[offset++]}.${buf[offset++]}.${buf[offset++]}.${buf[offset++]}`
}
function encodeSigData (msg) {
const ref = { seq: msg.seq || 0, v: msg.v }
if (msg.salt) ref.salt = msg.salt
return bencode.encode(ref).slice(1, -1)
}
function toNode (node) {
return {
host: node.host,
port: node.port
}
}
function toBuffer (str) {
if (Buffer.isBuffer(str)) return str
if (ArrayBuffer.isView(str)) return Buffer.from(str.buffer, str.byteOffset, str.byteLength)
if (typeof str === 'string') return Buffer.from(str, 'hex')
throw new Error('Pass a buffer or a string')
}
export default DHT