diff --git a/.gitignore b/.gitignore index f890669..91fa8cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -/config/local.php -/database.json -/log -/vendor/ +/node_modules/ +npm-debug.log diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..205f370 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,23 @@ +{ + "bitwise": true, + "curly": true, + "eqeqeq": true, + "es5": true, + "latedef": true, + "laxbreak": true, + "maxcomplexity": 10, + "maxdepth": 5, + "maxlen": 80, + "maxparams": 4, + "maxstatements": 15, + "newcap": true, + "node": true, + "noempty": true, + "nonew": true, + "quotmark": true, + "smarttabs": true, + "strict": false, + "trailing": true, + "undef": true, + "unused": true +} diff --git a/README.md b/README.md index 4573140..1f568ac 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Xen Orchestra Server -XO-Server is part of [Xen Orchestra](https://github.com/vatesfr/xo), a web interface for XenServer (or XAPI enabled) hosts. +XO-Server is part of [Xen Orchestra](https://github.com/vatesfr/xo), a web interface for XenServer or XAPI enabled hosts. It contains all the logic of XO and handles: -- connections to all XCP servers/pools; +- connections to all XAPI servers/pools; - a cache system to provide the best response time possible; - users authentication and authorizations; - a JSON-RPC based interface for XO clients (i.e. [XO-Web](https://github.com/vatesfr/xo-web)). @@ -16,11 +16,9 @@ __XO is currently under development and may be subject to important bugs.__ _There is currently no package available for XO-Server, you must therefore use the following procedure._ 1. Download the code, you may either use git `git clone git://github.com/vatesfr/xo-server` or download a [Zip archive](https://github.com/vatesfr/xo-server/archive/master.zip). -2. XO-Web uses [Composer](https://getcomposer.org) for its dependency management, so, once you have [installed it](https://getcomposer.org/download/), juste run `php composer.phar install`. -3. Copy `config/local.php.dist` to `config/local.php` and complete the configuration. -4. Finally, run `./xo-server`. - -The first time you start XO-Server an `admin` user with the `admin` password is created. +2. You need [node.js](http://nodejs.org/) running. Go in the xo-server folder and do a `npm update && npm install`. +3. Go into `public/http` folder and symlink to xo-web by doing this: `for f in ../../../xo-web/public/*; do ln -s "$f" .;done` +4. Finally, run `./xo-server`, your XO install is available on `http://IPADDRESS:8080` ## How to report a bug? diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb957b8 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "author": { + "name": "Julien Fontanet", + "email": "julien.fontanet@vates.fr", + "url": "http://vates.fr/" + }, + "name": "xo-server", + "version": "0.1.0", + "homepage": "http://github.com/vatesfr/xo-server/", + "repository": { + "type": "git", + "url": "git://github.com/vatesfr/xo-server.git" + }, + "main": "src/main.js", + "dependencies": { + "connect": ">=2.8.4", + "extendable": ">=0.0.3", + "hashy": ">=0.1.0", + "q": ">=0.9.6", + "sync": ">=0.2.2", + "underscore": ">=1.4.4", + "validator": ">=1.2.1", + "ws": ">=0.4.27", + "xmlrpc": ">=1.1.0" + }, + "devDependencies": {}, + "optionalDependencies": {}, + "engines": { + "node": "*" + } +} diff --git a/public/http/.keepme b/public/http/.keepme new file mode 100644 index 0000000..e69de29 diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..09161d1 --- /dev/null +++ b/src/api.js @@ -0,0 +1,736 @@ +var _ = require('underscore'); +var Q = require('q'); + +////////////////////////////////////////////////////////////////////// + +function deprecated(fn) +{ + return function (session, req) { + console.warn(req.method +' is deprecated!'); + + return fn.apply(this, arguments); + }; +} + +////////////////////////////////////////////////////////////////////// + +function Api(xo) +{ + if ( !(this instanceof Api) ) + { + return new Api(xo); + } + + this.xo = xo; +} + +Api.prototype.exec = function (session, request) { + /* jshint newcap: false */ + + var method = this.getMethod(request.method); + + if (!method) + { + console.warn('Invalid method: '+ request.method); + return Q.reject(Api.err.INVALID_METHOD); + } + + try + { + return Q(method.call(this, session, request)); + } + catch (e) + { + return Q.reject(e); + } +}; + +Api.prototype.getMethod = function (name) { + /* jshint noempty: false */ + + var parts = name.split('.'); + + var current = Api.fn; + for ( + var i = 0, n = parts.length; + (i < n) && (current = current[parts[i]]); + ++i + ) + {} + + // Method found. + if (_.isFunction(current)) + { + return current; + } + + // It's a (deprecated) alias. + if (_.isString(current)) + { + return deprecated(this.getMethod(current)); + } + + // No entry found, looking for a catch-all method. + current = Api.fn; + var catch_all; + for (i = 0; (i < n) && (current = current[parts[i]]); ++i) + { + catch_all = current.__catchAll || catch_all; + } + + return catch_all; +}; + +module.exports = Api; + +////////////////////////////////////////////////////////////////////// + +function err(code, message) +{ + return { + 'code': code, + 'message': message + }; +} + +Api.err = { + + ////////////////////////////////////////////////////////////////// + // JSON-RPC errors. + ////////////////////////////////////////////////////////////////// + + 'INVALID_JSON': err(-32700, 'invalid JSON'), + + 'INVALID_REQUEST': err(-32600, 'invalid JSON-RPC request'), + + 'INVALID_METHOD': err(-32601, 'method not found'), + + 'INVALID_PARAMS': err(-32602, 'invalid parameter(s)'), + + 'SERVER_ERROR': err(-32603, 'unknown error from the server'), + + ////////////////////////////////////////////////////////////////// + // XO errors. + ////////////////////////////////////////////////////////////////// + + 'NOT_IMPLEMENTED': err(0, 'not implemented'), + + 'NO_SUCH_OBJECT': err(1, 'no such object'), + + // Not authenticated or not enough permissions. + 'UNAUTHORIZED': err(2, 'not authenticated or not enough permissions'), + + // Invalid email & passwords or token. + 'INVALID_CREDENTIAL': err(3, 'invalid credential'), + + 'ALREADY_AUTHENTICATED': err(4, 'already authenticated'), +}; + +////////////////////////////////////////////////////////////////////// + +// Helper functions that should be written: +// - checkParams(req.params, param1, ..., paramN).then(...) +// - checkPermission(xo, session, [permission]).then(...) + +// @todo Put helpers in their own namespace. +Api.prototype.checkPermission = function (session, permission) +{ + // @todo Handle token permission. + + var user_id = session.get('user_id'); + + if (undefined === user_id) + { + return Q.reject(Api.err.UNAUTHORIZED); + } + + if (!permission) + { + /* jshint newcap: false */ + return Q(); + } + + return this.xo.users.first(user_id).then(function (user) { + if (!user.hasPermission(permission)) + { + throw Api.err.UNAUTHORIZED; + } + }); +}; + +////////////////////////////////////////////////////////////////////// + +Api.fn = {}; + +Api.fn.api = { + 'getVersion' : function () { + return '0.1'; + }, +}; + +// Session management +Api.fn.session = { + 'signInWithPassword': function (session, req) { + var p_email = req.params.email; + var p_pass = req.params.password; + + if (!p_email || !p_pass) + { + throw Api.err.INVALID_PARAMS; + } + + if (session.has('user_id')) + { + throw Api.err.ALREADY_AUTHENTICATED; + } + + return this.xo.users.first({'email': p_email}).then(function (user) { + if (!user) + { + throw Api.err.INVALID_CREDENTIAL; + } + + return user.checkPassword(p_pass).then(function (success) { + if (!success) + { + throw Api.err.INVALID_CREDENTIAL; + } + + session.set('user_id', user.get('id')); + return true; + }); + }); + }, + + 'signInWithToken': function (session, req) { + var p_token = req.params.token; + + if (!p_token) + { + throw Api.err.INVALID_PARAMS; + } + + if (session.has('user_id')) + { + throw Api.err.ALREADY_AUTHENTICATED; + } + + return this.xo.tokens.first(p_token).then(function (token) { + if (!token) + { + throw Api.err.INVALID_CREDENTIAL; + } + + session.set('token_id', token.get('id')); + session.set('user_id', token.get('user_id')); + return true; + }); + }, + + 'getUser': deprecated(function (session) { + var user_id = session.get('user_id'); + if (undefined === user_id) + { + return null; + } + + return this.xo.users.first(user_id).then(function (user) { + return _.pick(user.properties, 'id', 'email', 'permission'); + }); + }), + + 'getUserId': function (session) { + return session.get('user_id', null); + }, + + 'createToken': 'token.create', + + 'destroyToken': 'token.delete', +}; + +// User management. +Api.fn.user = { + 'create': function (session, req) { + var p_email = req.params.email; + var p_pass = req.params.password; + var p_perm = req.params.permission; + + if (!p_email || !p_pass) + { + throw Api.err.INVALID_PARAMS; + } + + var users = this.xo.users; + return this.checkPermission(session, 'admin').then(function () { + return users.create(p_email, p_pass, p_perm); + }).then(function (user) { + return (''+ user.get('id')); + }); + }, + + 'delete': function (session, req) { + var p_id = req.params.id; + if (undefined === p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var users = this.xo.users; + return this.checkPermission(session, 'admin').then(function () { + return users.remove(p_id); + }).then(function (success) { + if (!success) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return true; + }); + }, + + 'changePassword': function (session, req) { + var p_old = req.params.old; + var p_new = req.params['new']; + if ((undefined === p_old) || (undefined === p_new)) + { + throw Api.err.INVALID_PARAMS; + } + + var user_id = session.get('user_id'); + if (undefined === user_id) + { + throw Api.err.UNAUTHORIZED; + } + + var user; + var users = this.xo.users; + return users.first(user_id).then(function (u) { + user = u; + + return user.checkPassword(p_old); + }).then(function (success) { + if (!success) + { + throw Api.err.INVALID_CREDENTIAL; + } + + return user.setPassword(p_new); + }).then(function () { + return users.update(user).thenResolve(true); + }); + }, + + 'get': function (session, req) { + var p_id = req.params.id; + if (undefined === p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var promise; + if (session.get('user_id') === p_id) + { + /* jshint newcap: false */ + promise = Q(); + } + else + { + promise = this.checkPermission(session, 'admin'); + } + + var users = this.xo.users; + return promise.then(function () { + return users.first(p_id); + }).then(function (user) { + if (!user) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return _.pick(user.properties, 'id', 'email', 'permission'); + }); + }, + + 'getAll': function (session) { + var users = this.xo.users; + return this.checkPermission(session, 'admin').then(function () { + return users.get(); + }).then(function (all_users) { + for (var i = 0, n = all_users.length; i < n; ++i) + { + all_users[i] = _.pick( + all_users[i], + 'id', 'email', 'permission' + ); + } + + return all_users; + }); + }, + + 'set': function (session, request) { + var p_id = request.params.id; + var p_email = request.params.email; + var p_password = request.params.password; + var p_permission = request.params.permission; + + if ((undefined === p_id) + || ((undefined === p_email) + && (undefined === p_password) + && (undefined === p_permission))) + { + throw Api.err.INVALID_PARAMS; + } + + var user_id = session.get('user_id'); + if (undefined === user_id) + { + throw Api.err.UNAUTHORIZED; + } + + var users = this.xo.users; + return users.first(user_id).then(function (user) { + // Get the current user to check its permission. + if (!user.hasPermission('admin')) + { + throw Api.err.UNAUTHORIZED; + } + + + // @todo Check there are no invalid parameter. + return users.first(p_id).fail(function () { + throw Api.err.INVALID_PARAMS; + }); + }).then(function (user) { + // @todo Check user exists. + + // Gets the user to update. + + // @todo Check undefined value are ignored. + user.set({ + 'email': p_email, + 'permission': p_permission, + }); + + if (p_password) + { + return user.setPassword(p_password).thenResolve(user); + } + + return user; + }).then(function (user) { + // Save the updated user. + + return users.update(user); + }).thenResolve(true); + }, +}; + +// Token management. +Api.fn.token = { + 'create': function (session) { + var user_id = session.get('user_id'); + if ((undefined === user_id) + || session.has('token_id')) + { + throw Api.err.UNAUTHORIZED; + } + + // @todo Token permission. + + return this.xo.tokens.generate(user_id).then(function (token) { + return token.get('id'); + }); + }, + + 'delete': function (session, req) { + var p_token = req.params.token; + + var tokens = this.xo.tokens; + return tokens.first(p_token).then(function (token) { + if (!token) + { + throw Api.err.INVALID_PARAMS; + } + + // @todo Returns NO_SUCH_OBJECT if the token does not exists. + return tokens.remove(p_token).thenResolve(true); + }); + }, +}; + +// Pool management. +Api.fn.server = { + 'add': function (session, req) { + var p_host = req.params.host; + var p_username = req.params.username; + var p_password = req.params.password; + + if (!p_host || !p_username || !p_password) + { + throw Api.err.INVALID_PARAMS; + } + + var servers = this.xo.servers; + return this.checkPermission(session, 'admin').then(function () { + // @todo We are storing passwords which is bad! + // Can we use tokens instead? + return servers.add({ + 'host': p_host, + 'username': p_username, + 'password': p_password, + }); + }).then(function (server) { + return (''+ server.id); + }); + }, + + 'remove': function (session, req) { + var p_id = req.params.id; + + if (undefined === p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var servers = this.xo.servers; + return this.checkPermission(session, 'admin').then(function () { + return servers.remove(p_id); + }).then(function(success) { + if (!success) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return true; + }); + }, + + 'getAll': function (session) { + var servers = this.xo.servers; + return this.checkPermission(session, 'admin').then(function () { + return servers.get(); + }).then(function (all_servers) { + _.each(all_servers, function (server, i) { + all_servers[i] = _.pick(server, 'id', 'host', 'username'); + }); + + return all_servers; + }); + }, + + 'connect': function () { + throw Api.err.NOT_IMPLEMENTED; + }, + + 'disconnect': function () { + throw Api.err.NOT_IMPLEMENTED; + }, +}; + +// Extra methods not really bound to an object. +Api.fn.xo = { + 'getStats': function () { + // @todo Keep up-to-date stats in this.xo to avoid unecessary + // (and possibly heavy) computing. + + var xo = this.xo; + return Q.all([ + xo.hosts.count(), + xo.vms.get({ + 'is_a_template': false, + 'is_control_domain': false, + }), + xo.srs.count(), + ]).spread(function (n_hosts, vms, n_srs) { + var running_vms = _.where(vms, { + 'power_state': 'Running', + }); + + var n_vifs = 0; + var n_vcpus = 0; + var memory = 0; + _.each(vms, function (vm) { + n_vifs += vm.VIFs.length; + n_vcpus += +vm.metrics.VCPUs_number; + memory += +vm.metrics.memory_actual; + }); + + return { + 'hosts': n_hosts, + 'vms': vms.length, + 'running_vms': running_vms.length, + 'memory': memory, + 'vcpus': n_vcpus, + 'vifs': n_vifs, + 'srs': n_srs, + }; + }); + }, + + 'getSessionId': function (req) { + var p_pool_id = req.params.id; + if (undefined === p_pool_id) + { + throw Api.err.INVALID_PARAMS; + } + + return this.xo.pools.first(p_pool_id).then(function (pool) { + return pool.get('sessionId'); + }); + }, +}; + +Api.fn.xapi = { + '__catchAll': function (session, req) { + var RE = /^xapi\.(pool|host|vm|network|sr|vdi|pif|vif)\.getAll$/; + var match; + if (!(match = req.method.match(RE))) + { + throw Api.err.INVALID_METHOD; + } + + return this.xo[match[1] +'s'].get(); + }, + + 'vm': { + 'pause': function (session, req) { + var p_id = req.params.id; + if (!p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var xo = this.xo; + var vm; + return this.checkPermission(session, 'write').then(function () { + return xo.vms.first(p_id); + }).then(function (tmp) { + vm = tmp; + + if (!vm) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return xo.pools.first(vm.get('pool_uuid')); + }).then(function (pool) { + var xapi = xo.connections[pool.get('uuid')]; + + return xapi.call('VM.pause', vm.get('ref')); + }).thenResolve(true); + }, + + 'unpause': function (session, req) { + var p_id = req.params.id; + if (!p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var xo = this.xo; + var vm; + return this.checkPermission(session, 'write').then(function () { + return xo.vms.first(p_id); + }).then(function (tmp) { + vm = tmp; + + if (!vm) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return xo.pools.first(vm.get('pool_uuid')); + }).then(function (pool) { + var xapi = xo.connections[pool.get('uuid')]; + + return xapi.call('VM.unpause', vm.get('ref')); + }).thenResolve(true); + }, + + 'reboot': function (session, req) { + var p_id = req.params.id; + if (!p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var xo = this.xo; + var vm; + return this.checkPermission(session, 'write').then(function () { + return xo.vms.first(p_id); + }).then(function (tmp) { + vm = tmp; + + if (!vm) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return xo.pools.first(vm.get('pool_uuid')); + }).then(function (pool) { + var xapi = xo.connections[pool.get('uuid')]; + + // @todo If XS tools are unavailable, do a hard reboot. + return xapi.call('VM.clean_reboot', vm.get('ref')); + }).thenResolve(true); + }, + + 'shutdown': function (session, req) { + var p_id = req.params.id; + if (!p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var xo = this.xo; + var vm; + return this.checkPermission(session, 'write').then(function () { + return xo.vms.first(p_id); + }).then(function (tmp) { + vm = tmp; + + if (!vm) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return xo.pools.first(vm.get('pool_uuid')); + }).then(function (pool) { + var xapi = xo.connections[pool.get('uuid')]; + + // @todo If XS tools are unavailable, do a hard shutdown. + return xapi.call('VM.clean_shutdown', vm.get('ref')); + }).thenResolve(true); + }, + + // we choose to start with default additional parameters: + // false (don't start paused) and false (don't skip pre-boot checks) + 'start': function (session, req) { + var p_id = req.params.id; + if (!p_id) + { + throw Api.err.INVALID_PARAMS; + } + + var xo = this.xo; + var vm; + return this.checkPermission(session, 'write').then(function () { + return xo.vms.first(p_id); + }).then(function (tmp) { + vm = tmp; + + if (!vm) + { + throw Api.err.NO_SUCH_OBJECT; + } + + return xo.pools.first(vm.get('pool_uuid')); + }).then(function (pool) { + var xapi = xo.connections[pool.get('uuid')]; + + return xapi.call('VM.start', vm.get('ref'), false, false); + }).thenResolve(true); + }, + }, +}; diff --git a/src/collection.js b/src/collection.js new file mode 100644 index 0000000..1a7b14a --- /dev/null +++ b/src/collection.js @@ -0,0 +1,222 @@ +var _ = require('underscore'); +var Q = require('q'); + +////////////////////////////////////////////////////////////////////// + +// @todo Add events. +function Collection(models) +{ + // Parent constructor. + Collection.super_.call(this); + + this.models = {}; + + this.next_id = 0; + + if (models) + { + this.add(models); + } +} +require('util').inherits(Collection, require('events').EventEmitter); + +Collection.prototype.model = require('./model'); + +/** + * Adds new models to this collection. + */ +Collection.prototype.add = function (models, options) { + var array = true; + if (!_.isArray(models)) + { + models = [models]; + array = false; + } + + // @todo Temporary mesure, implement “set()” instead. + var replace = !!(options && options.replace); + + for (var i = 0, n = models.length; i < n; ++i) + { + var model = models[i]; + + if ( !(model instanceof this.model) ) + { + model = new this.model(model); + } + + var error = model.validate(); + if (undefined !== error) + { + // @todo Better system inspired by Backbone.js. + throw error; + } + + var id = model.get('id'); + + if (undefined === id) + { + id = this.next_id++; + model.set('id', id); + } + + // Existing models are ignored. + if (!replace && this.models[id]) + { + return Q.reject('cannot add existing models!'); + } + + this.models[id] = models[i] = model.properties; + } + + this.emit('add', models); + + /* jshint newcap: false */ + return Q(array ? models : models[0]); +}; + +/** + * + */ +Collection.prototype.count = function (properties) { + return this.get(properties).then(function (models) { + return models.length; + }); +}; + + +/** + * + */ +Collection.prototype.exists = function (properties) { + return this.first(properties).then(function (model) { + return (null !== model); + }); +}; + +/** + * + */ +Collection.prototype.first = function (properties) { + /* jshint newcap:false */ + + var model; + + if (_.isObject(properties)) + { + model = _.findWhere(this.models, properties); + } + else + { + // Research by id. + model = this.models[properties]; + } + + if (!model) + { + return Q(null); + } + + return Q(new this.model(model)); +}; + +/** + * Find all models which have a given set of properties. + * + * /!\: Does not return instance of this.model. + */ +Collection.prototype.get = function (properties) { + /* jshint newcap: false */ + + // For coherence with other methods. + if ((undefined !== properties) && !_.isObject(properties)) + { + properties = { + 'id': properties, + }; + } + + if (_.isEmpty(properties)) + { + return Q(_.values(this.models)); + } + + return Q(_.where(this.models, properties)); +}; + + +/** + * Removes models from this collection. + */ +Collection.prototype.remove = function (ids) { + if (!_.isArray(ids)) + { + ids = [ids]; + } + + _.each(ids, function (id) { + delete this.models[id]; + }, this); + + this.emit('remove', ids); + + // @todo Maybe return a more meaningful value. + /* jshint newcap: false */ + return Q(true); // @todo Returns false if it fails. +}; + +/** + * Smartly updates the collection. + * + * - Adds new models. + * - Updates existing models. + * - Removes missing models. + */ +// Collection.prototype.set = function (/*models*/) { +// // @todo +// }; + +/** + * Updates existing models. + */ +Collection.prototype.update = function (models) { + var array = true; + if (!_.isArray(models)) + { + models = [models]; + array = false; + } + + // @todo Rewrite. + for (var i = 0; i < models.length; i++) + { + var model = models[i]; + + if (model instanceof this.model) + { + model = model.properties; + } + + var id = model.id; + + // Missing models should be added not updated. + if (!this.models[id]) + { + return Q.reject('missing model'); + } + + // @todo Model validation. + + // @todo Event handling. + _.extend(this.models[id], model); + } + + /* jshint newcap: false */ + return Q(array ? models : models[0]); +}; + +Collection.extend = require('extendable'); + +////////////////////////////////////////////////////////////////////// + +module.exports = Collection; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..f977b69 --- /dev/null +++ b/src/main.js @@ -0,0 +1,267 @@ +var _ = require('underscore'); +var connect = require('connect'); +var Q = require('q'); +var Session = require('./session'); +var tcp = require('net'); +var WSServer = require('ws').Server; + +//-------------------------------------- + +var xo = require('./xo')(); + +var Api = require('./api'); +var api = new Api(xo); + +// @todo Port should be configurable. +var http_serv = require('http').createServer().listen(8080); + +////////////////////////////////////////////////////////////////////// + +function json_api_call(session, message) +{ + /* jshint newcap:false */ + + var req = { + 'id': null, + }; + + function format_error(error) + { + return JSON.stringify({ + 'jsonrpc': '2.0', + 'error': error, + 'id': req.id, + }); + } + + try + { + req = JSON.parse(message.toString()); + } + catch (e) + { + if (e instanceof SyntaxError) + { + return Q(format_error(Api.err.INVALID_JSON)); + } + return Q(format_error(Api.err.SERVER_ERROR)); + } + + /* jshint laxbreak: true */ + if (!req.method || !req.params + || (undefined === req.id) + || ('2.0' !== req.jsonrpc)) + { + return Q(format_error(Api.err.INVALID_REQUEST)); + } + + return api.exec( + session, + { + 'method': req.method, + 'params': req.params, + } + ).then( + function (result) { + return JSON.stringify({ + 'jsonrpc': '2.0', + 'result': result, + 'id': req.id, + }); + }, + function (error) { + if (error instanceof Error) + { + console.error(error.stack); + return format_error(Api.err.SERVER_ERROR); + } + + return format_error(error); + } + ); +} + +////////////////////////////////////////////////////////////////////// +// Static file serving (for XO-Web for instance). +////////////////////////////////////////////////////////////////////// + +xo.on('started', function () { + http_serv.on('request', connect() + // Compresses reponses using GZip. + .use(connect.compress()) + + // Caches the responses in memory. + //.use(connect.staticCache()) + + // Serve static files. + .use(connect.static(__dirname +'/../public/http')) + ); +}); + +////////////////////////////////////////////////////////////////////// +// WebSocket to TCP proxy (used for consoles). +////////////////////////////////////////////////////////////////////// + +// Protocol: +// +// 1. The web browser connects to the server via WebSocket. +// +// 2. It sends a first message containing the “host” and “port” to +// connect to in a JSON object. +// +// 3. All messages to send to the TCP server and received from it will +// be encoded using Base64. + +// @todo Avoid Base64 encoding and directly use binary streams. +xo.on('started', function () { + var server = new WSServer({ + 'server': http_serv, + 'path': '/websockify', + }); + + server.on('connection', function (socket) { + // Parses the first message which SHOULD contains the host and + // port of the host to connect to. + socket.once('message', function (message) { + try + { + message = JSON.parse(message); + } + catch (e) + { + socket.close(); + return; + } + + if (!message.host && !message.port) + { + socket.close(); + return; + } + + var target = tcp.createConnection(message.port, message.host); + target.on('data', function (data) { + socket.send(data.toString('base64')); + }); + target.on('end', function () { + socket.close(); + }); + target.on('error', function () { + target.end(); + }); + + socket.on('message', function (message) { + target.write(new Buffer(message, 'base64')); + }); + socket.on('close', function () { + target.end(); + }); + }); + + socket.on('error', function () { + socket.close(); + }); + }); +}); + +////////////////////////////////////////////////////////////////////// +// JSON-RPC over WebSocket. +////////////////////////////////////////////////////////////////////// + +xo.on('started', function () { + var server = new WSServer({ + 'server': http_serv, + 'path': '/api/', + }); + + server.on('connection', function (socket) { + var session = new Session(xo); + session.once('close', function () { + socket.close(); + }); + + socket.on('message', function (request) { + json_api_call(session, request).then(function (response) { + // Send response if session still open. + if (socket.readyState === socket.OPEN) + { + socket.send(response); + } + }).done(); + }); + + // @todo Ugly inter dependency. + socket.once('close', function () { + session.close(); + }); + }); +}); + +////////////////////////////////////////////////////////////////////// +// JSON-RPC over TCP. +////////////////////////////////////////////////////////////////////// + +xo.on('started', function () { + require('net').createServer(function (socket) { + var session = new Session(xo); + session.on('close', function () { + socket.end(); // @todo Check it is enough. + }); + + var length = null; // Expected message length. + var buffer = new Buffer(1024); // @todo I hate hardcoded values! + socket.on('data', function (data) { + data.copy(buffer); + + // Read the message length. + if (!length) + { + var i = _.indexOf(buffer, 10); + if (-1 === i) + { + return; + } + + length = +(buffer.toString('ascii', 0, i)); + + // If the length is NaN, we cannot do anything except + // closing the connection. + if (length !== length) + { + session.close(); + return; + } + + buffer = buffer.slice(i + 1); + } + + // We do not have received everything. + if (buffer.length < length) + { + return; + } + + json_api_call( + session, + buffer.slice(0, length).toString() + ).then(function (response) { + // @todo Handle long messages. + socket.write(response.length +'\n'+ response); + }).done(); + + // @todo Check it frees the memory. + buffer = buffer.slice(length); + + length = null; + }); + + // @todo Ugly inter dependency. + socket.once('close', function () { + session.close(); + }); + }).listen(1024); // @todo Should be configurable. +}); + +////////////////////////////////////////////////////////////////////// + +xo.start(); diff --git a/src/model.js b/src/model.js new file mode 100644 index 0000000..064be7c --- /dev/null +++ b/src/model.js @@ -0,0 +1,111 @@ +var _ = require('underscore'); + +////////////////////////////////////////////////////////////////////// + +function Model(properties) +{ + // Parent constructor. + Model.super_.call(this); + + this.properties = _.extend({}, this['default']); + + if (properties) + { + this.set(properties); + } +} +require('util').inherits(Model, require('events').EventEmitter); + +/** + * Initializes the model after construction. + */ +Model.prototype.initialize = function () {}; + +/** + * Validates the model. + * + * @returns {undefined|mixed} Returns something else than undefined if + * there was an error. + */ +Model.prototype.validate = function (/*properties*/) {}; + +/** + * Gets property. + */ +Model.prototype.get = function (property, def) { + var prop = this.properties[property]; + if (undefined !== prop) + { + return prop; + } + + return def; +}; + +/** + * Checks if a property exists. + */ +Model.prototype.has = function (property) { + return (undefined !== this.properties[property]); +}; + +/** + * Sets properties. + */ +Model.prototype.set = function (properties, value) { + if (undefined !== value) + { + var property = properties; + properties = {}; + properties[property] = value; + } + + var previous = {}; + + var model = this; + _.each(properties, function (value, key) { + if (undefined === value) + { + return; + } + + var prev = model.get(key); + + // New value. + if (value !== prev) + { + previous[key] = prev; + model.properties[key] = value; + } + }); + + if (!_.isEmpty(previous)) + { + this.emit('change', previous); + + _.each(previous, function (previous, property) { + this.emit('change:'+ property, previous); + }, this); + } +}; + +/** + * Unsets properties. + */ +Model.prototype.unset = function (properties) { + // @todo Events. + this.properties = _.omit(this.properties, properties); +}; + +/** + * Default properties. + * + * @type {Object} + */ +Model.prototype['default'] = {}; + +Model.extend = require('extendable'); + +////////////////////////////////////////////////////////////////////// + +module.exports = Model; diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..5c7f002 --- /dev/null +++ b/src/session.js @@ -0,0 +1,53 @@ +var Model = require('./model'); + +////////////////////////////////////////////////////////////////////// + +var Session = Model.extend({ + 'constructor': function (xo) { + Model.call(this); + + + var self = this; + var close = function () { + self.close(); + }; + + // If the user associated to this session is deleted or + // disabled, the session must close. + this.on('change:user_id', function () { + var event = 'user.revoked:'+ this.get('user_id'); + + xo.on(event, close); + + // Prevents a memory leak. + self.on('close', function () { + xo.removeListener(event, close); + }); + }); + + // If the token associated to this session is deleted, the + // session must close. + this.on('change:token_id', function () { + var event = 'token.revoked:'+ this.get('token_id'); + + xo.on(event, close); + + // Prevents a memory leak. + self.on('close', function () { + xo.removeListener(event, close); + }); + }); + }, + + 'close': function () { + // This function can be called multiple times but will only + // emit an event once. + this.close = function () {}; + + this.emit('close'); + }, +}); + +////////////////////////////////////////////////////////////////////// + +module.exports = Session; diff --git a/src/xapi.js b/src/xapi.js new file mode 100644 index 0000000..06c7dc8 --- /dev/null +++ b/src/xapi.js @@ -0,0 +1,52 @@ +var Q = require('q'); +var xmlrpc = require('xmlrpc'); + +Q.longStackSupport = true; + +////////////////////////////////////////////////////////////////////// + +function Xapi(host) +{ + // Parent constructor. + Xapi.super_.call(this); + + this.xmlrpc = xmlrpc.createSecureClient({ + hostname: host, + port: '443', + rejectUnauthorized: false, + }); // @todo Handle connection success/error. +} +require('util').inherits(Xapi, require('events').EventEmitter); + +Xapi.prototype.call = function (method) { + var params = Array.prototype.slice.call(arguments, 1); + + if (this.sessionId) + { + params.unshift(this.sessionId); + } + + var self = this; + return Q.ninvoke(this.xmlrpc, 'methodCall', method, params) + .then(function (value) { + if ('Success' !== value.Status) + { + throw value; + } + + return value.Value; + }); +}; + +Xapi.prototype.connect = function (username, password) { + var self = this; + + return this.call('session.login_with_password', username, password) + .then(function (session_id) { + self.sessionId = session_id; + }); +}; + +////////////////////////////////////////////////////////////////////// + +module.exports = Xapi; diff --git a/src/xo.js b/src/xo.js new file mode 100644 index 0000000..89c0853 --- /dev/null +++ b/src/xo.js @@ -0,0 +1,606 @@ +var _ = require('underscore'); +var crypto = require('crypto'); +var hashy = require('hashy'); +var Q = require('q'); + +var Collection = require('./collection'); +var Model = require('./model'); +var Xapi = require('./xapi'); + +////////////////////////////////////////////////////////////////////// + +var check = function () { + var errors; + + var validator = new (require('validator').Validator)(); + validator.error = function (err) { + (errors || (errors = [])).push(err); + return this; + }; + + var check = function (data) { + validator.check(data); + }; + check.pop = function () { + var res = errors; + errors = undefined; + return res; + }; + + return check; +}(); + +////////////////////////////////////////////////////////////////////// +// Models & Collections. +////////////////////////////////////////////////////////////////////// + +var Server = Model.extend({ + 'validate': function () { + // @todo + }, +}); + +var Servers = Collection.extend({ + 'model': Server, +}); + +//-------------------------------------------------------------------- + +// @todo We could also give a permission level to tokens (<= +// user.permission). +var Token = Model.extend({ + // Validates model attributes. + // 'validate': function (attr) { + // check(attr.id).len(10); + // check(attr.user_id).isInt(); + + // return check.pop(); + // }, +}, { + 'generate': function (user_id) { + return Q.ninvoke(crypto, 'randomBytes', 32).then(function (buf) { + return new Token({ + 'id': buf.toString('base64'), + 'user_id': user_id, + }); + }); + }, +}); + +var Tokens = Collection.extend({ + 'model': Token, + + 'generate': function (user_id) { + var self = this; + return Token.generate(user_id).then(function (token) { + return self.add(token); + }); + } +}); + +//-------------------------------------------------------------------- + +var User = Model.extend({ + 'default': { + 'permission': 'none', + }, + + // Validates model attributes. + // 'validate': function (attr) { + // check(attr.id).isInt(); + // check(attr.email).isEmail(); + // check(attr.pw_hash).len(40); + // check(attr.permission).isIn('none', 'read', 'write', 'admin'); + + // return check.pop(); + // }, + + 'setPassword': function (password) { + var self = this; + return hashy.hash(password).then(function (hash) { + self.set('pw_hash', hash); + }); + }, + + // Checks and updates the hash if necessary. + 'checkPassword': function (password) { + var hash = this.get('pw_hash'); + var user = this; + + return hashy.verify(password, hash).then(function (success) { + if (!success) + { + return false; + } + + if (hashy.needsRehash(hash)) + { + return user.setPassword(password).thenResolve(true); + } + + return true; + }); + }, + + 'hasPermission': function (permission) { + var perms = { + 'none': 0, + 'read': 1, + 'write': 2, + 'admin': 3, + }; + + return (perms[this.get('permission')] >= perms[permission]); + }, +}); + +// @todo handle email uniqueness. +var Users = Collection.extend({ + 'model': User, + + 'create': function (email, password, permission) { + var user = new User({ + 'email': email, + }); + if (permission) + { + user.set('permission', permission); + } + + var self = this; + return user.setPassword(password).then(function () { + return self.add(user); + }); + } +}); + +//-------------------------------------------------------------------- + +var Pool = Model.extend({}); + +var Pools = Collection.extend({ + 'model': Pool, +}); + +//-------------------------------------------------------------------- + +var Host = Model.extend({}); + +var Hosts = Collection.extend({ + 'model': Host, +}); + +//-------------------------------------------------------------------- + +var VM = Model.extend({}); + +var VMs = Collection.extend({ + 'model': VM, +}); + +//-------------------------------------------------------------------- + +var Network = Model.extend({}); + +var Networks = Collection.extend({ + 'model': Network, +}); + +//-------------------------------------------------------------------- + +var SR = Model.extend({}); + +var SRs = Collection.extend({ + 'model': SR, +}); + +//-------------------------------------------------------------------- + +var VDI = Model.extend({}); + +var VDIs = Collection.extend({ + 'model': VDI, +}); + +//-------------------------------------------------------------------- + +var PIF = Model.extend({}); + +var PIFs = Collection.extend({ + 'model': PIF, +}); + +//-------------------------------------------------------------------- + +var VIF = Model.extend({}); + +var VIFs = Collection.extend({ + 'model': VIF, +}); + +////////////////////////////////////////////////////////////////////// +// Collections +////////////////////////////////////////////////////////////////////// + +var VDIs = Collection.extend({ + 'model': VDI, +}); + +////////////////////////////////////////////////////////////////////// + +// @todo Really ugly. +function refresh(xo, xapi) +{ + var get_records = function (classes) { + var promises = []; + for (var i = 0, n = classes.length; i < n; i++) + { + !function (klass) { + promises.push( + xapi.call(klass +'.get_all_records') + .fail(function (error) { + console.error(klass, error); + return {}; + }) + ); + }(classes[i]); + } + return Q.all(promises); + }; + + return get_records([ + // Main classes. + 'pool', + 'host', + 'VM', + + 'network', + 'SR', + 'VDI', + + 'PIF', + 'VIF', + + // Associated classes (e.g. metrics). + 'console', + 'crashdump', + 'DR_task', + 'host_cpu', + 'host_crashdump', + 'host_metrics', + 'host_patch', + 'message', + 'PBD', + 'PCI', + 'PGPU', + 'PIF_metrics', + 'VBD', + 'VGPU', + 'VIF_metrics', + 'VM_appliance', + 'VM_metrics', + 'VM_guest_metrics', + //'VMPP', + //'VTPM', + ]).spread(function ( + pools, + hosts, + vms, + + networks, + srs, + vdis, + + pifs, + vifs, + + consoles, + crashdumps, + dr_tasks, + host_cpus, + host_crashdumps, + host_metrics, + host_patches, + messages, + pbds, + pcis, + pgpus, + pif_metrics, + vbds, + vgpus, + vif_metrics, + vm_appliances, + vm_metrics, + vm_guest_metrics + //vmpps + //vtpms + ) { + // Special case for pools. + pools = _.values(pools); + var pool_uuid = pools[0].id = pools[0].uuid; + + xo.connections[pool_uuid] = xapi; + + // @todo Remove: security concerns. + pools[0].sessionId = xapi.sessionId; + + var resolve = function (model, collection, props, include) { + /* jshint laxbreak: true */ + + if (!_.isArray(props)) + { + props = [props]; + } + + var helper; + if (include) + { + helper = function (ref) { + return collection[ref] || null; + }; + } + else + { + helper = function (ref) { + var model = collection[ref]; + return model && model.uuid || null; + }; + } + + var map = function (list, iterator) { + var result = _.isArray(list) ? [] : {}; + _.each(list, function (value, key) { + result[key] = iterator(value); + }); + return result; + }; + + for (var i = 0, n = props.length; i < n; ++i) + { + var prop = props[i]; + var ref = model[prop]; + + model[prop] = _.isArray(ref) + ? map(ref, helper) + : helper(ref); + } + }; + + // @todo Messages are linked differently. + messages = _.groupBy(messages, 'obj_uuid'); + + // @todo Cast numerical/boolean properties to correct types. + + // Resolves dependencies. + // + // 1. Associated objects are included. + // 2. Linked objects are relinked using their uuid instead of + // their reference. + _.each(pools, function (pool) { + // @todo Blobs? + + resolve(pool, srs, [ + 'crash_dump_SR', + 'default_SR', + 'suspend_image_SR', + ]); + resolve(pool, hosts, 'master'); + resolve(pool, vdis, [ + 'metadata_VDIs', + 'redo_log_vdi', + ]); + }); + _.each(hosts, function (host) { + // @todo Blobs? + + resolve(host, srs, [ + 'crash_dump_sr', + 'local_cache_sr', + 'suspend_image_SR', + ]); + resolve(host, host_crashdumps, 'host_crashdumps', true); + resolve(host, host_cpus, 'host_CPUs', true); + resolve(host, host_metrics, 'metrics', true); + resolve(host, host_patches, 'patches', true); + resolve(host, pbds, 'PBDs', true); + resolve(host, pcis, 'PCIs', true); + resolve(host, pgpus, 'PGPUs', true); + resolve(host, pifs, 'PIFs'); + resolve(host, vms, 'resident_VMs'); + }); + _.each(vms, function (vm) { + // @todo Blobs? + + resolve(vm, hosts, [ + 'affinity', + 'resident_on', + ]); + resolve(vm, vm_appliances, 'appliance', true); + resolve(vm, pcis, 'attached_PCIs', true); + resolve(vm, vms, [ + 'children', // Snapshots? + 'parent', + 'snapshot_of', + ]); + resolve(vm, consoles, 'consoles', true); + resolve(vm, crashdumps, 'crash_dumps', true); + resolve(vm, vm_guest_metrics, 'guest_metrics', true); + vm.messages = messages[vm.uuid] || []; // @todo + resolve(vm, vm_metrics, 'metrics', true); + //resolve(vm, vmpps, 'protection_policy', true); + resolve(vm, srs, 'suspend_SR'); + resolve(vm, vdis, 'suspend_VDI'); + resolve(vm, vbds, 'VBDs'); + resolve(vm, vgpus, 'VGPUs'); + resolve(vm, vifs, 'VIFs'); + //resolve(vm, vtpms, 'VTPMs'); + }); + _.each(networks, function (network) { + // @todo Blobs? + + resolve(network, pifs, 'PIFs'); + resolve(network, vifs, 'VIFs'); + }); + _.each(srs, function (sr) { + // @todo Blobs? + + resolve(sr, dr_tasks, 'introduced_by'); // @todo. + resolve(sr, pbds, 'PBDs'); + resolve(sr, vdis, 'VDIs'); + }); + _.each(vdis, function (vdi) { + resolve(vdi, crashdumps, 'crash_dumps', true); + resolve(vdi, pools, 'metadata_of_pool'); + resolve(vdi, vdis, [ + 'parent', + 'snapshot_of', + 'snapshots', + ]); + resolve(vdi, srs, 'SR'); + resolve(vdi, vbds, 'VBDs'); + }); + _.each(pifs, function (pif) { + // @todo Bonds, tunnels & VLANs. + + resolve(pif, hosts, 'host'); + resolve(pif, pif_metrics, 'metrics'); + resolve(pif, networks, 'network'); + }); + _.each(vifs, function (vif) { + resolve(vif, vif_metrics, 'metrics'); + resolve(vif, networks, 'network'); + resolve(vif, vms, 'VM'); + }); + + // Normalizes the collections. + // + // 1. The collection is converted to an array. + // 2. For each object, an identifier based on its uuid is + // created. + var normalize = function (items) { + return _.map(items, function (item, ref) { + item.id = item.uuid; + item.pool_uuid = pool_uuid; + item.ref = ref; + return item; + }); + }; + + var opts = { + 'replace': true, + }; + + return Q.all([ + xo.pools.add(pools, opts), // Special case. + xo.hosts.add(normalize(hosts), opts), + xo.vms.add(normalize(vms), opts), + + xo.networks.add(normalize(networks), opts), + xo.srs.add(normalize(srs), opts), + xo.vdis.add(normalize(vdis), opts), + ]); + }); +} + +function Xo() +{ + if ( !(this instanceof Xo) ) + { + return new Xo(); + } + + var xo = this; + + //-------------------------------------- + // Main objects (@todo should be persistent). + + xo.servers = new Servers(); + xo.tokens = new Tokens(); + xo.users = new Users(); + + // When a server is added we should connect to it and fetch data. + xo.servers.on('add', function (servers) { + _.each(servers, function (server) { + var xapi = new Xapi(server.host); + + xapi.connect(server.username, server.password).then(function () { + // @todo Use events. + !function helper() { + refresh(xo, xapi).then(function () { + setTimeout(helper, 5000); + }).done(); + }(); + }).fail(function (error) { + console.log(error); + }).done(); + }); + }); + xo.servers.on('remove', function (server_ids) { + // @todo + }); + + // xo events are used to automatically close connections if the + // associated credentials are invalidated. + xo.tokens.on('remove', function (token_ids) { + _.each(token_ids, function (token_id) { + xo.emit('token.revoked:'+ token_id); + }); + }); + xo.users.on('remove', function (user_ids) { + _.each(user_ids, function (user_id) { + xo.emit('user.revoked:'+ user_id); + }); + }); + + // Connections to Xen pools/servers. + xo.connections = {}; + + //-------------------------------------- + // Xen objects. + + xo.pools = new Pools(); + xo.hosts = new Hosts(); + xo.vms = new VMs(); + + xo.networks = new Networks(); + xo.srs = new SRs(); + xo.vdis = new VDIs(); + + // Connecting classes. (@todo VBD & SR). + xo.vifs = new VIFs(); + xo.pifs = new PIFs(); + + // ------------------------------------- + // Temporary data for testing purposes. + + xo.servers.add([{ + 'host': '192.168.1.116', + 'username': 'root', + 'password': 'qwerty', + }]).done(); + xo.users.add([{ + 'email': 'bob@gmail.com', + 'pw_hash': '$2a$10$PsSOXflmnNMEOd0I5ohJQ.cLty0R29koYydD0FBKO9Rb7.jvCelZq', + 'permission': 'admin', + }, { + 'email': 'toto@gmail.com', + 'pw_hash': '$2a$10$PsSOXflmnNMEOd0I5ohJQ.cLty0R29koYydD0FBKO9Rb7.jvCelZq', + 'permission': 'none', + }]).done(); +} +require('util').inherits(Xo, require('events').EventEmitter); + +Xo.prototype.start = function () { + + var xo = this; + + // @todo Connect to persistent collection. + + //-------------------------------------- + + xo.emit('started'); +}; + +////////////////////////////////////////////////////////////////////// + +module.exports = Xo; diff --git a/tests/websocket.js b/tests/websocket.js new file mode 100644 index 0000000..692ec05 --- /dev/null +++ b/tests/websocket.js @@ -0,0 +1,380 @@ +/* jshint loopfunc:false */ + +var assert = require('assert'); +var sync = require('sync'); +var WS = require('ws'); +var _ = require('underscore'); + +////////////////////////////////////////////////////////////////////// + +var tests = { + + 'Session management': { + + 'Password sign in': function () { + // Connects, signs in (with a password). + var conn = this.connect(); + assert(conn('session.signInWithPassword', { + 'email': 'bob@gmail.com', + 'password': '123', + })); + }, + + 'Password sign in with a inexistent user': function() { + // Connects + var conn = this.connect(); + try + { + conn('session.signInWithPassword', { + 'email': ' @gmail.com', + 'password': '123', + }); + } + catch (e) + { + // Check the error. + assert.strictEqual(e.code, 3); + return; + } + + assert(false); + }, + + /* jshint maxlen:90 */ + 'Password sign in withan existing user and incorrect password': function () { + // Connects + var conn = this.connect(); + + try + { + // Try to sign in (with password). + conn('session.signInWithPassword', { + 'email': 'bob@gmail.com', + 'password': 'abc', + }); + } + catch (e) + { + // Check if received invalid credential error. + assert.strictEqual(e.code, 3); + return; + } + + assert(false); + }, + + 'Password sign in with user already authenticated': function() { + // Connects, signs in (with a password). + var conn = this.connect(); + conn('session.signInWithPassword', { + 'email': 'bob@gmail.com', + 'password': '123', + }); + + try + { + // Try to connect with other user account + conn('session.signInWithPassword', { + 'email': 'toto@gmail.com', + 'password': '123', + }); + } + catch (e) + { + // Check if received already authenticated error. + assert.strictEqual(e.code, 4); + return; + } + + assert(false); + }, + }, + + /////////////////////////////////////// + + 'Token management': { + + 'Token sign in': function () { + // Creates a token. + var token = this.master('token.create'); + + // Connects, signs in (with a token). + var conn = this.connect(); + assert(conn('session.signInWithToken', { + 'token': token + })); + + // Check if connected with the same user. + assert.strictEqual( + conn('session.getUserId'), + this.master('session.getUserId') + ); + }, + + 'Token sign in with invalid parameter': function() { + // Connects. + var conn = this.connect(); + try + { + // Try to sign in (with a token). + conn('session.signInWithToken', { + 'token': ' ', + }); + } + catch (e) + { + // Check if received invalid credential error. + assert.strictEqual(e.code, 3); + return; + } + + assert(false); + }, + + 'Connection close out when token removed': function () { + // Creates a token. + var token = this.master('token.create'); + + // Connects again and uses the token to sign in. + var conn = this.connect(); + conn('session.signInWithToken', {'token': token}); + + // Delete the tokens. + this.master('token.delete', {'token': token}); + + // Checks the connection is closed. + assert.throws(function () { + conn('session.getUserId'); + }); + }, + }, + + /////////////////////////////////////// + + 'User management': { + + 'Create user': function() { + // Connects, sign in (with a password). + var conn = this.connect(); + conn('session.signInWithPassword', { + 'email': 'bob@gmail.com', + 'password': '123', + }); + + // Create a user account. + assert(conn('user.create', { + 'email': 'tintin@gmail.com', + 'password': 'abc', + 'permission': 'admin', + })); + }, + + 'Delete user': function() { + // Connects, sign in (with a password). + var user_id = this.master('user.create', { + 'email': 'fox@gmail.com', + 'password': '123', + 'permission': 'none', + }); + + // Delete user + assert(this.master('user.delete', { + 'id': user_id, + })); + }, + + 'Connection close out when user removed': function() { + // Connects, sign in (with a password). + var user_id = this.master('user.create', { + 'email': 'fox@gmail.com', + 'password': '123', + 'permission': 'none', + }); + + // Connects, sign in (with a password) + var conn = this.connect(); + conn('session.signInWithPassword', { + 'email': 'fox@gmail.com', + 'password': '123', + }); + + // Delete the user + this.master('user.delete', { + 'id': user_id, + }); + + // Checks the connection is closed. + assert.throws(function () { + conn('session.getUserId'); + }); + }, + + 'Change password': function() { + // Create new account. + this.master('user.create', { + 'email': 'fox@gmail.com', + 'password': '123', + 'permission': 'none', + }); + + // Connects, sign in (with a password). + var conn = this.connect(); + conn('session.signInWithPassword', { + 'email': 'fox@gmail.com', + 'password': '123', + }); + + // Change password. + conn('user.changePassword', { + 'old': '123', + 'new': 'abc', + }); + + // Check if password has changed + var conn2 = this.connect(); + assert(conn2('session.signInWithPassword', { + 'email': 'fox@gmail.com', + 'password': 'abc', + })); + }, + + 'Get all users': function() { + var users = this.master('user.getAll'); + assert(_.isArray(users)); + }, + + 'Set user': function() { + var user_id = this.master('user.create', { + 'email': 'link@gmail.com', + 'password': 'abc', + 'permission': 'none', + }); + + this.master('user.set', { + 'id': user_id, + 'email': 'mario@gmail.com', + 'password': '123', + }); + + var conn = this.connect(); + assert(conn('session.signInWithPassword', { + 'email': 'mario@gmail.com', + 'password': '123', + })); + }, + + }, +}; + +////////////////////////////////////////////////////////////////////// + +var next_id = 0; +function call(method, params) +{ + return function (callback) + { + var request = { + 'jsonrpc': '2.0', + 'id': next_id++, + 'method': method, + 'params': params || {}, + }; + this.send(JSON.stringify(request), function (error) { + if (error) + { + callback(error); + } + }); + + this.once('message', function (response) { + try + { + response = JSON.parse(response.toString()); + } + catch (e) + { + callback(e); + return; + } + + if (response.error) + { + // To help find the problem, the request is included + // in the error. + var error = response.error; + error.request = request; + + callback(error); + return; + } + + callback(null, response.result); + }); + }.sync(this); +} + +function connect(url) +{ + var socket; + + (function (callback) + { + socket = new WS(url); + socket.on('open', function () { + callback(null, socket); + }); + socket.on('error', function (error) { + callback(error); + }); + }).sync(); + + var conn = function (method, params) { + return call.call(socket, method, params); + }; + return conn; +} + +////////////////////////////////////////////////////////////////////// + +sync(function () { + + // All tests have access to this master connection to create + // initial data. + var master = connect('ws://localhost:8080/'); + master('session.signInWithPassword', { + 'email': 'bob@gmail.com', + 'password': '123', + }); + + var self = { + 'connect': function () { + return connect('ws://localhost:8080/'); + }, + 'master': master, + }; + + for (var category in tests) + { + console.log(); + console.log(category); + console.log('===================='); + + for (var test in tests[category]) + { + console.log('- '+ test); + + var f = tests[category][test]; + try + { + f.call(self); + } + catch (error) + { + console.error(error); + } + } + } +}, function () { + process.exit(); +}); + diff --git a/xo-server b/xo-server index c185056..ce342ff 100755 --- a/xo-server +++ b/xo-server @@ -1,72 +1,60 @@ -#!/usr/bin/php -. - * - * @author Julien Fontanet - * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 - * - * @package Xen Orchestra Server - */ +#/bin/sh -eu -/** - * Bootstraps and returns the application singleton. - */ -function _bootstrap() +# This file is a part of Xen Orchestra Server. +# +# Xen Orchestra Server is free software: you can redistribute it +# and/or modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# Xen Orchestra Server is distributed in the hope that it will be +# useful, but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Xen Orchestra Server. If not, see +# . +# +# @author Julien Fontanet +# @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 +# +# @package Xen Orchestra Server + + +# _fail message +_fail() { - static $application; - - if (!isset($application)) - { - // Class autoloading is done by composer. - require(__DIR__.'/vendor/autoload.php'); - - // Reads configuration. - $config = new Config; - foreach (array('global', 'local') as $file) - { - $file = __DIR__.'/config/'.$file.'.php'; - if (is_file($file)) - { - $config->merge(require($file)); - } - } - - // Injects some variables. - $config['root_dir'] = __DIR__; - - // Dependency injector. - $di = new DI; - $di->set('config', $config); - - // Logs all errors. - $error_logger = $di->get('error_logger'); - set_error_handler(array($error_logger, 'log')); - register_shutdown_function(array($error_logger, 'handleShutdown')); - - // Finally, creates the application. - $application = $di->get('application'); - } - - return $application; + printf '%s\n' "$1" >&2 + exit 1 } -_bootstrap()->run(); +# _have +_have() +{ + type "$1" 2> /dev/null >&2 +} -// Local Variables: -// mode: php -// End: +######################################## + +cd -P "$(dirname "$(which "$0")")" + +######################################## + +if [ "${NODE:-}" ] +then + node=$NODE + unset NODE # Unexports it. +elif _have node +then + node=node +elif _have nodejs +then + node=nodejs +else + _fail 'node.js could not be found' +fi + +######################################## + +exec "$node" "src/main.js" "$@"