/*!
*
* Copyright (c) 2013 Sebastian Golasch
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
'use strict';
// ext. libs
var http = require('http');
var os = require('os');
var Q = require('q');
/**
* Sets the configuration options for the
* dalek remote browser executor
*
* @param {options} opts Configuration options
* @constructor
*/
var Host = function (opts) {
this.reporterEvents = opts.reporterEvents;
this.config = opts.config;
};
/**
* Remote Dalek host proxy
*
* @module Dalek
* @class Host
* @part Remote
* @api
*/
Host.prototype = {
/**
* Default port that the Dalek remote server is linking against
*
* @property defaultPort
* @type {integer}
* @default 9020
*/
defaultPort: 9020,
/**
* Instance of the local browser
*
* @property bro
* @type {object}
* @default null
*/
bro: null,
/**
* Instance of the reporter event emitter
*
* @property reporterEvents
* @type {EventEmitter2}
* @default null
*/
reporterEvents: null,
/**
* Instance of the dalek config
*
* @property config
* @type {Dalek.Config}
* @default null
*/
config: null,
/**
* Local configuration
*
* @property configuration
* @type {object}
* @default {}
*/
configuration: {},
/**
* Host address of the called webdriver server
*
* @property remoteHost
* @type {string}
* @default null
*/
remoteHost: null,
/**
* Path of the webdriver server endpoint
*
* @property remotePath
* @type {string}
* @default null
*/
remotePath: null,
/**
* Port of the called webdriver server
*
* @property remotePort
* @type {string}
* @default null
*/
remotePort: null,
/**
* Secret that got emitted by the remote instance
*
* @property remoteSecret
* @type {string}
* @default null
*/
remoteSecret: null,
/**
* Identifier of the remote client
*
* @property remoteId
* @type {string}
* @default null
*/
remoteId: null,
/**
* Secret that is stored in the local instance
*
* @property secret
* @type {string}
* @default null
*/
secret: null,
/**
* Incoming message that needs to be proxied
* to the local webdriver server
*
* @property proxyRequest
* @type {http.IncomingMessage}
* @default null
*/
proxyRequest: null,
/**
* Starts the remote proxy server,
* prepares the config
*
* @method run
* @param {object} opts Configuration options
* @chainable
*/
run: function (opts) {
// apply configuration
this.configuration = this.config.get('host') || {};
this.configuration.host = this.configuration.host ? !this.configuration.port : 'localhost';
this.secret = this.configuration.secret ? this.configuration.secret : this.secret;
if (!this.configuration.port || opts.port) {
this.configuration.port = opts.port ? opts.port : this.defaultPort;
}
// start the proxy server// emit the instance ready event
this.server = http.createServer(this._createServer.bind(this)).listen(this.configuration.port, this.reporterEvents.emit.bind(this.reporterEvents, 'report:remote:ready', {ip: this._getLocalIp(), port: this.configuration.port}));
return this;
},
/**
* Shutdown the proxy server
*
* @method kill
* @return {object} Promise
*/
kill: function () {
var deferred = Q.defer();
this.server.close(deferred.resolve);
return deferred.promise;
},
/**
* Launches the local browser
*
* @method _launcher
* @param {object} request Request from the dalek remote caller
* @param {object} response Response to the dalek remote caller
* @private
* @chainable
*/
_launcher: function (request, response) {
// extract the browser id from the request url
var browser = this._extractBrowser(request.url);
// load local browser module
this.bro = this._loadBrowserModule(browser, response);
// launch the local browser
if (this.bro) {
this.bro
.launch({}, this.reporterEvents, this.config)
.then(this._onBrowserLaunch.bind(this, browser, response));
}
return this;
},
/**
* Shuts the local browser down,
* end the otherwise hanging request
*
* @method _launcher
* @param {object} response Response to the dalek remote caller
* @private
* @chainable
*/
_killer: function (response) {
if (this.bro) {
this.bro.kill();
}
response.setHeader('Connection', 'close');
response.end();
this.reporterEvents.emit('report:remote:closed', {id: this.remoteId, browser: this.bro.longName});
return this;
},
/**
* Requires the local browser module & returns it
*
* @method _loadBrowserModule
* @param {string} browser Name of the browser to load
* @param {object} response Response to the dalek remote caller
* @return {object} The local browser module
* @private
*/
_loadBrowserModule: function (browser, response) {
var bro = null;
try {
bro = require('dalek-browser-' + browser);
} catch (e) {
try {
bro = require('dalek-browser-' + browser + '-canary');
} catch (e) {
response.setHeader('Connection', 'close');
response.end(JSON.stringify({error: 'The requested browser "' + browser + '" could not be loaded'}));
}
}
return bro;
},
/**
* Stores network data from the local browser instance,
* sends browser specific data to the client
*
* @method _onBrowserLaunch
* @param {string} browser Name of the browser to load
* @param {object} response Response to the dalek remote caller
* @chainable
* @private
*/
_onBrowserLaunch: function (browser, response) {
this.remoteHost = this.bro.getHost();
this.remotePort = this.bro.getPort();
this.remotePath = this.bro.path.replace('/', '');
this.reporterEvents.emit('report:remote:established', {id: this.remoteId, browser: this.bro.longName});
response.setHeader('Connection', 'close');
response.end(JSON.stringify({browser: browser, caps: this.bro.desiredCapabilities, defaults: this.bro.driverDefaults, name: this.bro.longName}));
return this;
},
/**
* Dispatches all incoming requests,
* possible endpoints local webdriver server,
* browser launcher, browser shutdown handler
*
* @method _createServer
* @param {object} request Request from the dalek remote caller
* @param {object} response Response to the dalek remote caller
* @private
* @chainable
*/
_createServer: function (request, response) {
// delegate calls based on url
if (request.url.search('/dalek/launch/') !== -1) {
// store the remotes ip address
this.remoteId = request.connection.remoteAddress;
// store the remote secret
if (request.headers['secret-token']) {
this.remoteSecret = request.headers['secret-token'];
}
// check if the secrets match, then launch browser
// else emit an error
if (this.secret === this.remoteSecret) {
this._launcher(request, response);
} else {
response.setHeader('Connection', 'close');
response.end(JSON.stringify({error: 'Secrets do not match'}));
}
} else if (request.url.search('/dalek/kill') !== -1) {
this._killer(response);
} else {
this.proxyRequest = http.request(this._generateProxyRequestOptions(request.headers, request.method, request.url), this._onProxyRequest.bind(this, response, request));
request.on('data', this._onRequestDataChunk.bind(this));
request.on('end', this.proxyRequest.end.bind(this.proxyRequest));
}
return this;
},
/**
* Proxies data from the local webdriver server to the client
*
* @method _onRequestDataChunk
* @param {buffer} chunk Chunk of the incoming request data
* @private
* @chainable
*/
_onRequestDataChunk: function (chunk) {
this.proxyRequest.write(chunk, 'binary');
return this;
},
/**
* Proxies remote data to the webdriver server
*
* @method _onProxyRequest
* @param {object} request Request from the dalek remote caller
* @param {object} response Response to the dalek remote caller
* @param {object} res Response to the local webdriver server
* @private
* @chainable
*/
_onProxyRequest: function (response, request, res) {
var chunks = [];
// deny access if the remote ids (onitial request, webdriver request) do not match
if (this.remoteId !== request.connection.remoteAddress) {
response.setHeader('Connection', 'close');
response.end();
return this;
}
res.on('data', function (chunk) {
chunks.push(chunk+'');
}).on('end', this._onProxyRequestEnd.bind(this, res, response, request, chunks));
return this;
},
/**
* Handles data exchange between the client and the
* local webdriver server
*
* @method _onProxyRequest
* @param {object} request Request from the dalek remote caller
* @param {object} response Response to the dalek remote caller
* @param {object} res Response to the local webdriver server
* @param {array} chunks Array of received data pieces that should be forwarded to the local webdriver server
* @private
* @chainable
*/
_onProxyRequestEnd: function (res, response, request, chunks) {
var buf = '';
// proxy headers for the session request
if (request.url === '/session') {
response.setHeader('Connection', 'close');
Object.keys(res.headers).forEach(function (key) {
response.setHeader(key, res.headers[key]);
});
}
if (chunks.length) {
buf = chunks.join('');
}
response.write(buf);
response.end();
return this;
},
/**
* Extracts the browser that should be launched
* from the launch url request
*
* @method _extractBrowser
* @param {string} url Url to parse
* @return {string} Extracted browser
* @private
*/
_extractBrowser: function (url) {
return url.replace('/dalek/launch/', '');
},
/**
* Generates the request options from the incoming
* request that should then be forwared to the local
* webdriver server
*
* @method _generateProxyRequestOptions
* @param {object} header Header meta data
* @param {string} method HTTP method
* @param {string} url Webriver server endpoint url
* @return {object} Request options
* @private
*/
_generateProxyRequestOptions: function (headers, method, url) {
var options = {
host: this.remoteHost,
port: this.remotePort,
path: this.remotePath + url,
method: method,
headers: {
'Content-Type': headers['content-type'],
'Content-Length': headers['content-length']
}
};
// check if the path is valid,
// else prepend a `root` slash
if (options.path.charAt(0) !== '/') {
options.path = '/' + options.path;
}
return options;
},
/**
* Gets the local ip address
* (should be the IPv4 address where the runner is accessible from)
*
* @method _getLocalIp
* @return {string} Local IP address
* @private
*/
_getLocalIp: function () {
var ifaces = os.networkInterfaces();
var address = [null];
for (var dev in ifaces) {
var alias = [0];
ifaces[dev].forEach(this._grepIp.bind(this, alias, address));
}
return address[0];
},
/**
* Tries to find the local IP address
*
* @method _grepIp
* @param
* @param
* @param
* @chainable
* @private
*/
_grepIp: function (alias, address, details) {
if (details.family === 'IPv4') {
if (details.address !== '127.0.0.1') {
address[0] = details.address;
}
++alias[0];
}
return this;
}
};
module.exports = Host;