@silentbot1/nat-api
Version:
Port mapping with UPnP and NAT-PMP
182 lines (150 loc) • 5.78 kB
JavaScript
import fetch from 'cross-fetch-ponyfill'
import xml2js from 'xml2js'
const ERROR_MESSAGES = {
606: 'Action not authorized',
714: 'The specified value does not exist in the array',
715: 'The source IP address cannot be wild-carded',
716: 'The external port cannot be wild-carded',
718: 'The port mapping entry specified conflicts with a mapping assigned previously to another client',
724: 'Internal and External port values MUST be the same',
725: 'The NAT implementation only supports permanent lease times on port mappings',
726: 'RemoteHost must be a wildcard and cannot be a specific IP address or DNS name',
727: 'ExternalPort MUST be a wildcard and cannot be a specific port value',
728: 'There are not enough free ports available to complete port mapping',
729: 'Attempted port mapping is not allowed due to conflict with other mechanisms',
732: 'The internal port cannot be wild-carded'
}
export default class Device {
constructor (opts = {}) {
this.url = opts.url
this.permanentFallback = opts.permanentFallback || false
this.services = [
'urn:schemas-upnp-org:service:WANIPConnection:1',
'urn:schemas-upnp-org:service:WANIPConnection:2',
'urn:schemas-upnp-org:service:WANPPPConnection:1'
]
}
async run (action, args) {
const info = await this._getService(this.services)
const requestBody = '<?xml version="1.0"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' +
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' +
'<u:' + action + ' xmlns:u=' + JSON.stringify(info.service) + '>' +
args.map((args) => {
return '<' + args[0] + '>' +
(args[1] ? args[1] : '') +
'</' + args[0] + '>'
}).join('') +
'</u:' + action + '>' +
'</s:Body>' +
'</s:Envelope>'
const res = await fetch(info.controlURL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'Content-Length': Buffer.byteLength(requestBody),
Connection: 'close',
SOAPAction: JSON.stringify(info.service + '#' + action)
},
body: requestBody
})
const rawBody = await res.text()
const parser = new xml2js.Parser(xml2js.defaults['0.1'])
const body = await parser.parseStringPromise(rawBody)
if (res.status === 500) {
const fault = body['s:Body']['s:Fault']
const faultString = fault.faultString || fault.faultstring
if (faultString.toLowerCase() === 'upnperror') {
const errorCode = parseInt(fault.detail.UPnPError.errorCode, 10)
const errorMessage = ERROR_MESSAGES[errorCode] || `Request failed, status code: ${res.status}, fault string ${faultString}`
if (errorCode === 725 && this.permanentFallback) {
args[args.length - 1] = ['NewLeaseDuration', 0]
return this.run(action, args)
} else {
throw new Error(errorMessage)
}
}
} else if (res.status !== 200) {
throw new Error('Request failed: ' + res.status)
}
const soapns = this._getNamespace(
body,
'http://schemas.xmlsoap.org/soap/envelope/'
)
return body[soapns + 'Body']
}
async _getService (types) {
const info = await this._getXml(this.url)
const s = this._parseDescription(info).services.filter((service) => {
return types.indexOf(service.serviceType) !== -1
})
// Use the first available service
if (s.length === 0 || !s[0].controlURL || !s[0].SCPDURL) {
throw new Error('Service not found')
}
const base = new URL(info.baseURL || this.url)
const addPrefix = (u) => {
let uri
try {
uri = new URL(u)
} catch (err) {
// Is only the path of the URL
uri = new URL(u, base.href)
}
uri.host = uri.host || base.host
uri.protocol = uri.protocol || base.protocol
return uri.toString()
}
return {
service: s[0].serviceType,
SCPDURL: addPrefix(s[0].SCPDURL),
controlURL: addPrefix(s[0].controlURL)
}
}
async _getXml (url) {
const response = await fetch(url)
if (response.status !== 200) throw new Error('Request failed: ' + response.status)
const text = await response.text()
const parser = new xml2js.Parser(xml2js.defaults['0.1'])
return parser.parseStringPromise(text)
}
_parseDescription (info) {
const services = []
const devices = []
const toArray = (item) => {
return Array.isArray(item) ? item : [item]
}
const traverseServices = (service) => {
if (!service) return
services.push(service)
}
const traverseDevices = (device) => {
if (!device) return
devices.push(device)
if (device.deviceList && device.deviceList.device) {
toArray(device.deviceList.device).forEach(traverseDevices)
}
if (device.serviceList && device.serviceList.service) {
toArray(device.serviceList.service).forEach(traverseServices)
}
}
traverseDevices(info.device)
return {
services,
devices
}
}
_getNamespace (data, uri) {
let ns
if (data['@']) {
Object.keys(data['@']).some((key) => {
if (!/^xmlns:/.test(key)) return false
if (data['@'][key] !== uri) return false
ns = key.replace(/^xmlns:/, '')
return true
})
}
return ns ? ns + ':' : ''
}
}