hyperdht
Version:
The DHT powering Hyperswarm
385 lines (299 loc) • 10 kB
JavaScript
const b4a = require('b4a')
const Nat = require('./nat')
const Sleeper = require('./sleeper')
const { FIREWALL } = require('./constants')
const BIRTHDAY_SOCKETS = 256
const HOLEPUNCH = b4a.from([0])
const HOLEPUNCH_TTL = 5
const DEFAULT_TTL = 64
const MAX_REOPENS = 3
module.exports = class Holepuncher {
constructor (dht, session, isInitiator, remoteFirewall = FIREWALL.UNKNOWN) {
const holder = dht._socketPool.acquire()
this.dht = dht
this.session = session
this.nat = new Nat(dht, session, holder.socket)
this.nat.autoSample()
this.isInitiator = isInitiator
// events
this.onconnect = noop
this.onabort = noop
this.punching = false
this.connected = false
this.destroyed = false
this.randomized = false
// track remote state
this.remoteFirewall = remoteFirewall
this.remoteAddresses = []
this.remoteHolepunching = false
this._sleeper = new Sleeper()
this._reopening = null
this._timeout = null
this._punching = null
this._allHolders = []
this._holder = this._addRef(holder)
}
get socket () {
return this._holder.socket
}
updateRemote ({ punching, firewall, addresses, verified }) {
const remoteAddresses = []
if (addresses) {
for (const addr of addresses) {
remoteAddresses.push({
host: addr.host,
port: addr.port,
verified: (verified === addr.host) || this._isVerified(addr.host)
})
}
}
this.remoteFirewall = firewall
this.remoteAddresses = remoteAddresses
this.remoteHolepunching = punching
}
_isVerified (host) {
for (const addr of this.remoteAddresses) {
if (addr.verified && addr.host === host) {
return true
}
}
return false
}
ping (addr, socket = this._holder.socket) {
return holepunch(socket, addr, false)
}
openSession (addr, socket = this._holder.socket) {
return holepunch(socket, addr, true)
}
async analyze (allowReopen) {
await this.nat.analyzing
if (this._unstable()) {
if (!allowReopen) return false
if (!this._reopening) this._reopening = this._reopen()
return this._reopening
}
return true
}
_unstable () {
// TODO!!: We need an additional heuristic here... If we were NOT random in the past we should also do this.
const firewall = this.nat.firewall
return (this.remoteFirewall >= FIREWALL.RANDOM && firewall >= FIREWALL.RANDOM) || firewall === FIREWALL.UNKNOWN
}
_reset () {
const prev = this._holder
this._allHolders.pop()
this._holder = this._addRef(this.dht._socketPool.acquire())
prev.release()
this.nat.destroy()
this.nat = new Nat(this.dht, this.session, this._holder.socket)
// TODO: maybe make auto sampling configurable somehow?
this.nat.autoSample()
}
_addRef (ref) {
this._allHolders.push(ref)
ref.onholepunchmessage = (msg, rinfo) => this._onholepunchmessage(msg, rinfo, ref)
return ref
}
_onholepunchmessage (_, addr, ref) {
if (!this.isInitiator) { // TODO: we don't need this if we had a way to connect a socket to many hosts
holepunch(ref.socket, addr, false) // never fails
return
}
if (this.connected) return
this.connected = true
this.punching = false
for (const r of this._allHolders) {
if (r === ref) continue
r.release()
}
this._allHolders[0] = ref
while (this._allHolders.length > 1) this._allHolders.pop()
this._decrementRandomized()
this.onconnect(ref.socket, addr.port, addr.host)
}
_done () {
return this.destroyed || this.connected
}
async _reopen () {
for (let i = 0; this._unstable() && i < MAX_REOPENS && !this._done() && !this.punching; i++) {
this._reset()
await this.nat.analyzing
}
return coerceFirewall(this.nat.firewall) === FIREWALL.CONSISTENT
}
punch () {
if (!this._punching) this._punching = this._punch()
return this._punching
}
async _punch () {
if (this._done() || !this.remoteAddresses.length) return false
this.punching = true
// Coerce into consistency for now. Obvs we could make this this more efficient if we use that info
// but that's seldomly used since those will just use tcp most of the time.
const local = coerceFirewall(this.nat.firewall)
const remote = coerceFirewall(this.remoteFirewall)
// Note that most of these async functions are meant to run in the background
// which is why we don't await them here and why they are not allowed to throw
let remoteVerifiedAddress = null
for (const addr of this.remoteAddresses) {
if (addr.verified) {
remoteVerifiedAddress = addr
break
}
}
if (local === FIREWALL.CONSISTENT && remote === FIREWALL.CONSISTENT) {
this.dht.stats.punches.consistent++
this._consistentProbe()
return true
}
if (!remoteVerifiedAddress) return false
if (local === FIREWALL.CONSISTENT && remote >= FIREWALL.RANDOM) {
this.dht.stats.punches.random++
this._incrementRandomized()
this._randomProbes(remoteVerifiedAddress)
return true
}
if (local >= FIREWALL.RANDOM && remote === FIREWALL.CONSISTENT) {
this.dht.stats.punches.random++
this._incrementRandomized()
await this._openBirthdaySockets(remoteVerifiedAddress)
if (this.punching) this._keepAliveRandomNat(remoteVerifiedAddress)
return true
}
return false
}
// Note that this never throws so it is safe to run in the background
async _consistentProbe () {
// Here we do the sleep first because the "fast open" mode in the server just fired a ping
if (!this.isInitiator) await this._sleeper.pause(1000)
let tries = 0
while (this.punching && tries++ < 10) {
for (const addr of this.remoteAddresses) {
// only try unverified addresses every 4 ticks
if (!addr.verified && ((tries & 3) !== 0)) continue
await holepunch(this._holder.socket, addr, false)
}
if (this.punching) await this._sleeper.pause(1000)
}
this._autoDestroy()
}
// Note that this never throws so it is safe to run in the background
async _randomProbes (remoteAddr) {
let tries = 1750 // ~35s
while (this.punching && tries-- > 0) {
const addr = { host: remoteAddr.host, port: randomPort() }
await holepunch(this._holder.socket, addr, false)
if (this.punching) await this._sleeper.pause(20)
}
this._autoDestroy()
}
// Note that this never throws so it is safe to run in the background
async _keepAliveRandomNat (remoteAddr) {
let i = 0
let lowTTLRounds = 1
// TODO: experiment with this here. We just bursted all the messages in
// openOtherSockets to ensure the sockets are open, so it's potentially
// a good idea to slow down for a bit.
await this._sleeper.pause(100)
let tries = 1750 // ~35s
while (this.punching && tries-- > 0) {
if (i === this._allHolders.length) {
i = 0
if (lowTTLRounds > 0) lowTTLRounds--
}
await holepunch(this._allHolders[i++].socket, remoteAddr, lowTTLRounds > 0)
if (this.punching) await this._sleeper.pause(20)
}
this._autoDestroy()
}
async _openBirthdaySockets (remoteAddr) {
while (this.punching && this._allHolders.length < BIRTHDAY_SOCKETS) {
const ref = this._addRef(this.dht._socketPool.acquire())
await holepunch(ref.socket, remoteAddr, HOLEPUNCH_TTL)
}
}
_autoDestroy () {
if (!this.connected) this.destroy()
}
_incrementRandomized () {
if (!this.randomized) {
this.randomized = true
this.dht._randomPunches++
}
}
_decrementRandomized () {
if (this.randomized) {
this.dht._lastRandomPunch = Date.now()
this.randomized = false
this.dht._randomPunches--
}
}
destroy () {
if (this.destroyed) return
this.destroyed = true
this.punching = false
for (const ref of this._allHolders) ref.release()
this._allHolders = []
this.nat.destroy()
if (!this.connected) {
this._decrementRandomized()
this.onabort()
}
}
static ping (socket, addr) {
return holepunch(socket, addr, false)
}
static localAddresses (socket) {
return localAddresses(socket)
}
static matchAddress (myAddresses, externalAddresses) {
return matchAddress(myAddresses, externalAddresses)
}
}
function holepunch (socket, addr, lowTTL) {
return socket.send(HOLEPUNCH, addr.port, addr.host, lowTTL ? HOLEPUNCH_TTL : DEFAULT_TTL)
}
function randomPort () {
return 1000 + (Math.random() * 64536) | 0
}
function coerceFirewall (fw) {
return fw === FIREWALL.OPEN ? FIREWALL.CONSISTENT : fw
}
function localAddresses (socket) {
const addrs = []
const { host, port } = socket.address()
if (host === '127.0.0.1') return [{ host, port }]
for (const n of socket.udx.networkInterfaces()) {
if (n.family !== 4 || n.internal) continue
addrs.push({ host: n.host, port })
}
if (addrs.length === 0) {
addrs.push({ host: '127.0.0.1', port })
}
return addrs
}
function matchAddress (localAddresses, remoteLocalAddresses) {
if (remoteLocalAddresses.length === 0) return null
let best = { segment: 1, addr: null }
for (const localAddress of localAddresses) {
// => 192.168.122.238
const a = localAddress.host.split('.')
for (const remoteAddress of remoteLocalAddresses) {
// => 192.168.0.23
// => 192.168.122.1
const b = remoteAddress.host.split('.')
// Matches 192.*.*.*
if (a[0] === b[0]) {
if (best.segment === 1) best = { segment: 2, addr: remoteAddress }
// Matches 192.168.*.*
if (a[1] === b[1]) {
if (best.segment === 2) best = { segment: 3, addr: remoteAddress }
// Matches 192.168.122.*
if (a[2] === b[2]) return remoteAddress
}
}
}
}
return best.addr
}
function noop () {}