hyperdht
Version:
The DHT powering Hyperswarm
699 lines (562 loc) • 20.1 kB
JavaScript
const { EventEmitter } = require('events')
const safetyCatch = require('safety-catch')
const NoiseSecretStream = require('@hyperswarm/secret-stream')
const b4a = require('b4a')
const relay = require('blind-relay')
const NoiseWrap = require('./noise-wrap')
const Announcer = require('./announcer')
const { FIREWALL, ERROR } = require('./constants')
const { unslabbedHash } = require('./crypto')
const SecurePayload = require('./secure-payload')
const Holepuncher = require('./holepuncher')
const { isPrivate } = require('bogon')
const { ALREADY_LISTENING, NODE_DESTROYED, KEYPAIR_ALREADY_USED } = require('./errors')
const HANDSHAKE_CLEAR_WAIT = 10000
const HANDSHAKE_INITIAL_TIMEOUT = 10000
module.exports = class Server extends EventEmitter {
constructor (dht, opts = {}) {
super()
this.dht = dht
this.target = null
this.closed = false
this.firewall = opts.firewall || (() => false)
this.holepunch = opts.holepunch || (() => true)
this.relayThrough = opts.relayThrough || null
this.relayKeepAlive = opts.relayKeepAlive || 5000
this.pool = opts.pool || null
this.createHandshake = opts.createHandshake || defaultCreateHandshake
this.createSecretStream = opts.createSecretStream || defaultCreateSecretStream
this.suspended = false
this._shareLocalAddress = opts.shareLocalAddress !== false
this._reusableSocket = !!opts.reusableSocket
this._neverPunch = opts.holepunch === false // useful for fully disabling punching
this._keyPair = null
this._announcer = null
this._connects = new Map()
this._holepunches = []
this._listening = null
this._closing = null
}
get listening () {
return this._listening !== null
}
get publicKey () {
return this._keyPair && this._keyPair.publicKey
}
get relayAddresses () {
return this._announcer ? this._announcer.relayAddresses : []
}
onconnection (encryptedSocket) {
this.emit('connection', encryptedSocket)
}
async suspend ({ log = noop } = {}) {
log('Suspending hyperdht server')
if (this._listening !== null) await this._listening
log('Suspending hyperdht server (post listening)')
this.suspended = true
this._clearAll()
return this._announcer ? this._announcer.suspend({ log }) : Promise.resolve()
}
async resume () {
if (this._listening !== null) await this._listening
this.suspended = false
return this._announcer ? this._announcer.resume() : Promise.resolve()
}
address () {
if (!this._keyPair) return null
return {
publicKey: this._keyPair.publicKey,
host: this.dht.host,
port: this.dht.port
}
}
close () {
if (this._closing) return this._closing
this._closing = this._close()
return this._closing
}
_gc () {
this.dht.listening.delete(this)
if (this.target) this.dht._router.delete(this.target)
}
async _stopListening () {
try {
if (this._announcer) await this._announcer.stop()
} catch {
// ignore
}
this._announcer = null
this._listening = null
this._keyPair = null
}
async _close () {
if (this._listening === null) {
this.closed = true
this.emit('close')
return
}
try {
await this._listening
} catch {}
this._gc()
this._clearAll()
await this._stopListening()
this.closed = true
this.emit('close')
}
_clearAll () {
while (this._holepunches.length > 0) {
const h = this._holepunches.pop()
if (h && h.puncher) h.puncher.destroy()
if (h && h.clearing) clearTimeout(h.clearing)
if (h && h.prepunching) clearTimeout(h.prepunching)
if (h && h.rawStream) h.rawStream.destroy()
}
this._connects.clear()
}
async listen (keyPair = this.dht.defaultKeyPair, opts = {}) {
if (this._listening !== null) throw ALREADY_LISTENING()
if (this.dht.destroyed) throw NODE_DESTROYED()
this._listening = this._listen(keyPair, opts)
await this._listening
return this
}
async _listen (keyPair, opts) {
// From now on, the DHT object which created me is responsible for closing me
this.dht.listening.add(this)
try {
await this.dht.bind()
if (this._closing) return
for (const s of this.dht.listening) {
if (s._keyPair && b4a.equals(s._keyPair.publicKey, keyPair.publicKey)) {
throw KEYPAIR_ALREADY_USED()
}
}
this.target = unslabbedHash(keyPair.publicKey)
this._keyPair = keyPair
this._announcer = new Announcer(this.dht, keyPair, this.target, opts)
this.dht._router.set(this.target, {
relay: null,
record: this._announcer.record,
onpeerhandshake: this._onpeerhandshake.bind(this),
onpeerholepunch: this._onpeerholepunch.bind(this)
})
// warm it up for now
this._localAddresses().catch(safetyCatch)
await this._announcer.start()
} catch (err) {
await this._stopListening()
this._gc()
throw err
}
if (this._closing) return
if (this.suspended) await this._announcer.suspend()
if (this._closing) return
if (this.dht.destroyed) throw NODE_DESTROYED()
if (this.pool) this.pool._attachServer(this)
this.emit('listening')
}
refresh () {
if (this._announcer) this._announcer.refresh()
}
notifyOnline () {
if (this._announcer) this._announcer.online.notify()
}
_localAddresses () {
return this.dht.validateLocalAddresses(Holepuncher.localAddresses(this.dht.io.serverSocket))
}
async _addHandshake (k, noise, clientAddress, { from, to: serverAddress, socket }, direct) {
let id = this._holepunches.indexOf(null)
if (id === -1) id = this._holepunches.push(null) - 1
const hs = {
round: 0,
reply: null,
puncher: null,
payload: null,
rawStream: null,
encryptedSocket: null,
prepunching: null,
firewalled: true,
clearing: null,
onsocket: null,
// Relay state
relayTimeout: null,
relayToken: null,
relaySocket: null,
relayClient: null,
relayPaired: false
}
this._holepunches[id] = hs
const handshake = this.createHandshake(this._keyPair, null)
let remotePayload
try {
remotePayload = await handshake.recv(noise)
} catch (err) {
safetyCatch(err)
this._clearLater(hs, id, k)
return null
}
if (this._closing || this.suspended) return null
try {
hs.firewalled = await this.firewall(handshake.remotePublicKey, remotePayload, clientAddress)
} catch (err) {
safetyCatch(err)
}
if (this._closing || this.suspended) return null
if (hs.firewalled) {
this._clearLater(hs, id, k)
return null
}
const error = remotePayload.version === 1
? (remotePayload.udx ? ERROR.NONE : ERROR.ABORTED)
: ERROR.VERSION_MISMATCH
const addresses = []
const ourRemoteAddr = this.dht.remoteAddress()
const ourLocalAddrs = this._shareLocalAddress ? await this._localAddresses() : null
if (this._closing || this.suspended) return null
if (ourRemoteAddr) addresses.push(ourRemoteAddr)
if (ourLocalAddrs) addresses.push(...ourLocalAddrs)
if (error === ERROR.NONE) {
hs.rawStream = this.dht.createRawStream({
framed: true,
firewall (socket, port, host) {
// Check if the traffic originated from the socket on which we're expecting relay traffic. If so,
// we haven't hole punched yet and the other side is just sending us traffic through the relay.
if (hs.relaySocket && isRelay(hs.relaySocket, socket, port, host)) {
return false
}
hs.onsocket(socket, port, host)
return false
}
})
hs.rawStream.on('error', autoDestroy)
hs.onsocket = (socket, port, host) => {
if (hs.rawStream === null) return // Already hole punched
this._clearLater(hs, id, k)
if (hs.prepunching) {
clearTimeout(hs.prepunching)
hs.prepunching = null
}
if (this._reusableSocket && remotePayload.udx.reusableSocket) {
this.dht._socketPool.routes.add(handshake.remotePublicKey, hs.rawStream)
}
hs.rawStream.removeListener('error', autoDestroy)
if (hs.rawStream.connected) {
const remoteChanging = hs.rawStream.changeRemote(socket, remotePayload.udx.id, port, host)
if (remoteChanging) remoteChanging.catch(safetyCatch)
} else {
hs.rawStream.connect(socket, remotePayload.udx.id, port, host)
hs.encryptedSocket = this.createSecretStream(false, hs.rawStream, {
handshake: h,
keepAlive: this.dht.connectionKeepAlive
})
this.onconnection(hs.encryptedSocket)
}
if (hs.puncher) {
hs.puncher.onabort = noop
hs.puncher.destroy()
}
hs.rawStream = null
}
function autoDestroy () {
if (hs.puncher) hs.puncher.destroy()
}
}
const relayThrough = selectRelay(this.relayThrough)
if (relayThrough) hs.relayToken = relay.token()
try {
hs.reply = await handshake.send({
error,
firewall: ourRemoteAddr ? FIREWALL.OPEN : FIREWALL.UNKNOWN,
holepunch: ourRemoteAddr ? null : { id, relays: this._announcer.relays },
addresses4: addresses,
addresses6: null,
udx: {
reusableSocket: this._reusableSocket,
id: hs.rawStream ? hs.rawStream.id : 0,
seq: 0
},
secretStream: {},
relayThrough: relayThrough
? { publicKey: relayThrough, token: hs.relayToken }
: null
})
} catch (err) {
safetyCatch(err)
hs.rawStream.destroy()
this._clearLater(hs, id, k)
return null
}
if (this._closing || this.suspended) {
hs.rawStream.destroy()
return null
}
const h = handshake.final()
if (error !== ERROR.NONE) {
hs.rawStream.destroy()
this._clearLater(hs, id, k)
return hs
}
if (relayThrough || remotePayload.relayThrough) {
this._relayConnection(hs, relayThrough, remotePayload, h)
}
if (remotePayload.firewall === FIREWALL.OPEN || direct) {
const sock = direct ? socket : this.dht.socket
this.dht.stats.punches.open++
hs.onsocket(sock, clientAddress.port, clientAddress.host)
return hs
}
const onabort = () => {
if (hs.prepunching) clearTimeout(hs.prepunching)
hs.prepunching = null
hs.rawStream.on('close', () => this._clearLater(hs, id, k))
if (hs.relayToken === null) hs.rawStream.destroy()
}
if (!direct && clientAddress.host === serverAddress.host) {
const clientAddresses = remotePayload.addresses4.filter(onlyPrivateHosts)
if (clientAddresses.length > 0 && this._shareLocalAddress) {
const myAddresses = await this._localAddresses()
const addr = Holepuncher.matchAddress(myAddresses, clientAddresses)
if (addr) {
hs.prepunching = setTimeout(onabort, HANDSHAKE_INITIAL_TIMEOUT)
return hs
}
}
}
if (this._closing || this.suspended) return null
if (ourRemoteAddr || this._neverPunch) {
hs.prepunching = setTimeout(onabort, HANDSHAKE_INITIAL_TIMEOUT)
return hs
}
hs.payload = new SecurePayload(h.holepunchSecret)
hs.puncher = new Holepuncher(this.dht, this.dht.session(), false, remotePayload.firewall)
hs.puncher.onconnect = hs.onsocket
hs.puncher.onabort = onabort
hs.prepunching = setTimeout(hs.puncher.destroy.bind(hs.puncher), HANDSHAKE_INITIAL_TIMEOUT)
return hs
}
_clearLater (hs, id, k) {
if (hs.clearing) return
hs.clearing = setTimeout(() => this._clear(hs, id, k), HANDSHAKE_CLEAR_WAIT)
}
_clear (hs, id, k) {
if (id >= this._holepunches.length || this._holepunches[id] !== hs) return
if (hs.clearing) clearTimeout(hs.clearing)
this._holepunches[id] = null
while (this._holepunches.length > 0 && this._holepunches[this._holepunches.length - 1] === null) {
this._holepunches.pop()
}
this._connects.delete(k)
}
async _onpeerhandshake ({ noise, peerAddress }, req) {
const k = b4a.toString(noise, 'hex')
// The next couple of statements MUST run within the same tick to prevent
// a malicious peer from flooding us with handshakes.
let p = this._connects.get(k)
if (!p) {
p = this._addHandshake(k, noise, peerAddress || req.from, req, !peerAddress)
this._connects.set(k, p)
}
const h = await p
if (!h) return null
if (this._closing !== null || this.suspended) return null
return { socket: h.puncher && h.puncher.socket, noise: h.reply }
}
async _onpeerholepunch ({ id, peerAddress, payload }, req) {
const h = id < this._holepunches.length ? this._holepunches[id] : null
if (!h) return null
if (!peerAddress || this._closing !== null || this.suspended) return null
const p = h.puncher
if (!p || !p.socket) return this._abort(h) // not opened
const remotePayload = h.payload.decrypt(payload)
if (!remotePayload) return null
const isServerRelay = this._announcer.isRelay(req.from)
const { error, firewall, round, punching, addresses, remoteAddress, remoteToken } = remotePayload
if (error !== ERROR.NONE) {
// We actually do not need to set the round here, but just do it for consistency.
if (round >= h.round) h.round = round
return this._abort(h)
}
const token = h.payload.token(peerAddress)
const echoed = isServerRelay && !!remoteToken && b4a.equals(token, remoteToken)
// Update our heuristics here
if (req.socket === p.socket) {
p.nat.add(req.to, req.from)
}
if (round >= h.round) {
h.round = round
p.updateRemote({ punching, firewall, addresses, verified: echoed ? peerAddress.host : null })
}
// Wait for the analyzer to reach a conclusion...
let stable = await p.analyze(false)
if (p.destroyed) return null
if (!p.remoteHolepunching && !stable) {
stable = await p.analyze(true)
if (p.destroyed) return null
if (!stable) return this._abort(h)
}
// Fast mode! If we are consistent and the remote has opened a session to us (remoteAddress)
// then fire a quick punch back. Note the await here just waits for the udp socket to flush.
if (isConsistent(p.nat.firewall) && remoteAddress && hasSameAddr(p.nat.addresses, remoteAddress)) {
await p.ping(peerAddress)
if (p.destroyed) return null
}
// Remote said they are punching (or willing to), so we will punch as well.
// Note that this returns when the punching has STARTED, so no guarantee
// we will have a connection after this promise etc.
if (p.remoteHolepunching) {
// TODO: still continue here if a local connection might work, but then do not holepunch...
if (!this.holepunch(p.remoteFirewall, p.nat.firewall, p.remoteAddresses, p.nat.addresses)) {
return p.destroyed ? null : this._abort(h)
}
if (h.prepunching) {
clearTimeout(h.prepunching)
h.prepunching = null
}
if (p.remoteFirewall >= FIREWALL.RANDOM || p.nat.firewall >= FIREWALL.RANDOM) {
if (this.dht._randomPunches >= this.dht._randomPunchLimit || (Date.now() - this.dht._lastRandomPunch) < this.dht._randomPunchInterval) {
if (!h.relayToken) return this._abort(h, ERROR.TRY_LATER)
return {
socket: p.socket,
payload: h.payload.encrypt({
error: ERROR.TRY_LATER,
firewall: p.nat.firewall,
round: h.round,
connected: p.connected,
punching: p.punching,
addresses: p.nat.addresses,
remoteAddress: null,
token: isServerRelay ? token : null,
remoteToken: remotePayload.token
})
}
}
}
const punching = await p.punch()
if (p.destroyed) return null
if (!punching) return this._abort(h)
}
// Freeze that analysis as soon as we have a result we are giving to the other peer
if (p.nat.firewall !== FIREWALL.UNKNOWN) {
p.nat.freeze()
}
return {
socket: p.socket,
payload: h.payload.encrypt({
error: ERROR.NONE,
firewall: p.nat.firewall,
round: h.round,
connected: p.connected,
punching: p.punching,
addresses: p.nat.addresses,
remoteAddress: null,
token: isServerRelay ? token : null,
remoteToken: remotePayload.token
})
}
}
_abort (h, error = ERROR.ABORTED) {
if (!h.payload) {
if (h.puncher) h.puncher.destroy()
return null
}
const payload = h.payload.encrypt({
error,
firewall: FIREWALL.UNKNOWN,
round: h.round,
connected: false,
punching: false,
addresses: null,
remoteAddress: null,
token: null,
remoteToken: null
})
h.puncher.destroy()
return { socket: this.dht.socket, payload }
}
_relayConnection (hs, relayThrough, remotePayload, h) {
let isInitiator
let publicKey
let token
if (relayThrough) {
isInitiator = true
publicKey = relayThrough
token = hs.relayToken
} else {
isInitiator = false
publicKey = remotePayload.relayThrough.publicKey
token = remotePayload.relayThrough.token
}
hs.relayToken = token
hs.relaySocket = this.dht.connect(publicKey)
hs.relaySocket.setKeepAlive(this.relayKeepAlive)
hs.relayClient = relay.Client.from(hs.relaySocket, { id: hs.relaySocket.publicKey })
hs.relayTimeout = setTimeout(onabort, 15000)
hs.relayClient
.pair(isInitiator, token, hs.rawStream)
.on('error', onabort)
.on('data', (remoteId) => {
if (hs.relayTimeout) clearRelayTimeout(hs)
if (hs.rawStream === null) {
onabort(null)
return
}
hs.relayPaired = true
if (hs.prepunching) clearTimeout(hs.prepunching)
hs.prepunching = null
const {
remotePort,
remoteHost,
socket
} = hs.relaySocket.rawStream
hs.rawStream
.on('close', () => hs.relaySocket.destroy())
.connect(socket, remoteId, remotePort, remoteHost)
hs.encryptedSocket = this.createSecretStream(false, hs.rawStream, { handshake: h })
this.onconnection(hs.encryptedSocket)
})
function onabort () {
if (hs.relayTimeout) clearRelayTimeout(hs)
const socket = hs.relaySocket
hs.relayToken = null
hs.relaySocket = null
if (socket) socket.destroy()
}
}
}
function clearRelayTimeout (hs) {
clearTimeout(hs.relayTimeout)
hs.relayTimeout = null
}
function isConsistent (fw) {
return fw === FIREWALL.OPEN || fw === FIREWALL.CONSISTENT
}
function hasSameAddr (addrs, other) {
if (addrs === null) return false
for (const addr of addrs) {
if (addr.port === other.port && addr.host === other.host) return true
}
return false
}
function defaultCreateHandshake (keyPair, remotePublicKey) {
return new NoiseWrap(keyPair, remotePublicKey)
}
function defaultCreateSecretStream (isInitiator, rawStream, opts) {
return new NoiseSecretStream(isInitiator, rawStream, opts)
}
function onlyPrivateHosts (addr) {
return isPrivate(addr.host)
}
function isRelay (relaySocket, socket, port, host) {
const stream = relaySocket.rawStream
if (!stream) return false
if (stream.socket !== socket) return false
return port === stream.remotePort && host === stream.remoteHost
}
function selectRelay (relayThrough) {
if (typeof relayThrough === 'function') relayThrough = relayThrough()
if (relayThrough === null) return null
if (Array.isArray(relayThrough)) return relayThrough[Math.floor(Math.random() * relayThrough.length)]
return relayThrough
}
function noop () {}