@achingbrain/nat-port-mapper
Version:
Port mapping with UPnP and NAT-PMP
429 lines (356 loc) • 11.9 kB
text/typescript
import { createSocket } from 'dgram'
import { EventEmitter } from 'events'
import { isIPv4 } from '@chainsafe/is-ip'
import { logger } from '@libp2p/logger'
import errCode from 'err-code'
import defer from 'p-defer'
import { raceSignal } from 'race-signal'
import { DEFAULT_PORT_MAPPING_TTL, DEFAULT_REFRESH_THRESHOLD, DEFAULT_REFRESH_TIMEOUT } from '../upnp/constants.js'
import { findLocalAddresses } from '../upnp/utils.js'
import { isPrivateIp } from '../utils.js'
import type { Gateway, MapPortOptions, GlobalMapPortOptions, PortMapping } from '../index.js'
import type { AbortOptions } from 'abort-error'
import type { Socket, RemoteInfo } from 'dgram'
import type { DeferredPromise } from 'p-defer'
const log = logger('nat-port-mapper:pmp')
// Ports defined by draft
const CLIENT_PORT = 5350
const SERVER_PORT = 5351
// Opcodes
const OP_EXTERNAL_IP = 0
const OP_MAP_UDP = 1
const OP_MAP_TCP = 2
const SERVER_DELTA = 128
// Result codes
const RESULT_CODES: Record<number, string> = {
0: 'Success',
1: 'Unsupported Version',
2: 'Not Authorized/Refused (gateway may have NAT-PMP disabled)',
3: 'Network Failure (gateway may have not obtained a DHCP lease)',
4: 'Out of Resources (no ports left)',
5: 'Unsupported opcode'
}
export interface PortMappingOptions {
type?: 'tcp' | 'udp'
ttl?: number
public?: number
private?: number
internal?: number
external?: number
}
export class PMPGateway extends EventEmitter implements Gateway {
public id: string
private readonly socket: Socket
private queue: Array<{ op: number, buf: Uint8Array, deferred: DeferredPromise<any> }>
private connecting: boolean
private listening: boolean
private req: any
private reqActive: boolean
public readonly host: string
public readonly port: number
public readonly family: 'IPv4' | 'IPv6'
private readonly options: GlobalMapPortOptions
private readonly refreshIntervals: Map<number, ReturnType<typeof setTimeout>>
constructor (gateway: string, options: GlobalMapPortOptions = {}) {
super()
this.queue = []
this.connecting = false
this.listening = false
this.req = null
this.reqActive = false
this.host = gateway
this.port = SERVER_PORT
this.family = isIPv4(gateway) ? 'IPv4' : 'IPv6'
this.id = this.host
this.options = options
this.refreshIntervals = new Map()
// Create socket
this.socket = createSocket({ type: 'udp4', reuseAddr: true })
this.socket.on('listening', () => { this.onListening() })
this.socket.on('message', (msg, rinfo) => { this.onMessage(msg, rinfo) })
this.socket.on('close', () => { this.onClose() })
this.socket.on('error', (err) => { this.onError(err) })
// Try to connect
this.connect()
}
connect (): void {
log('Client#connect()')
if (this.connecting) { return }
this.connecting = true
this.socket.bind(CLIENT_PORT)
}
async * mapAll (localPort: number, options: MapPortOptions = {}): AsyncGenerator<PortMapping, void, unknown> {
let mapped = false
for (const host of findLocalAddresses(this.family)) {
try {
const mapping = await this.map(localPort, host, options)
mapped = true
yield mapping
} catch (err) {
log.error('error mapping %s:%d - %e', host, localPort, err)
}
}
if (!mapped) {
throw new Error(`All attempts to map port ${localPort} failed`)
}
}
async map (localPort: number, localHost: string, opts?: MapPortOptions): Promise<PortMapping> {
const options = {
publicPort: opts?.externalPort ?? localPort,
publicHost: opts?.remoteHost ?? '',
localAddress: localHost,
protocol: opts?.protocol ?? 'tcp',
description: opts?.description ?? this.options.description ?? '@achingbrain/nat-port-mapper',
ttl: opts?.ttl ?? this.options.ttl ?? DEFAULT_PORT_MAPPING_TTL,
autoRefresh: opts?.autoRefresh ?? this.options.autoRefresh ?? true,
refreshTimeout: opts?.refreshTimeout ?? this.options.refreshTimeout ?? DEFAULT_REFRESH_TIMEOUT,
refreshBeforeExpiry: opts?.refreshThreshold ?? this.options.refreshThreshold ?? DEFAULT_REFRESH_THRESHOLD
}
log('Client#portMapping()')
let opcode: typeof OP_MAP_TCP | typeof OP_MAP_UDP
switch (options.protocol.toLowerCase()) {
case 'tcp':
opcode = OP_MAP_TCP
break
case 'udp':
opcode = OP_MAP_UDP
break
default:
throw new Error('"type" must be either "tcp" or "udp"')
}
const deferred = defer<{ public: number, private: number, ttl: number, type: 'TCP' | 'UDP' }>()
this.request(opcode, deferred, localPort, options)
const result = await raceSignal(deferred.promise, opts?.signal)
if (options.autoRefresh) {
const refresh = ((localPort: number, opts: MapPortOptions = {}): void => {
this.map(localPort, localHost, {
...opts,
signal: AbortSignal.timeout(options.refreshTimeout)
})
.catch(err => {
log.error('could not refresh port mapping - %e', err)
})
}).bind(this, localPort, {
...options,
signal: undefined
})
this.refreshIntervals.set(localPort, setTimeout(refresh, options.ttl - options.refreshBeforeExpiry))
}
return {
externalHost: isPrivateIp(localHost) === true ? await this.externalIp(opts) : localHost,
externalPort: result.public,
internalHost: localHost,
internalPort: result.private,
protocol: result.type
}
}
async unmap (localPort: number, opts?: MapPortOptions): Promise<void> {
log('Client#portUnmapping()')
await this.map(localPort, '', {
...opts,
description: '',
ttl: 0
})
}
async externalIp (options?: AbortOptions): Promise<string> {
log('Client#externalIp()')
const deferred = defer<{ ip: number[] }>()
this.request(OP_EXTERNAL_IP, deferred)
const result = await raceSignal(deferred.promise, options?.signal)
return result.ip.join('.')
}
async stop (options?: AbortOptions): Promise<void> {
log('Client#close()')
this.queue = []
this.connecting = false
this.listening = false
this.req = null
this.reqActive = false
await Promise.all([...this.refreshIntervals.entries()].map(async ([port, timeout]) => {
clearTimeout(timeout)
await this.unmap(port, options)
}))
this.refreshIntervals.clear()
if (this.socket != null) {
this.socket.close()
}
}
/**
* Queues a UDP request to be send to the gateway device.
*/
request (op: typeof OP_EXTERNAL_IP, deferred: DeferredPromise<any>): void
request (op: typeof OP_MAP_TCP | typeof OP_MAP_UDP, deferred: DeferredPromise<any>, localPort: number, obj: MapPortOptions): void
request (op: number, deferred: DeferredPromise<any>, localPort?: any, obj?: MapPortOptions): void {
log('Client#request()', [op, obj])
let buf
let size
let pos = 0
let ttl
switch (op) {
case OP_MAP_UDP:
case OP_MAP_TCP:
if (obj == null) {
throw new Error('mapping a port requires an "options" object')
}
ttl = Number(obj.ttl ?? this.options.ttl ?? 0)
if (ttl !== (ttl | 0)) {
// The RECOMMENDED Port Mapping Lifetime is 7200 seconds (two hours)
ttl = 7200
}
size = 12
buf = Buffer.alloc(size)
buf.writeUInt8(0, pos)
pos++ // Vers = 0
buf.writeUInt8(op, pos)
pos++ // OP = x
buf.writeUInt16BE(0, pos)
pos += 2 // Reserved (MUST be zero)
buf.writeUInt16BE(localPort, pos)
pos += 2 // Internal Port
buf.writeUInt16BE(obj.externalPort ?? localPort, pos)
pos += 2 // Requested External Port
buf.writeUInt32BE(ttl, pos)
pos += 4 // Requested Port Mapping Lifetime in Seconds
break
case OP_EXTERNAL_IP:
size = 2
buf = Buffer.alloc(size)
// Vers = 0
buf.writeUInt8(0, 0)
pos++
// OP = x
buf.writeUInt8(op, 1)
pos++
break
default:
throw new Error(`Invalid opcode: ${op}`)
}
// assert.equal(pos, size, 'buffer not fully written!')
// Add it to queue
this.queue.push({ op, buf, deferred })
// Try to send next message
this._next()
}
/**
* Processes the next request if the socket is listening.
*/
_next (): void {
log('Client#_next()')
const req = this.queue[0]
if (req == null) {
log('_next: nothing to process')
return
}
if (this.socket == null) {
log('_next: client is closed')
return
}
if (!this.listening) {
log('_next: not "listening" yet, cannot send out request yet')
if (!this.connecting) {
this.connect()
}
return
}
if (this.reqActive) {
log('_next: already an active request so wait...')
return
}
this.reqActive = true
this.req = req
const buf = req.buf
log('_next: sending request', buf, this.host)
this.socket.send(buf, 0, buf.length, SERVER_PORT, this.host)
}
onListening (): void {
log('Client#onListening()')
this.listening = true
this.connecting = false
// Try to send next message
this._next()
}
onMessage (msg: Buffer, rinfo: RemoteInfo): void {
// Ignore message if we're not expecting it
if (this.queue.length === 0) {
return
}
log('Client#onMessage()', [msg, rinfo])
const cb = (err?: Error, parsed?: any): void => {
this.req = null
this.reqActive = false
if (err != null) {
if (req.deferred != null) {
req.deferred.reject(err)
} else {
this.emit('error', err)
}
} else if (req.deferred != null) {
req.deferred.resolve(parsed)
}
// Try to send next message
this._next()
}
const req = this.queue[0]
const parsed: any = { msg }
parsed.vers = msg.readUInt8(0)
parsed.op = msg.readUInt8(1)
if (parsed.op - SERVER_DELTA !== req.op) {
log('WARN: ignoring unexpected message opcode', parsed.op)
return
}
// if we got here, then we're gonna invoke the request's callback,
// so shift this request off of the queue.
log('removing "req" off of the queue')
this.queue.shift()
if (parsed.vers !== 0) {
cb(new Error(`"vers" must be 0. Got: ${parsed.vers}`))
return
}
// Common fields
parsed.resultCode = msg.readUInt16BE(2)
parsed.resultMessage = RESULT_CODES[parsed.resultCode]
parsed.epoch = msg.readUInt32BE(4)
// Error
if (parsed.resultCode !== 0) {
cb(errCode(new Error(parsed.resultMessage), parsed.resultCode)); return
}
// Success
switch (req.op) {
case OP_MAP_UDP:
case OP_MAP_TCP:
parsed.private = parsed.internal = msg.readUInt16BE(8)
parsed.public = parsed.external = msg.readUInt16BE(10)
parsed.ttl = msg.readUInt32BE(12)
parsed.type = (req.op === OP_MAP_UDP) ? 'UDP' : 'TCP'
break
case OP_EXTERNAL_IP:
parsed.ip = []
parsed.ip.push(msg.readUInt8(8))
parsed.ip.push(msg.readUInt8(9))
parsed.ip.push(msg.readUInt8(10))
parsed.ip.push(msg.readUInt8(11))
break
default:
{ cb(new Error(`Unknown opcode: ${req.op}`)); return }
}
cb(undefined, parsed)
}
onClose (): void {
log('Client#onClose()')
this.listening = false
this.connecting = false
}
onError (err: Error): void {
log('Client#onError()', [err])
if (this.req?.cb != null) {
this.req.cb(err)
} else {
this.emit('error', err)
}
if (this.socket != null) {
this.socket.close()
// Force close - close() does not guarantee to trigger onClose()
this.onClose()
}
}
}