feat: add UPnP NAT manager (#810)

* feat: add uPnP nat manager

Adds a really basic nat manager that attempts to use UPnP to punch
a hole through your router for any IPV4 tcp addresses you have
configured.

Adds any configured addresses to the node's observed addresses list
and adds observed addresses to `libp2p.multiaddrs` so we exchange
them with peers when performing `identify` and people can dial you.

Adds configuration options under `config.nat`

Hole punching is async to not affect start up time.

Co-authored-by: Vasco Santos <vasco.santos@moxy.studio>
This commit is contained in:
Alex Potsides
2021-01-27 13:55:26 +00:00
committed by GitHub
parent b5c9e48b68
commit 0a6bc0d101
16 changed files with 742 additions and 22 deletions

View File

@ -1,6 +1,10 @@
'use strict'
/** @typedef {import('../types').EventEmitterFactory} Events */
/** @type Events */
const EventEmitter = require('events')
const multiaddr = require('multiaddr')
const PeerId = require('peer-id')
/**
* @typedef {import('multiaddr')} Multiaddr
@ -11,7 +15,11 @@ const multiaddr = require('multiaddr')
* @property {string[]} [listen = []] - list of multiaddrs string representation to listen.
* @property {string[]} [announce = []] - list of multiaddrs string representation to announce.
*/
class AddressManager {
/**
* @fires AddressManager#change:addresses Emitted when a addresses change.
*/
class AddressManager extends EventEmitter {
/**
* Responsible for managing the peer addresses.
* Peers can specify their listen and announce addresses.
@ -19,11 +27,18 @@ class AddressManager {
* while the announce addresses will be used for the peer addresses' to other peers in the network.
*
* @class
* @param {AddressManagerOptions} [options]
* @param {PeerId} peerId - The Peer ID of the node
* @param {object} [options]
* @param {Array<string>} [options.listen = []] - list of multiaddrs string representation to listen.
* @param {Array<string>} [options.announce = []] - list of multiaddrs string representation to announce.
*/
constructor ({ listen = [], announce = [] } = {}) {
this.listen = new Set(listen)
this.announce = new Set(announce)
constructor (peerId, { listen = [], announce = [] } = {}) {
super()
this.peerId = peerId
this.listen = new Set(listen.map(ma => ma.toString()))
this.announce = new Set(announce.map(ma => ma.toString()))
this.observed = new Set()
}
/**
@ -43,6 +58,45 @@ class AddressManager {
getAnnounceAddrs () {
return Array.from(this.announce).map((a) => multiaddr(a))
}
/**
* Get observed multiaddrs.
*
* @returns {Array<Multiaddr>}
*/
getObservedAddrs () {
return Array.from(this.observed).map((a) => multiaddr(a))
}
/**
* Add peer observed addresses
*
* @param {string | Multiaddr} addr
*/
addObservedAddr (addr) {
let ma = multiaddr(addr)
const remotePeer = ma.getPeerId()
// strip our peer id if it has been passed
if (remotePeer) {
const remotePeerId = PeerId.createFromB58String(remotePeer)
// use same encoding for comparison
if (remotePeerId.equals(this.peerId)) {
ma = ma.decapsulate(multiaddr(`/p2p/${this.peerId}`))
}
}
const addrString = ma.toString()
// do not trigger the change:addresses event if we already know about this address
if (this.observed.has(addrString)) {
return
}
this.observed.add(addrString)
this.emit('change:addresses')
}
}
module.exports = AddressManager

View File

@ -59,6 +59,16 @@ const DefaultConfig = {
timeout: 10e3
}
},
nat: {
enabled: true,
ttl: 7200,
keepAlive: true,
gateway: null,
externalIp: null,
pmp: {
enabled: false
}
},
peerDiscovery: {
autoDial: true
},

View File

@ -43,6 +43,7 @@ class IdentifyService {
constructor ({ libp2p }) {
this._libp2p = libp2p
this.peerStore = libp2p.peerStore
this.addressManager = libp2p.addressManager
this.connectionManager = libp2p.connectionManager
this.peerId = libp2p.peerId
@ -201,8 +202,9 @@ class IdentifyService {
this.peerStore.protoBook.set(id, protocols)
this.peerStore.metadataBook.set(id, 'AgentVersion', uint8ArrayFromString(message.agentVersion))
// TODO: Track our observed address so that we can score it
// TODO: Score our observed addr
log('received observed address of %s', observedAddr)
this.addressManager.addObservedAddr(observedAddr)
}
/**

View File

@ -10,6 +10,7 @@ const EventEmitter = require('events')
const errCode = require('err-code')
const PeerId = require('peer-id')
const multiaddr = require('multiaddr')
const PeerRouting = require('./peer-routing')
const ContentRouting = require('./content-routing')
@ -33,6 +34,8 @@ const Registrar = require('./registrar')
const ping = require('./ping')
const IdentifyService = require('./identify')
const IDENTIFY_PROTOCOLS = IdentifyService.multicodecs
const NatManager = require('./nat-manager')
const { updateSelfPeerRecord } = require('./record/utils')
/**
* @typedef {import('multiaddr')} Multiaddr
@ -133,7 +136,14 @@ class Libp2p extends EventEmitter {
// Addresses {listen, announce, noAnnounce}
this.addresses = this._options.addresses
this.addressManager = new AddressManager(this._options.addresses)
this.addressManager = new AddressManager(this.peerId, this._options.addresses)
// when addresses change, update our peer record
this.addressManager.on('change:addresses', () => {
updateSelfPeerRecord(this).catch(err => {
log.error('Error updating self peer record', err)
})
})
this._modules = this._options.modules
this._config = this._options.config
@ -187,6 +197,14 @@ class Libp2p extends EventEmitter {
faultTolerance: this._options.transportManager.faultTolerance
})
// Create the Nat Manager
this.natManager = new NatManager({
peerId: this.peerId,
addressManager: this.addressManager,
transportManager: this.transportManager,
...this._options.config.nat
})
// Create the Registrar
this.registrar = new Registrar({
peerStore: this.peerStore,
@ -350,6 +368,7 @@ class Libp2p extends EventEmitter {
this.metrics && this.metrics.stop()
])
await this.natManager.stop()
await this.transportManager.close()
ping.unmount(this)
@ -445,22 +464,32 @@ class Libp2p extends EventEmitter {
}
/**
* Get peer advertising multiaddrs by concating the addresses used
* by transports to listen with the announce addresses.
* Duplicated addresses and noAnnounce addresses are filtered out.
* Get a deduplicated list of peer advertising multiaddrs by concatenating
* the listen addresses used by transports with any configured
* announce addresses as well as observed addresses reported by peers.
*
* If Announce addrs are specified, configured listen addresses will be
* ignored though observed addresses will still be included.
*
* @returns {Multiaddr[]}
*/
get multiaddrs () {
const announceAddrs = this.addressManager.getAnnounceAddrs()
if (announceAddrs.length) {
return announceAddrs
let addrs = this.addressManager.getAnnounceAddrs().map(ma => ma.toString())
if (!addrs.length) {
// no configured announce addrs, add configured listen addresses
addrs = this.transportManager.getAddrs().map(ma => ma.toString())
}
addrs = addrs.concat(this.addressManager.getObservedAddrs().map(ma => ma.toString()))
const announceFilter = this._options.addresses.announceFilter || ((multiaddrs) => multiaddrs)
// dedupe multiaddrs
const addrSet = new Set(addrs)
// Create advertising list
return announceFilter(this.transportManager.getAddrs())
return announceFilter(Array.from(addrSet).map(str => multiaddr(str)))
}
/**
@ -539,6 +568,9 @@ class Libp2p extends EventEmitter {
const addrs = this.addressManager.getListenAddrs()
await this.transportManager.listen(addrs)
// Manage your NATs
this.natManager.start()
// Start PeerStore
await this.peerStore.start()

168
src/nat-manager.js Normal file
View File

@ -0,0 +1,168 @@
'use strict'
const NatAPI = require('@motrix/nat-api')
const debug = require('debug')
const promisify = require('promisify-es6')
const Multiaddr = require('multiaddr')
const log = Object.assign(debug('libp2p:nat'), {
error: debug('libp2p:nat:err')
})
const { isBrowser } = require('ipfs-utils/src/env')
const retry = require('p-retry')
const isPrivateIp = require('private-ip')
const pkg = require('../package.json')
const errcode = require('err-code')
const {
codes: { ERR_INVALID_PARAMETERS }
} = require('./errors')
const isLoopback = require('libp2p-utils/src/multiaddr/is-loopback')
/**
* @typedef {import('peer-id')} PeerId
* @typedef {import('./transport-manager')} TransportManager
* @typedef {import('./address-manager')} AddressManager
*/
function highPort (min = 1024, max = 65535) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
const DEFAULT_TTL = 7200
class NatManager {
/**
* @class
* @param {object} options
* @param {PeerId} options.peerId - The peer ID of the current node
* @param {TransportManager} options.transportManager - A transport manager
* @param {AddressManager} options.addressManager - An address manager
* @param {boolean} options.enabled - Whether to enable the NAT manager
* @param {string} [options.externalIp] - Pass a value to use instead of auto-detection
* @param {string} [options.description] - A string value to use for the port mapping description on the gateway
* @param {number} [options.ttl] - How long UPnP port mappings should last for in seconds (minimum 1200)
* @param {boolean} [options.keepAlive] - Whether to automatically refresh UPnP port mappings when their TTL is reached
* @param {string} [options.gateway] - Pass a value to use instead of auto-detection
* @param {object} [options.pmp] - PMP options
* @param {boolean} [options.pmp.enabled] - Whether to enable PMP as well as UPnP
*/
constructor ({ peerId, addressManager, transportManager, ...options }) {
this._peerId = peerId
this._addressManager = addressManager
this._transportManager = transportManager
this._enabled = options.enabled
this._externalIp = options.externalIp
this._options = {
description: options.description || `${pkg.name}@${pkg.version} ${this._peerId}`,
ttl: options.ttl || DEFAULT_TTL,
autoUpdate: options.keepAlive || true,
gateway: options.gateway,
enablePMP: Boolean(options.pmp && options.pmp.enabled)
}
if (this._options.ttl < DEFAULT_TTL) {
throw errcode(new Error(`NatManager ttl should be at least ${DEFAULT_TTL} seconds`), ERR_INVALID_PARAMETERS)
}
}
/**
* Starts the NAT manager
*/
start () {
if (isBrowser || !this._enabled) {
return
}
// done async to not slow down startup
this._start().catch((err) => {
// hole punching errors are non-fatal
log.error(err)
})
}
async _start () {
const addrs = this._transportManager.getAddrs()
for (const addr of addrs) {
// try to open uPnP ports for each thin waist address
const { family, host, port, transport } = addr.toOptions()
if (!addr.isThinWaistAddress() || transport !== 'tcp') {
// only bare tcp addresses
continue
}
if (isLoopback(addr)) {
continue
}
if (family !== 'ipv4') {
// ignore ipv6
continue
}
const client = this._getClient()
const publicIp = this._externalIp || await client.externalIp()
if (isPrivateIp(publicIp)) {
throw new Error(`${publicIp} is private - please set config.nat.externalIp to an externally routable IP or ensure you are not behind a double NAT`)
}
const publicPort = highPort()
log(`opening uPnP connection from ${publicIp}:${publicPort} to ${host}:${port}`)
await client.map({
publicPort,
privatePort: port,
protocol: transport.toUpperCase()
})
this._addressManager.addObservedAddr(Multiaddr.fromNodeAddress({
family: 'IPv4',
address: publicIp,
port: `${publicPort}`
}, transport))
}
}
_getClient () {
if (this._client) {
return this._client
}
const client = new NatAPI(this._options)
const map = promisify(client.map, { context: client })
const destroy = promisify(client.destroy, { context: client })
const externalIp = promisify(client.externalIp, { context: client })
this._client = {
// these are all network operations so add a retry
map: (...args) => retry(() => map(...args), { onFailedAttempt: log.error }),
destroy: (...args) => retry(() => destroy(...args), { onFailedAttempt: log.error }),
externalIp: (...args) => retry(() => externalIp(...args), { onFailedAttempt: log.error })
}
return this._client
}
/**
* Stops the NAT manager
*
* @async
*/
async stop () {
if (isBrowser || !this._client) {
return
}
try {
await this._client.destroy()
this._client = null
} catch (err) {
log.error(err)
}
}
}
module.exports = NatManager