UNPKG

@silentbot1/nat-api

Version:

Port mapping with UPnP and NAT-PMP

182 lines (150 loc) 5.78 kB
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 + ':' : '' } }