net: start using bsocks and bupnp.

This commit is contained in:
Christopher Jeffrey 2017-11-01 13:42:57 -07:00
parent ce8b6f483f
commit 277ac9a62a
No known key found for this signature in database
GPG Key ID: 8962AB9DE6666BBD
7 changed files with 2 additions and 1538 deletions

View File

@ -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');

View File

@ -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;

View File

@ -1,3 +0,0 @@
'use strict';
exports.unsupported = true;

View File

@ -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
});
};

View File

@ -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;

View File

@ -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 += `</${key}>`;
}
return ''
+ '<?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=${type}>`
+ `${params}`
+ `</u:${action}>`
+ '</s:Body>'
+ '</s:Envelope>';
};
/**
* 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;