nat-api
Version:
Port mapping with UPnP and NAT-PMP
334 lines (278 loc) • 7.92 kB
JavaScript
const dgram = require('dgram')
// const assert = require('assert')
const debug = require('debug')('nat-pmp')
const EventEmitter = require('events').EventEmitter
// 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
// Resulit codes
const RESULT_CODES = {
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'
}
module.exports.connect = function (gateway) {
return new Client(gateway)
/* process.nextTick(function () {
client.connect()
}) */
}
class Client extends EventEmitter {
constructor (gateway) {
super()
if (!gateway) throw new Error('gateway is not defined')
this.gateway = gateway
this._queue = []
this._connecting = false
this._listening = false
this._req = null
this._reqActive = false
// Create socket
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
this.socket.on('listening', () => this.onListening())
this.socket.on('message', () => this.onMessage())
this.socket.on('close', () => this.onClose())
this.socket.on('error', (err) => this.onError(err))
// Try to connect
this.connect()
}
connect () {
debug('Client#connect()')
if (this._connecting) return
this._connecting = true
this.socket.bind(CLIENT_PORT)
}
portMapping (opts, cb) {
debug('Client#portMapping()')
let opcode
switch (String(opts.type || 'tcp').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"')
}
this._request(opcode, opts, cb)
}
portUnmapping (opts, cb) {
debug('Client#portUnmapping()')
opts.ttl = 0
this.portMapping(opts, cb)
}
externalIp (cb) {
debug('Client#externalIp()')
this._request(OP_EXTERNAL_IP, cb)
}
close () {
debug('Client#close()')
if (this.socket) {
this.socket.close()
}
this.socket = null
this._queue = []
this._connecting = false
this._listening = false
this._req = null
this._reqActive = false
}
/**
* Queues a UDP request to be send to the gateway device.
*/
_request (op, obj, cb) {
if (typeof obj === 'function') {
cb = obj
obj = null
}
debug('Client#request()', [op, obj])
let buf
let size
let pos = 0
let internal
let external
let ttl
switch (op) {
case OP_MAP_UDP:
case OP_MAP_TCP:
if (!obj) throw new Error('mapping a port requires an "options" object')
internal = +(obj.private || obj.internal || 0)
if (internal !== (internal | 0) || internal < 0) {
throw new Error('the "private" port must be a whole integer >= 0')
}
external = +(obj.public || obj.external || 0)
if (external !== (external | 0) || external < 0) {
throw new Error('the "public" port must be a whole integer >= 0')
}
ttl = +(obj.ttl)
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(internal, pos)
pos += 2 // Internal Port
buf.writeUInt16BE(external, 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({ buf: buf, cb: cb })
// Try to send next message
this._next()
}
/**
* Processes the next request if the socket is listening.
*/
_next () {
debug('Client#_next()')
const req = this._queue[0]
if (!req) {
debug('_next: nothing to process')
return
}
if (!this.socket) {
debug('_next: client is closed')
return
}
if (!this._listening) {
debug('_next: not "listening" yet, cannot send out request yet')
if (!this._connecting) this.connect()
return
}
if (this._reqActive) {
debug('_next: already an active request so wait...')
return
}
this._reqActive = true
this._req = req
const buf = req.buf
debug('_next: sending request', buf, this.gateway)
this.socket.send(buf, 0, buf.length, SERVER_PORT, this.gateway)
}
onListening () {
debug('Client#onListening()')
this._listening = true
this._connecting = false
// Try to send next message
this._next()
}
onMessage (msg, rinfo) {
// Ignore message if we're not expecting it
if (this._queue.length === 0) return
debug('Client#onMessage()', [msg, rinfo])
const self = this
function cb (err) {
self._req = null
self._reqActive = false
if (err) {
if (req.cb) {
req.cb.call(self, err)
} else {
self.emit('error', err)
}
} else if (req.cb) {
req.cb.apply(self, arguments)
}
// Try to send next message
self._next()
}
const req = this._queue[0]
const parsed = { msg: msg }
parsed.vers = msg.readUInt8(0)
parsed.op = msg.readUInt8(1)
if (parsed.op - SERVER_DELTA !== req.op) {
debug('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.
debug('removing "req" off of the queue')
this._queue.shift()
if (parsed.vers !== 0) {
cb(new Error('"vers" must be 0. Got: ' + parsed.vers))
return
}
// Xommon fields
parsed.resultCode = msg.readUInt16BE(2)
parsed.resultMessage = RESULT_CODES[parsed.resultCode]
parsed.epoch = msg.readUInt32BE(4)
// Error
if (parsed.resultCode !== 0) {
const err = new Error(parsed.resultMessage)
err.code = parsed.resultCode
return cb(err)
}
// 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:
return cb(new Error('Unknown opcode: ' + req.op))
}
cb(null, parsed)
}
onClose () {
debug('Client#onClose()')
this._listening = false
this._connecting = false
this.socket = null
}
onError (err) {
debug('Client#onError()', [err])
if (this._req && this._req.cb) {
this._req.cb(err)
} else {
this.emit('error', err)
}
if (this.socket) {
this.socket.close()
// Force close - close() does not guarantee to trigger onClose()
this.onClose()
}
}
}
module.exports.Client = Client