diff --git a/lib/net/proxysocket.js b/browser/proxysocket.js similarity index 100% rename from lib/net/proxysocket.js rename to browser/proxysocket.js diff --git a/lib/net/index.js b/lib/net/index.js index 261c3e93..77968eb1 100644 --- a/lib/net/index.js +++ b/lib/net/index.js @@ -21,5 +21,3 @@ exports.packets = require('./packets'); exports.Parser = require('./parser'); exports.Peer = require('./peer'); exports.Pool = require('./pool'); -exports.socks = require('./socks'); -exports.UPNP = require('./upnp'); diff --git a/lib/net/pool.js b/lib/net/pool.js index 57efa971..d60d1872 100644 --- a/lib/net/pool.js +++ b/lib/net/pool.js @@ -12,6 +12,8 @@ const EventEmitter = require('events'); const IP = require('binet'); const dns = require('bdns'); const tcp = require('btcp'); +const UPNP = require('bupnp'); +const socks = require('bsocks'); const BloomFilter = require('bfilter/lib/bloom'); const RollingFilter = require('bfilter/lib/rolling'); const secp256k1 = require('bcrypto/lib/secp256k1'); @@ -29,9 +31,7 @@ const Network = require('../protocol/network'); const Peer = require('./peer'); const external = require('./external'); const List = require('../utils/list'); -const socks = require('./socks'); const HostList = require('./hostlist'); -const UPNP = require('./upnp'); const InvItem = require('../primitives/invitem'); const packets = require('./packets'); const services = common.services; diff --git a/lib/net/socks-browser.js b/lib/net/socks-browser.js deleted file mode 100644 index 21227270..00000000 --- a/lib/net/socks-browser.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -exports.unsupported = true; diff --git a/lib/net/socks.js b/lib/net/socks.js deleted file mode 100644 index e55993f3..00000000 --- a/lib/net/socks.js +++ /dev/null @@ -1,780 +0,0 @@ -/*! - * socks.js - socks proxy for bcoin - * Copyright (c) 2014-2017, Christopher Jeffrey (MIT License). - * https://github.com/bcoin-org/bcoin - */ - -'use strict'; - -/** - * @module net/socks - */ - -const assert = require('assert'); -const EventEmitter = require('events'); -const net = require('net'); -const {format} = require('util'); -const IP = require('binet'); - -/** - * SOCKS state machine - * @constructor - */ - -function SOCKS() { - if (!(this instanceof SOCKS)) - return new SOCKS(); - - EventEmitter.call(this); - - this.socket = new net.Socket(); - this.state = SOCKS.states.INIT; - this.target = SOCKS.states.INIT; - this.destHost = '0.0.0.0'; - this.destPort = 0; - this.username = ''; - this.password = ''; - this.name = 'localhost'; - this.destroyed = false; - this.timeout = null; - this.proxied = false; -} - -Object.setPrototypeOf(SOCKS.prototype, EventEmitter.prototype); - -SOCKS.states = { - INIT: 0, - CONNECT: 1, - HANDSHAKE: 2, - AUTH: 3, - PROXY: 4, - PROXY_DONE: 5, - RESOLVE: 6, - RESOLVE_DONE: 7 -}; - -SOCKS.statesByVal = [ - 'INIT', - 'CONNECT', - 'HANDSHAKE', - 'AUTH', - 'PROXY', - 'PROXY_DONE', - 'RESOLVE', - 'RESOLVE_DONE' -]; - -SOCKS.errors = [ - '', - 'General failure', - 'Connection not allowed', - 'Network is unreachable', - 'Host is unreachable', - 'Connection refused', - 'TTL expired', - 'Command not supported', - 'Address type not supported', - 'Unknown proxy error' -]; - -SOCKS.prototype.error = function error(err) { - if (this.destroyed) - return; - - if (err instanceof Error) { - this.emit('error', err); - this.destroy(); - return; - } - - const msg = format.apply(null, arguments); - this.emit('error', new Error(msg)); - this.destroy(); -}; - -SOCKS.prototype.getError = function getError(code) { - if (code >= SOCKS.errors.length) - return SOCKS.errors[9]; - - return SOCKS.errors[code]; -}; - -SOCKS.prototype.destroy = function destroy() { - if (this.destroyed) - return; - - this.destroyed = true; - this.socket.destroy(); - - this.stopTimeout(); - - if (this.state === this.target) - return; - - this.emit('close'); -}; - -SOCKS.prototype.startTimeout = function startTimeout() { - this.timeout = setTimeout(() => { - const state = SOCKS.statesByVal[this.state]; - this.timeout = null; - this.error('SOCKS request timed out (state=%s).', state); - }, 8000); -}; - -SOCKS.prototype.stopTimeout = function stopTimeout() { - if (this.timeout != null) { - clearTimeout(this.timeout); - this.timeout = null; - } -}; - -SOCKS.prototype.connect = function connect(port, host) { - assert(typeof port === 'number'); - assert(typeof host === 'string'); - - this.state = SOCKS.states.CONNECT; - this.socket.connect(port, host); - - this.socket.on('connect', () => { - if (this.proxied) - return; - this.handleConnect(); - }); - - this.socket.on('data', (data) => { - if (this.proxied) - return; - this.handleData(data); - }); - - this.socket.on('error', (err) => { - if (this.proxied) - return; - this.handleError(err); - }); - - this.socket.on('close', () => { - if (this.proxied) - return; - this.handleClose(); - }); -}; - -SOCKS.prototype.open = function open(options) { - assert(this.state === SOCKS.states.INIT); - - assert(options); - - if (options.username != null) { - assert(typeof options.username === 'string'); - this.username = options.username; - assert(typeof options.password === 'string', - 'Username must have a password.'); - } - - if (options.password != null) { - assert(typeof options.password === 'string'); - this.password = options.password; - } - - this.startTimeout(); - this.connect(options.port, options.host); -}; - -SOCKS.prototype.proxy = function proxy(options) { - assert(options); - assert(typeof options.destHost === 'string'); - assert(typeof options.destPort === 'number'); - - this.destHost = options.destHost; - this.destPort = options.destPort; - this.target = SOCKS.states.PROXY_DONE; - - this.open(options); -}; - -SOCKS.prototype.resolve = function resolve(options) { - assert(options); - assert(typeof options.name === 'string'); - - this.name = options.name; - this.target = SOCKS.states.RESOLVE_DONE; - - this.open(options); -}; - -SOCKS.prototype.handleConnect = function handleConnect() { - assert(this.state === SOCKS.states.CONNECT); - this.sendHandshake(); -}; - -SOCKS.prototype.handleError = function handleError(err) { - this.error(err); -}; - -SOCKS.prototype.handleClose = function handleClose() { - if (this.state !== this.target) { - const state = SOCKS.statesByVal[this.state]; - this.error('SOCKS request destroyed (state=%s).', state); - return; - } - - this.destroy(); -}; - -SOCKS.prototype.handleData = function handleData(data) { - switch (this.state) { - case SOCKS.states.INIT: - this.error('Data before SOCKS connection.'); - break; - case SOCKS.states.CONNECT: - this.error('Data before SOCKS handshake.'); - break; - case SOCKS.states.HANDSHAKE: - this.handleHandshake(data); - break; - case SOCKS.states.AUTH: - this.handleAuth(data); - break; - case SOCKS.states.PROXY: - this.handleProxy(data); - break; - case SOCKS.states.RESOLVE: - this.handleResolve(data); - break; - case SOCKS.states.PROXY_DONE: - case SOCKS.states.RESOLVE_DONE: - break; - default: - assert(false, 'Bad state.'); - break; - } -}; - -SOCKS.prototype.sendHandshake = function sendHandshake() { - let packet; - - if (this.username) { - packet = Buffer.allocUnsafe(4); - packet[0] = 0x05; - packet[1] = 0x02; - packet[2] = 0x00; - packet[3] = 0x02; - } else { - packet = Buffer.allocUnsafe(3); - packet[0] = 0x05; - packet[1] = 0x01; - packet[2] = 0x00; - } - - this.state = SOCKS.states.HANDSHAKE; - this.socket.write(packet); -}; - -SOCKS.prototype.handleHandshake = function handleHandshake(data) { - if (data.length !== 2) { - this.error('Bad SOCKS handshake response (size).'); - return; - } - - if (data[0] !== 0x05) { - this.error('Bad SOCKS version for handshake.'); - return; - } - - this.emit('handshake'); - - switch (data[1]) { - case 0xff: - this.error('No acceptable SOCKS auth methods.'); - break; - case 0x02: - this.sendAuth(); - break; - case 0x00: - this.state = SOCKS.states.AUTH; - this.auth(); - break; - default: - this.error('SOCKS handshake error: %d.', data[1]); - break; - } -}; - -SOCKS.prototype.sendAuth = function sendAuth() { - const user = this.username; - const pass = this.password; - - if (!user) { - this.error('No username passed for SOCKS auth.'); - return; - } - - if (!pass) { - this.error('No password passed for SOCKS auth.'); - return; - } - - const ulen = Buffer.byteLength(user, 'ascii'); - const plen = Buffer.byteLength(pass, 'ascii'); - const size = 3 + ulen + plen; - - const packet = Buffer.allocUnsafe(size); - - packet[0] = 0x01; - packet[1] = ulen; - packet.write(user, 2, ulen, 'ascii'); - packet[2 + ulen] = plen; - packet.write(pass, 2 + ulen, plen, 'ascii'); - - this.state = SOCKS.states.AUTH; - this.socket.write(packet); -}; - -SOCKS.prototype.handleAuth = function handleAuth(data) { - if (data.length !== 2) { - this.error('Bad packet size for SOCKS auth.'); - return; - } - - if (data[0] !== 0x01) { - this.error('Bad SOCKS auth version number.'); - return; - } - - if (data[1] !== 0x00) { - this.error('SOCKS auth failure: %d.', data[0]); - return; - } - - this.auth(); -}; - -SOCKS.prototype.auth = function auth() { - this.emit('auth'); - - switch (this.target) { - case SOCKS.states.PROXY_DONE: - this.sendProxy(); - break; - case SOCKS.states.RESOLVE_DONE: - this.sendResolve(); - break; - default: - this.error('Bad target state.'); - break; - } -}; - -SOCKS.prototype.sendProxy = function sendProxy() { - const host = this.destHost; - const port = this.destPort; - - let ip, len, type, name; - - switch (IP.getStringType(host)) { - case IP.types.IPV4: - ip = IP.toBuffer(host); - type = 0x01; - name = ip.slice(12, 16); - len = 4; - break; - case IP.types.IPV6: - ip = IP.toBuffer(host); - type = 0x04; - name = ip; - len = 16; - break; - default: - type = 0x03; - name = Buffer.from(host, 'ascii'); - len = 1 + name.length; - break; - } - - const packet = Buffer.allocUnsafe(6 + len); - - let off = 0; - - packet[off++] = 0x05; - packet[off++] = 0x01; - packet[off++] = 0x00; - packet[off++] = type; - - if (type === 0x03) - packet[off++] = name.length; - - off += name.copy(packet, off); - packet.writeUInt32BE(port, off, true); - - this.state = SOCKS.states.PROXY; - this.socket.write(packet); -}; - -SOCKS.prototype.handleProxy = function handleProxy(data) { - if (data.length < 6) { - this.error('Bad packet size for SOCKS connect.'); - return; - } - - if (data[0] !== 0x05) { - this.error('Bad SOCKS version for connect.'); - return; - } - - if (data[1] !== 0x00) { - const msg = this.getError(data[1]); - this.error('SOCKS connect error: %s.', msg); - return; - } - - if (data[2] !== 0x00) { - this.error('SOCKS connect failed (padding).'); - return; - } - - let addr; - try { - addr = parseAddr(data, 3); - } catch (e) { - this.error(e); - return; - } - - this.state = SOCKS.states.PROXY_DONE; - this.stopTimeout(); - this.proxied = true; - - this.emit('proxy address', addr); - this.emit('proxy', this.socket); -}; - -SOCKS.prototype.sendResolve = function sendResolve() { - const name = this.name; - const len = Buffer.byteLength(name, 'utf8'); - - const packet = Buffer.allocUnsafe(7 + len); - - packet[0] = 0x05; - packet[1] = 0xf0; - packet[2] = 0x00; - packet[3] = 0x03; - packet[4] = len; - packet.write(name, 5, len, 'utf8'); - packet.writeUInt32BE(0, 5 + len, true); - - this.state = SOCKS.states.RESOLVE; - this.socket.write(packet); -}; - -SOCKS.prototype.handleResolve = function handleResolve(data) { - if (data.length < 6) { - this.error('Bad packet size for tor resolve.'); - return; - } - - if (data[0] !== 0x05) { - this.error('Bad SOCKS version for tor resolve.'); - return; - } - - if (data[1] !== 0x00) { - const msg = this.getError(data[1]); - this.error('Tor resolve error: %s (%s).', msg, this.name); - return; - } - - if (data[2] !== 0x00) { - this.error('Tor resolve failed (padding).'); - return; - } - - let addr; - try { - addr = parseAddr(data, 3); - } catch (e) { - this.error(e); - return; - } - - if (addr.type === 0x03) { - this.error('Bad address type for tor resolve.'); - return; - } - - this.state = SOCKS.states.RESOLVE_DONE; - this.destroy(); - - this.emit('resolve', [addr.host]); -}; - -SOCKS.resolve = function resolve(options) { - const socks = new SOCKS(); - return new Promise((resolve, reject) => { - socks.resolve(options); - socks.on('resolve', resolve); - socks.on('error', reject); - }); -}; - -SOCKS.proxy = function proxy(options) { - const socks = new SOCKS(); - return new Promise((resolve, reject) => { - socks.proxy(options); - socks.on('proxy', resolve); - socks.on('error', reject); - }); -}; - -/** - * Proxy Socket - * @constructor - * @param {String} host - * @param {Number} port - * @param {String?} user - * @param {String?} pass - */ - -function Proxy(host, port, user, pass) { - if (!(this instanceof Proxy)) - return new Proxy(host, port, user, pass); - - EventEmitter.call(this); - - assert(typeof host === 'string'); - assert(typeof port === 'number'); - - this.socket = null; - this.host = host; - this.port = port; - this.username = user || null; - this.password = pass || null; - this.bytesWritten = 0; - this.bytesRead = 0; - this.remoteAddress = null; - this.remotePort = 0; - this.ops = []; -} - -Object.setPrototypeOf(Proxy.prototype, EventEmitter.prototype); - -Proxy.prototype.connect = async function connect(port, host) { - assert(!this.socket, 'Already connected.'); - - const options = { - host: this.host, - port: this.port, - username: this.username, - password: this.password, - destHost: host, - destPort: port - }; - - let socket; - try { - socket = await SOCKS.proxy(options); - } catch (e) { - this.emit('error', e); - return; - } - - this.remoteAddress = host; - this.remotePort = port; - this.socket = socket; - - this.socket.on('error', (err) => { - this.emit('error', err); - }); - - this.socket.on('close', () => { - this.emit('close'); - }); - - this.socket.on('data', (data) => { - this.bytesRead += data.length; - this.emit('data', data); - }); - - this.socket.on('drain', () => { - this.emit('drain'); - }); - - this.socket.on('timeout', () => { - this.emit('timeout'); - }); - - for (const op of this.ops) - op(); - - this.ops.length = 0; - - this.emit('connect'); -}; - -Proxy.prototype.setKeepAlive = function setKeepAlive(enable, delay) { - if (!this.socket) { - this.ops.push(() => { - this.socket.setKeepAlive(enable, delay); - }); - return; - } - this.socket.setKeepAlive(enable, delay); -}; - -Proxy.prototype.setNoDelay = function setNoDelay(enable) { - if (!this.socket) { - this.ops.push(() => { - this.socket.setNoDelay(enable); - }); - return; - } - this.socket.setNoDelay(enable); -}; - -Proxy.prototype.setTimeout = function setTimeout(timeout, callback) { - if (!this.socket) { - this.ops.push(() => { - this.socket.setTimeout(timeout, callback); - }); - return; - } - this.socket.setTimeout(timeout, callback); -}; - -Proxy.prototype.write = function write(data, callback) { - assert(this.socket, 'Not connected.'); - this.bytesWritten += data.length; - return this.socket.write(data, callback); -}; - -Proxy.prototype.end = function end() { - assert(this.socket, 'Not connected.'); - return this.socket.end(); -}; - -Proxy.prototype.pause = function pause() { - assert(this.socket, 'Not connected.'); - return this.socket.pause(); -}; - -Proxy.prototype.resume = function resume() { - assert(this.socket, 'Not connected.'); - return this.socket.resume(); -}; - -Proxy.prototype.destroy = function destroy() { - if (!this.socket) - return; - this.socket.destroy(); -}; - -/* - * Helpers - */ - -function parseProxy(host) { - const index = host.indexOf('@'); - - if (index === -1) { - const addr = IP.fromHostname(host, 1080); - return { - host: addr.host, - port: addr.port - }; - } - - const left = host.substring(0, index); - const right = host.substring(index + 1); - - const parts = left.split(':'); - assert(parts.length > 1, 'Bad username and password.'); - - const addr = IP.fromHostname(right, 1080); - - return { - host: addr.host, - port: addr.port, - username: parts[0], - password: parts[1] - }; -} - -function parseAddr(data, off) { - if (data.length - off < 2) - throw new Error('Bad SOCKS address length.'); - - const type = data[off]; - off += 1; - - let host, port; - - switch (type) { - case 0x01: { - if (data.length - off < 6) - throw new Error('Bad SOCKS ipv4 length.'); - - host = IP.toString(data.slice(off, off + 4)); - off += 4; - - port = data.readUInt16BE(off, true); - break; - } - case 0x03: { - const len = data[off]; - off += 1; - - if (data.length - off < len + 2) - throw new Error('Bad SOCKS domain length.'); - - host = data.toString('utf8', off, off + len); - off += len; - - port = data.readUInt16BE(off, true); - break; - } - case 0x04: { - if (data.length - off < 18) - throw new Error('Bad SOCKS ipv6 length.'); - - host = IP.toString(data.slice(off, off + 16)); - off += 16; - - port = data.readUInt16BE(off, true); - break; - } - default: { - throw new Error(`Unknown SOCKS address type: ${type}.`); - } - } - - return { type, host, port }; -} - -/* - * Expose - */ - -exports.connect = function connect(proxy, destPort, destHost) { - const addr = parseProxy(proxy); - const host = addr.host; - const port = addr.port; - const user = addr.username; - const pass = addr.password; - - const socket = new Proxy(host, port, user, pass); - socket.connect(destPort, destHost); - - return socket; -}; - -exports.resolve = function resolve(proxy, name) { - const addr = parseProxy(proxy); - return SOCKS.resolve({ - host: addr.host, - port: addr.port, - username: addr.username, - password: addr.password, - name: name - }); -}; diff --git a/lib/net/upnp-browser.js b/lib/net/upnp-browser.js deleted file mode 100644 index 05ecb876..00000000 --- a/lib/net/upnp-browser.js +++ /dev/null @@ -1,41 +0,0 @@ -/*! - * upnp-browser.js - upnp for bcoin - * Copyright (c) 2017, Christopher Jeffrey (MIT License). - * https://github.com/bcoin-org/bcoin - */ - -'use strict'; - -/** - * UPNP - * @constructor - * @ignore - * @param {String?} host - Multicast IP. - * @param {Number?} port - Multicast port. - * @param {String?} gateway - Gateway name. - */ - -function UPNP(host, port, gateway) { - throw new Error('UPNP not supported.'); -} - -/** - * Discover gateway and resolve service. - * @param {String?} host - Multicast IP. - * @param {Number?} port - Multicast port. - * @param {String?} gateway - Gateway type. - * @param {String[]?} targets - Target service types. - * @returns {Promise} Service. - */ - -UPNP.discover = function discover(host, port, gateway, targets) { - return new Promise((resolve, reject) => { - reject(new Error('UPNP not supported.')); - }); -}; - -/* - * Expose - */ - -module.exports = UPNP; diff --git a/lib/net/upnp.js b/lib/net/upnp.js deleted file mode 100644 index 94fa6a79..00000000 --- a/lib/net/upnp.js +++ /dev/null @@ -1,710 +0,0 @@ -/*! - * upnp.js - upnp for bcoin - * Copyright (c) 2017, Christopher Jeffrey (MIT License). - * https://github.com/bcoin-org/bcoin - */ - -'use strict'; - -const assert = require('assert'); -const dgram = require('dgram'); -const url = require('url'); -const breq = require('breq'); -const IP = require('binet'); - -/** - * UPNP - * @alias module:net.UPNP - * @constructor - * @param {String} [host=239.255.255.250] - Multicast IP. - * @param {Number} [port=1900] - Multicast port. - * @param {String?} gateway - Gateway name. - */ - -function UPNP(host, port, gateway) { - if (!(this instanceof UPNP)) - return new UPNP(host, port, gateway); - - this.host = host || '239.255.255.250'; - this.port = port || 1900; - this.gateway = gateway || UPNP.INTERNET_GATEWAY; - this.timeout = null; - this.job = null; -} - -/** - * Default internet gateway string. - * @const {String} - * @default - */ - -UPNP.INTERNET_GATEWAY = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1'; - -/** - * Default service types. - * @const {String[]} - * @default - */ - -UPNP.WAN_SERVICES = [ - 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'urn:schemas-upnp-org:service:WANPPPConnection:1' -]; - -/** - * Timeout before killing request. - * @const {Number} - * @default - */ - -UPNP.RESPONSE_TIMEOUT = 1000; - -/** - * Clean up current job. - * @private - * @returns {Job} - */ - -UPNP.prototype.cleanupJob = function cleanupJob() { - const job = this.job; - - assert(this.socket); - assert(this.job); - - this.job = null; - - this.socket.close(); - this.socket = null; - - this.stopTimeout(); - - return job; -}; - -/** - * Reject current job. - * @private - * @param {Error} err - */ - -UPNP.prototype.rejectJob = function rejectJob(err) { - const job = this.cleanupJob(); - job.reject(err); -}; - -/** - * Resolve current job. - * @private - * @param {Object} result - */ - -UPNP.prototype.resolveJob = function resolveJob(result) { - const job = this.cleanupJob(); - job.resolve(result); -}; - -/** - * Start gateway timeout. - * @private - */ - -UPNP.prototype.startTimeout = function startTimeout() { - this.stopTimeout(); - this.timeout = setTimeout(() => { - this.timeout = null; - this.rejectJob(new Error('Request timed out.')); - }, UPNP.RESPONSE_TIMEOUT); -}; - -/** - * Stop gateway timeout. - * @private - */ - -UPNP.prototype.stopTimeout = function stopTimeout() { - if (this.timeout != null) { - clearTimeout(this.timeout); - this.timeout = null; - } -}; - -/** - * Discover gateway. - * @private - * @returns {Promise} Location string. - */ - -UPNP.prototype.discover = async function discover() { - if (this.job) - throw new Error('Job already in progress.'); - - const socket = dgram.createSocket('udp4'); - - socket.on('error', (err) => { - this.rejectJob(err); - }); - - socket.on('message', (data, rinfo) => { - const msg = data.toString('utf8'); - this.handleMsg(msg); - }); - - this.socket = socket; - this.startTimeout(); - - const msg = '' - + 'M-SEARCH * HTTP/1.1\r\n' - + `HOST: ${this.host}:${this.port}\r\n` - + 'MAN: ssdp:discover\r\n' - + 'MX: 10\r\n' - + 'ST: ssdp:all\r\n'; - - socket.send(msg, this.port, this.host); - - return new Promise((resolve, reject) => { - this.job = { resolve, reject }; - }); -}; - -/** - * Handle incoming UDP message. - * @private - * @param {String} msg - * @returns {Promise} - */ - -UPNP.prototype.handleMsg = async function handleMsg(msg) { - if (!this.socket) - return; - - let headers; - try { - headers = UPNP.parseHeader(msg); - } catch (e) { - return; - } - - if (!headers.location) - return; - - if (headers.st !== this.gateway) - return; - - this.resolveJob(headers.location); -}; - -/** - * Resolve service parameters from location. - * @param {String} location - * @param {String[]} targets - Target services. - * @returns {Promise} - */ - -UPNP.prototype.resolve = async function resolve(location, targets) { - const host = parseHost(location); - - if (!targets) - targets = UPNP.WAN_SERVICES; - - const res = await breq({ - method: 'GET', - url: location, - timeout: UPNP.RESPONSE_TIMEOUT, - expect: 'xml' - }); - - const xml = XMLElement.fromRaw(res.body); - - const services = parseServices(xml); - assert(services.length > 0, 'No services found.'); - - const service = extractServices(services, targets); - assert(service, 'No service found.'); - assert(service.serviceId, 'No service ID found.'); - assert(service.serviceId.length > 0, 'No service ID found.'); - assert(service.controlURL, 'No control URL found.'); - assert(service.controlURL.length > 0, 'No control URL found.'); - - service.controlURL = prependHost(host, service.controlURL); - - if (service.eventSubURL) - service.eventSubURL = prependHost(host, service.eventSubURL); - - if (service.SCPDURL) - service.SCPDURL = prependHost(host, service.SCPDURL); - - return service; -}; - -/** - * Parse UPNP datagram. - * @private - * @param {String} str - * @returns {Object} - */ - -UPNP.parseHeader = function parseHeader(str) { - const lines = str.split(/\r?\n/); - const headers = Object.create(null); - - for (let line of lines) { - line = line.trim(); - - if (line.length === 0) - continue; - - const index = line.indexOf(':'); - - if (index === -1) { - const left = line.toLowerCase(); - headers[left] = ''; - continue; - } - - let left = line.substring(0, index); - let right = line.substring(index + 1); - - left = left.trim(); - right = right.trim(); - - left = left.toLowerCase(); - - headers[left] = right; - } - - return headers; -}; - -/** - * Discover gateway and resolve service. - * @param {String?} host - Multicast IP. - * @param {Number?} port - Multicast port. - * @param {String?} gateway - Gateway type. - * @param {String[]?} targets - Target service types. - * @returns {Promise} Service. - */ - -UPNP.discover = async function discover(host, port, gateway, targets) { - const upnp = new UPNP(host, port, gateway); - const location = await upnp.discover(); - const service = await upnp.resolve(location, targets); - return new UPNPService(service); -}; - -/** - * Gateway Service - * @constructor - * @ignore - * @param {Object} options - Service parameters. - */ - -function UPNPService(options) { - if (!(this instanceof UPNPService)) - return new UPNPService(options); - - this.serviceType = options.serviceType; - this.serviceId = options.serviceId; - this.controlURL = options.controlURL; - this.eventSubURL = options.eventSubURL; - this.SCPDURL = options.SCPDURL; -} - -/** - * Compile SOAP request. - * @private - * @param {String} action - * @param {String[]} args - * @returns {String} - */ - -UPNPService.prototype.createRequest = function createRequest(action, args) { - const type = JSON.stringify(this.serviceType); - - let params = ''; - - for (const [key, value] of args) { - params += `<${key}>`; - if (value != null) - params += value; - params += ``; - } - - return '' - + '' - + '' - + '' - + `` - + `${params}` - + `` - + '' - + ''; -}; - -/** - * Send SOAP request and parse XML response. - * @private - * @param {String} action - * @param {String[]} args - * @returns {XMLElement} - */ - -UPNPService.prototype.soapRequest = async function soapRequest(action, args) { - const type = this.serviceType; - const req = this.createRequest(action, args); - - const res = await breq({ - method: 'POST', - url: this.controlURL, - timeout: UPNP.RESPONSE_TIMEOUT, - expect: 'xml', - headers: { - 'Content-Type': 'text/xml; charset="utf-8"', - 'Content-Length': Buffer.byteLength(req, 'utf8').toString(10), - 'Connection': 'close', - 'SOAPAction': JSON.stringify(`${type}#${action}`) - }, - body: req - }); - - const xml = XMLElement.fromRaw(res.body); - const err = findError(xml); - - if (err) - throw err; - - return xml; -}; - -/** - * Attempt to get external IP from service (wan). - * @returns {Promise} - */ - -UPNPService.prototype.getExternalIP = async function getExternalIP() { - const action = 'GetExternalIPAddress'; - const xml = await this.soapRequest(action, []); - const ip = findIP(xml); - - if (!ip) - throw new Error('Could not find external IP.'); - - return ip; -}; - -/** - * Attempt to add port mapping to local IP. - * @param {String} remote - Remote IP. - * @param {Number} src - Remote port. - * @param {Number} dest - Local port. - * @returns {Promise} - */ - -UPNPService.prototype.addPortMapping = async function addPortMapping(remote, src, dest) { - const action = 'AddPortMapping'; - const local = IP.getPrivate(); - - if (local.length === 0) - throw new Error('Cannot determine local IP.'); - - const xml = await this.soapRequest(action, [ - ['NewRemoteHost', remote], - ['NewExternalPort', src], - ['NewProtocol', 'TCP'], - ['NewInternalClient', local[0]], - ['NewInternalPort', dest], - ['NewEnabled', 'True'], - ['NewPortMappingDescription', 'upnp:bcoin'], - ['NewLeaseDuration', 0] - ]); - - const child = xml.find('AddPortMappingResponse'); - - if (!child) - throw new Error('Port mapping failed.'); - - return child.text; -}; - -/** - * Attempt to remove port mapping from local IP. - * @param {String} remote - Remote IP. - * @param {Number} port - Remote port. - * @returns {Promise} - */ - -UPNPService.prototype.removePortMapping = async function removePortMapping(remote, port) { - const action = 'DeletePortMapping'; - - const xml = await this.soapRequest(action, [ - ['NewRemoteHost', remote], - ['NewExternalPort', port], - ['NewProtocol', 'TCP'] - ]); - - const child = xml.find('DeletePortMappingResponse'); - - if (!child) - throw new Error('Port unmapping failed.'); - - return child.text; -}; - -/** - * XML Element - * @constructor - * @ignore - */ - -function XMLElement(name) { - this.name = name; - this.type = name.replace(/^[^:]:/, ''); - this.children = []; - this.text = ''; -} - -/** - * Insantiate element from raw XML. - * @param {String} xml - * @returns {XMLElement} - */ - -XMLElement.fromRaw = function fromRaw(xml) { - const sentinel = new XMLElement(''); - const stack = [sentinel]; - - let current = sentinel; - let decl = false; - - while (xml.length > 0) { - let m; - - m = /^<\?xml[^<>]*\?>/i.exec(xml); - if (m) { - xml = xml.substring(m[0].length); - assert(current === sentinel, 'XML declaration inside element.'); - assert(!decl, 'XML declaration seen twice.'); - decl = true; - continue; - } - - m = /^<([\w:]+)[^<>]*?(\/?)>/i.exec(xml); - if (m) { - xml = xml.substring(m[0].length); - - const name = m[1]; - const trailing = m[2] === '/'; - const element = new XMLElement(name); - - if (trailing) { - current.add(element); - continue; - } - - stack.push(element); - current.add(element); - current = element; - - continue; - } - - m = /^<\/([\w:]+)[^<>]*>/i.exec(xml); - if (m) { - xml = xml.substring(m[0].length); - - const name = m[1]; - - assert(stack.length !== 1, 'No start tag.'); - - const element = stack.pop(); - - assert(element.name === name, 'Tag mismatch.'); - current = stack[stack.length - 1]; - - if (current === sentinel) - break; - - continue; - } - - m = /^([^<]+)/i.exec(xml); - if (m) { - xml = xml.substring(m[0].length); - const text = m[1]; - current.text = text.trim(); - continue; - } - - throw new Error('XML parse error.'); - } - - assert(sentinel.children.length > 0, 'No root element.'); - - return sentinel.children[0]; -}; - -/** - * Push element onto children. - * @param {XMLElement} child - * @returns {Number} - */ - -XMLElement.prototype.add = function add(child) { - return this.children.push(child); -}; - -/** - * Collect all descendants with matching name. - * @param {String} name - * @returns {XMLElement[]} - */ - -XMLElement.prototype.collect = function collect(name) { - return this._collect(name, []); -}; - -/** - * Collect all descendants with matching name. - * @private - * @param {String} name - * @param {XMLElement[]} result - * @returns {XMLElement[]} - */ - -XMLElement.prototype._collect = function _collect(name, result) { - for (const child of this.children) { - if (child.type === name) { - result.push(child); - continue; - } - - child._collect(name, result); - } - - return result; -}; - -/** - * Find child element with matching name. - * @param {String} name - * @returns {XMLElement|null} - */ - -XMLElement.prototype.find = function find(name) { - for (const child of this.children) { - if (child.type === name) - return child; - - const desc = child.find(name); - - if (desc) - return desc; - } - - return null; -}; - -/* - * XML Helpers - */ - -function parseServices(el) { - const children = el.collect('service'); - const services = []; - - for (const child of children) - services.push(parseService(child)); - - return services; -} - -function parseService(el) { - const service = Object.create(null); - - for (const child of el.children) { - if (child.children.length > 0) - continue; - - service[child.type] = child.text; - } - - return service; -} - -function findService(services, name) { - for (const service of services) { - if (service.serviceType === name) - return service; - } - - return null; -} - -function extractServices(services, targets) { - for (const name of targets) { - const service = findService(services, name); - if (service) - return service; - } - - return null; -} - -function findIP(el) { - const child = el.find('NewExternalIPAddress'); - - if (!child) - return null; - - return IP.normalize(child.text); -} - -function findError(el) { - const child = el.find('UPnPError'); - - if (!child) - return null; - - let code = -1; - const ccode = child.find('errorCode'); - - if (ccode && /^\d+$/.test(ccode.text)) - code = parseInt(ccode.text, 10); - - let desc = 'Unknown'; - const cdesc = child.find('errorDescription'); - - if (cdesc) - desc = cdesc.text; - - return new Error(`UPnPError: ${desc} (${code}).`); -} - -/* - * Helpers - */ - -function parseHost(uri) { - const {protocol, host} = url.parse(uri); - - assert(protocol === 'http:' || protocol === 'https:', - 'Bad URL for location.'); - - return `${protocol}//${host}`; -} - -function prependHost(host, uri) { - if (uri.indexOf('://') === -1) { - if (uri[0] !== '/') - uri = '/' + uri; - uri = host + uri; - } - return uri; -} - -/* - * Expose - */ - -module.exports = UPNP;