From abb2f50e105e4d91173c014c842e42b4678382a3 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 6 Feb 2013 10:29:52 +0100 Subject: [PATCH] Initial commit. --- .gitignore | 4 + composer.json | 20 + composer.lock | 152 +++++++ src/config/global.php | 39 ++ src/config/local.php.dist | 52 +++ src/lib/Application.php | 863 +++++++++++++++++++++++++++++++++++++ src/lib/Base.php | 51 +++ src/lib/BufferedWriter.php | 65 +++ src/lib/Client.php | 286 ++++++++++++ src/lib/Config.php | 159 +++++++ src/lib/Connection.php | 83 ++++ src/lib/DI.php | 112 +++++ src/lib/ErrorLogger.php | 110 +++++ src/lib/Loop.php | 176 ++++++++ src/lib/XCP.php | 198 +++++++++ src/xo-server | 73 ++++ 16 files changed, 2443 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/config/global.php create mode 100644 src/config/local.php.dist create mode 100644 src/lib/Application.php create mode 100644 src/lib/Base.php create mode 100644 src/lib/BufferedWriter.php create mode 100644 src/lib/Client.php create mode 100644 src/lib/Config.php create mode 100644 src/lib/Connection.php create mode 100644 src/lib/DI.php create mode 100644 src/lib/ErrorLogger.php create mode 100644 src/lib/Loop.php create mode 100644 src/lib/XCP.php create mode 100755 src/xo-server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd0741 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/src/config/local.php +/src/database.json +/src/log +/vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4db6a9d --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "vates/xo-server", + "description": "Xen Orchestra server.", + "license": "GPL-3.0", + "authors": [ + { + "name": "Julien Fontanet", + "email": "julien.fontanet@vates.fr" + } + ], + "require": { + "php": ">=5.3.0", + "ext-xmlrpc": "*", + "monolog/monolog": "~1.2", + "ircmaxell/password-compat": "dev-master" + }, + "autoload": { + "psr-0": { "": "src/lib" } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..2114f2e --- /dev/null +++ b/composer.lock @@ -0,0 +1,152 @@ +{ + "hash": "d65aa7e3aad71264202dfcffcccb3cc1", + "packages": [ + { + "name": "ircmaxell/password-compat", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/ircmaxell/password_compat.git", + "reference": "fc4ad2d46794ace07cbf04fe654a8bf546fc6764" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ircmaxell/password_compat/zipball/fc4ad2d46794ace07cbf04fe654a8bf546fc6764", + "reference": "fc4ad2d46794ace07cbf04fe654a8bf546fc6764", + "shasum": "" + }, + "time": "2013-02-04 16:45:02", + "type": "library", + "autoload": { + "files": [ + "lib/password.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anthony Ferrara", + "email": "ircmaxell@php.net", + "homepage": "http://blog.ircmaxell.com" + } + ], + "description": "A compatibility library for the proposed simplified password hashing algorithm: https://wiki.php.net/rfc/password_hash", + "homepage": "https://github.com/ircmaxell/password_compat", + "keywords": [ + "hashing", + "password" + ] + }, + { + "name": "monolog/monolog", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog", + "reference": "1.3.1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1.3.1", + "reference": "1.3.1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "psr/log": ">=1.0,<2.0" + }, + "require-dev": { + "doctrine/couchdb": "dev-master", + "mlehner/gelf-php": "1.0.*", + "raven/raven": "0.3.*" + }, + "suggest": { + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mongo": "Allow sending log messages to a MongoDB server", + "mlehner/gelf-php": "Allow sending log messages to a GrayLog2 server", + "raven/raven": "Allow sending log messages to a Sentry server" + }, + "time": "2013-01-11 10:23:20", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-0": { + "Monolog": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be", + "role": "Developer" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ] + }, + { + "name": "psr/log", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log", + "reference": "1.0.0" + }, + "dist": { + "type": "zip", + "url": "https://github.com/php-fig/log/archive/1.0.0.zip", + "reference": "1.0.0", + "shasum": "" + }, + "time": "2012-12-21 11:40:51", + "type": "library", + "autoload": { + "psr-0": { + "Psr\\Log\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "keywords": [ + "log", + "psr", + "psr-3" + ] + } + ], + "packages-dev": null, + "aliases": [ + + ], + "minimum-stability": "stable", + "stability-flags": { + "ircmaxell/password-compat": 20 + } +} diff --git a/src/config/global.php b/src/config/global.php new file mode 100644 index 0000000..1c30b3d --- /dev/null +++ b/src/config/global.php @@ -0,0 +1,39 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +return array( + + 'database' => array( + 'json' => '#{root_dir}/database.json', + ), + + 'listen' => array( + 'tcp://0.0.0.0:1024', + ), + + 'log' => array( + //'email' => 'your.email@example.net', + 'file' => '#{root_dir}/log', + ), +); diff --git a/src/config/local.php.dist b/src/config/local.php.dist new file mode 100644 index 0000000..d4e56cb --- /dev/null +++ b/src/config/local.php.dist @@ -0,0 +1,52 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + * + * Local Variables: + * mode: php + * End: + */ + +/* Local settings + * + * This file contains all settings related to this current + * installation of Xen Orchestra Server. + * + * You MUST define the following settings for which no default + * values exists. + * + * But, you MAY override any settings which already exists in + * “global.php”. + */ +return array( + + // For now, XCP servers/pools must be defined in this file. + 'xcp' => array( + 'pool 1' => array( + 'url' => 'https://xcp1.example.net', + 'username' => 'username', + 'password' => 'password' + ), + ), +); + + diff --git a/src/lib/Application.php b/src/lib/Application.php new file mode 100644 index 0000000..a57941a --- /dev/null +++ b/src/lib/Application.php @@ -0,0 +1,863 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class Application extends Base +{ + + /** + * + */ + const NONE = 0; + + /** + * + */ + const READ = 1; + + /** + * + */ + const WRITE = 2; + + /** + * + */ + const ADMIN = 3; + + /** + * + */ + function __construct(DI $di) + { + parent::__construct(); + + $this->_di = $di; + } + + /** + * + */ + function api_session_signInWithPassword($id, array $params, Client $c) + { + // Checks parameters. + if (!isset($params[0], $params[1])) + { + return -32602; // Invalid params. + } + list($name, $password) = $params; + + // Checks the client is not already authenticated. + if ($c->isAuthenticated()) + { + return array(0, 'already authenticated'); + } + + // Checks the user exists. + if (!isset($this->_usersByName[$name])) + { + return array(1, 'invalid credential'); + } + + $uid = $this->_usersByName[$name]; + $hash = &$this->_users[$uid]['password']; + + // Checks the password matches. + if (!password_verify($password, $hash)) + { + return array(1, 'invalid credential'); + } + + // Checks whether the hash needs to be updated. + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) + { + $hash = password_hash($password, PASSWORD_DEFAULT); + $this->_saveDatabase(); + } + + // Marks the client as authenticated. + $c->uid = $uid; + + // Returns success. + $c->respond($id, true); + } + + /** + * + */ + function api_session_signInWithToken($id, array $params, Client $c) + { + // Checks parameters. + if (!isset($params[0])) + { + return -32602; // Invalid params. + } + $token = $params[0]; + + // Checks the client is not already authenticated. + if ($c->isAuthenticated()) + { + return array(0, 'already authenticated'); + } + + // Checks the token exists. + if (!isset($this->_tokens[$token])) + { + return array(1, 'invalid token'); + } + + $record = $this->_tokens[$token]; + + // Checks the token is valid. + if ($record['expiration'] < time()) + { + unset($this->_tokens[$token]); + return array(1, 'invalid token'); + } + + // Marks the client as authenticated. + $c->uid = $record['uid']; + + // Returns success. + $c->respond($id, true); + } + + /** + * + */ + function api_session_getUser($id, array $params, Client $c) + { + if (!$c->isAuthenticated()) + { + return array(0, 'not authenticated'); + } + + $c->respond($id, array( + 'id' => (string) $c->uid, + 'name' => $this->_users[$c->uid]['name'], + 'permission' => $this->_users[$c->uid]['permission'], + )); + } + + /** + * + */ + function api_session_createToken($id, array $params, Client $c) + { + // Checks the client is authenticated. + if (!$c->isAuthenticated()) + { + return array(0, 'not authenticated'); + } + + // Generates the token and makes sure it is unique. + do + { + /* If available, we use OpenSSL to create more secure tokens. + * + * @todo Maybe we should also use a time-resistant token comparison + * algorithm in authentication. + * + * @todo Move the if outside of this function and furthermore of + * this loop for performance concerns. + */ + if (function_exists('openssl_random_pseudo_bytes')) + { + $token = bin2hex(openssl_random_pseudo_bytes(32)); + } + else + { + $token = uniqid('', true); + } + } while (isset($this->_tokens[$token])); + + // Registers it. + $this->_tokens[$token] = array( + 'expiration' => time() + 604800, // One week + 'uid' => $c->uid, + ); + $this->_saveDatabase(); + + // Returns it. + $c->respond($id, $token); + } + + /** + * + */ + function api_session_destroyToken($id, array $params, Client $c) + { + // Checks the token exists. + if (!isset($this->_tokens[$token])) + { + return array(0, 'invalid token'); + } + + // Deletes it. + unset($this->_tokens[$token]); + $this->_saveDatabase(); + + // Returns success. + $c->respond($id, true); + } + + /** + * + */ + function api_user_create($id, array $params, Client $c) + { + // Checks parameters. + if (!isset($params[0], $params[1])) + { + return -32602; // Invalid params. + } + list($name, $password) = $params; + + // Checks credentials. + if (!$c->isAuthenticated() + || !$this->_checkPermission($c->uid, self::ADMIN)) + { + return array(0, 'not authorized'); + } + + // Checks the provided user name. + if (!is_string($name) + || !preg_match('/^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/', $name)) + { + return array(1, 'invalid user name'); + } + + // Checks the provided password. + if (!is_string($password) + || !preg_match('/^.{8,}$/', $password)) + { + return array(2, 'invalid password'); + } + + // Checks provided permission. + if (isset($params[2])) + { + $permission = self::_permissionFromString($params[2]); + if ($permission === false) + { + return array(3, 'invalid permission'); + } + } + else + { + $permission = self::NONE; + } + + // Checks if the user name is already used. + if (isset($this->_usersByName[$name])) + { + return array(4, 'user name already taken'); + } + + // Creates the user. + $this->_users[] = array( + 'name' => $name, + 'password' => password_hash($password, PASSWORD_DEFAULT), + 'permission' => $permission, + ); + end($this->_users); + $uid = (string) key($this->_users); + $this->_usersByName[$name] = $uid; + $this->_saveDatabase(); + + // Returns the identifier. + $c->respond($id, $uid); + } + + /** + * + */ + function api_user_delete($id, array $params, Client $c) + { + // Checks parameter. + if (!isset($params[0])) + { + return -32602; // Invalid params. + } + $uid = $params[0]; + + // Checks credentials. + if (!$c->isAuthenticated() + || !$this->_checkPermission($c->uid, self::ADMIN)) + { + return array(0, 'not authorized'); + } + + // Checks user exists and is not the current user. + if (!isset($this->_users[$uid]) + || ($uid === $c->uid)) + { + return array(1, 'invalid user'); + } + + // Deletes the user. + $name = $this->_users[$uid]['name']; + unset($this->_users[$uid], $this->_usersByName[$name]); + $this->_saveDatabase(); + + // Returns success. + $c->respond($id, true); + } + + /** + * + */ + function api_user_changePassword($id, array $params, Client $c) + { + // Checks parameters. + if (!isset($params[0], $params[1])) + { + return -32602; // Invalid params. + } + list($old, $new) = $params; + + // Checks the client is authenticated. + if (!$c->isAuthenticated()) + { + return array(0, 'not authenticated'); + } + + $hash = &$this->_users[$c->uid]['password']; + + // Checks the old password matches. + if (!password_verify($old, $hash)) + { + return array(1, 'invalid credential'); + } + + // Checks the new password is valid. + if (!is_string($new) + || !preg_match('/^.{8,}$/', $new)) + { + return array(2, 'invalid password'); + } + + $hash = password_hash($new, PASSWORD_DEFAULT); + $this->_saveDatabase(); + + // Returns success. + $c->respond($id, true); + } + + /** + * + */ + function api_user_getAll($id, array $params, Client $c) + { + // Checks credentials. + if (!$c->isAuthenticated() + || !$this->_checkPermission($c->uid, self::ADMIN)) + { + return array(0, 'not authorized'); + } + + $users = array(); + foreach ($this->_users as $uid => $user) + { + $users[] = array( + 'id' => $uid, + 'name' => $user['name'], + 'permission' => self::_permissionToString($user['permission']), + ); + } + $c->respond($id, $users); + } + + /** + * + */ + function api_vm_getAll($id, array $params, Client $c) + { + // @todo Handles parameter. + + $c->respond($id, $this->_xenVms); + } + + /** + * + */ + function handleServer($handle, $data) + { + if (feof($handle)) + { + // Stops listening to this socket. + return false; + } + + $handle = @stream_socket_accept($handle, 10); + if (!$handle) + { + trigger_error( + 'error while handling an incoming connection', + E_USER_ERROR + ); + } + + /* Here we build a map for all available methods. + * + * This technic provides fast case sensitive matching (compare to + * “is_callable()”). + */ + static $methods; + if ($methods === null) + { + $methods = array(); + foreach (get_class_methods($this) as $method) + { + if (!substr_compare($method, 'api_', 0, 4)) + { + $_ = strtr(substr($method, 4), '_', '.'); + $methods[$_] = array($this, $method); + } + } + } + + new Client( + $data['loop'], + $handle, + $methods + ); + + echo "new client connected\n"; + } + + /** + * + */ + function handleXenEvents(array $events) + { + static $keys; + + $objects = array(); + + foreach ($events as $event) + { + $_ = array_keys($event); + if (!$keys) + { + $keys = $_; + var_export($keys); echo PHP_EOL; + } + elseif ($_ !== $keys) + { + $keys = array_intersect($keys, $_); + var_export($keys); echo PHP_EOL; + } + + $class = $event['class']; + $ref = $event['ref']; + $snapshot = $event['snapshot']; // Not present in the documentation. + + echo "$class - $ref\n"; + + $objects[$class][$ref] = $snapshot; + } + + isset($objects['pool']) + and $this->updateXenPools($objects['pool']); + isset($objects['host']) + and $this->updateXenHosts($objects['host']); + isset($objects['vm']) + and $this->updateXenVms($objects['vm']); + + // Requeue this request. + return true; + } + + /** + * + */ + function updateXenPools(array $pools) + { + foreach ($pools as $ref => $pool) + { + $this->_update($this->_xenPools[$ref], $pool); + } + } + + /** + * + */ + function updateXenHosts(array $hosts) + { + foreach ($hosts as $ref => $host) + { + $this->_update($this->_xenHosts[$ref], $host); + } + } + + /** + * + */ + function updateXenVms(array $vms) + { + foreach ($vms as $ref => $vm) + { + if ($vm['is_a_template']) + { + $_ = 'template'; + } + elseif ($vm['is_a_snapshot']) + { + $_ = 'snapshot'; + } + elseif ($vm['is_control_domain']) + { + $_ = 'control_domain'; + } + else + { + $_ = 'normal'; + } + + $this->_update( + $this->_xenVms[$_][$ref], + $vm + ); + } + } + + /** + * + */ + function run() + { + $this->_loadDatabase(); + + //-------------------------------------- + + $config = $this->_di->get('config'); + $loop = $this->_di->get('loop'); + + //-------------------------------------- + + // Creates master sockets. + foreach ($config->get('listen') as $uri) + { + $handle = self::_createServer($uri); + $loop->addRead($handle, array($this, 'handleServer')); + } + + //-------------------------------------- + + foreach ($config->get('xcp') as $_) + { + $xcp = new XCP($loop, $_['url'], $_['username'], $_['password']); + $xcp->queue( + 'VM.get_all_records', + null, + array($this, 'updateXenVms') + ); + $xcp->queue( + 'event.register', + array(array('host', 'pool', 'vm')) + ); + $xcp->queue( + 'event.next', + null, + array($this, 'handleXenEvents') + ); + } + + //-------------------------------------- + + $loop->run(array( + 'loop' => $loop, + 'server' => $this + )); + } + + /** + * + */ + private static function _createServer($uri) + { + list($transport, $target) = explode('://', $uri, 2); + + if (($transport === 'unix') + || ($transport === 'udg')) + { + @unlink($target); + } + + $handle = @stream_socket_server( + $uri, + /* out */ $errno, + /* out */ $errstr + ); + + if (!$handle) + { + trigger_error( + "could not create the server socket $uri: $errno - $errstr", + E_USER_ERROR + ); + } + + return $handle; + } + + /** + * Dependency injector. + * + * @var DI + */ + private $_di; + + /** + * Mapping from user identifier to record. + * + * Each record contains: + * - “name” (string): the user name used for sign in; + * - “password” (string): the user password hashed for sign in. + * + * @var array + */ + private $_users = array(); + + /** + * Mapping from user name to identifier. + * + * @var array + */ + private $_usersByName = array(); + + /** + * Tokens that may be used to authenticate clients. + * + * Each token record is an array containing: + * - “expiration” (integer): timestamp of when this token will be + * considered invalid; + * - “uid” (string): the identifier of the user authenticated with + * this token. + * + * @var array + */ + private $_tokens = array(); + + /** + * @var array + */ + private $_xenPools = array(); + + /** + * @var array + */ + private $_xenHosts = array(); + + /** + * @var array + */ + private $_xenVms = array(); + + /** + * + */ + private static function _tS($val) + { + if (is_scalar($val)) + { + return (string) $val; + } + return gettype($val); + } + + /** + * + */ + private static function _permissionFromString($string) + { + $permissions = array( + 'none' => self::NONE, + 'read' => self::READ, + 'write' => self::WRITE, + 'admin' => self::ADMIN + ); + + return isset($permissions[$string]) + ? $permissions[$string] + : false; + } + + /** + * + */ + private static function _permissionToString($permission) + { + $permissions = array( + self::NONE => 'none', + self::READ => 'read', + self::WRITE => 'write', + self::ADMIN => 'admin', + ); + + return isset($permissions[$permission]) + ? $permissions[$permission] + : false; + } + + /** + * + */ + private function _update(&$old, $new) + { + // There was no previous record. + if ($old === null) + { + echo "new record\n"; + $old = $new; + + return; + } + + // The record has been deleted. + if ($new === null) + { + echo "record deleted\n"; + $old = null; + + return; + } + + $old_keys = array_keys($old); + $new_keys = array_keys($new); + + foreach (array_diff($old_keys, $new_keys) as $key) + { + $_ = self::_tS($old[$key]); + echo "field removed: $key => $_\n"; + } + foreach (array_diff($new_keys, $old_keys) as $key) + { + $_ = self::_tS($new[$key]); + echo "field added: $key => $_\n"; + } + foreach (array_intersect($new_keys, $old_keys) as $key) + { + if ($new[$key] === $old[$key]) + { + continue; + } + + $_1 = self::_tS($old[$key]); + $_2 = self::_tS($new[$key]); + echo "field changed: $key => $_1 → $_2\n"; + } + $old = $new; + } + + /** + * + */ + private function _saveDatabase() + { + $data = json_encode(array( + 'users' => $this->_users, + 'usersByName' => $this->_usersByName, + 'tokens' => $this->_tokens, + )); + + $bytes = @file_put_contents( + $this->_di->get('config')->get('database.json'), + $data + ); + if ($bytes === false) + { + trigger_error( + 'could not write the database', + E_USER_ERROR + ); + } + } + + /** + * + */ + private function _loadDatabase() + { + $file = $this->_di->get('config')->get('database.json'); + if (!file_exists($file)) + { + trigger_error( + 'no such database, using default values (admin:admin)', + E_USER_WARNING + ); + + // @todo Factorizes this code with api_user_create(). + + $this->_users = array( + 1 => array( + 'name' => 'admin', + 'password' => '$2y$10$VzBQqiwnhG5zc2.MQmmW4ORcPW6FE7SLhPr1VBV2ubn5zJoesnmli', + 'permission' => self::ADMIN, + ), + ); + $this->_usersByName = array( + 'admin' => '1', + ); + + return; + } + + $data = @file_get_contents( + $this->_di->get('config')->get('database.json') + ); + if (($data === false) + || (($data = json_decode($data, true)) === null)) + { + trigger_error( + 'could not read the database', + E_USER_ERROR + ); + } + + foreach (array('users', 'usersByName', 'tokens') as $entry) + { + if (!isset($data[$entry])) + { + trigger_error( + "missing entry from the database: $entry", + E_USER_ERROR + ); + } + + $this->{'_'.$entry} = $data[$entry]; + } + } + + /** + * + */ + private function _checkPermission($uid, $permission, $object = null) + { + return ($this->_users[$uid]['permission'] >= $permission); + } +} diff --git a/src/lib/Base.php b/src/lib/Base.php new file mode 100644 index 0000000..7f30ba0 --- /dev/null +++ b/src/lib/Base.php @@ -0,0 +1,51 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * Ultimate base class. + */ +abstract class Base +{ + function __destruct() + {} + + function __get($name) + { + trigger_error( + 'no such readable property '.get_class($this).'->'.$name, + E_USER_ERROR + ); + } + + function __set($name, $value) + { + trigger_error( + 'no such writable property '.get_class($this).'->'.$name, + E_USER_ERROR + ); + } + + protected function __construct() + {} +} diff --git a/src/lib/BufferedWriter.php b/src/lib/BufferedWriter.php new file mode 100644 index 0000000..e91bf04 --- /dev/null +++ b/src/lib/BufferedWriter.php @@ -0,0 +1,65 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class BufferedWriter +{ + /** + * @param string $data + */ + function __construct($data, $len = null) + { + $this->_data = $data; + $this->_len = $len ?: strlen($data); + } + + /** + * + */ + function onWrite($handle) + { + $written = @fwrite( + $handle, + $this->_data, + $this->_len + ); + + if ($written === false) + { + // @todo Log error. + return false; + } + + $this->_len -= $written; + if (!$this->_len) + { + // Write complete, stops watching this handle. + return false; + } + + $this->_data = substr($this->_data, -$this->_len); + } +} diff --git a/src/lib/Client.php b/src/lib/Client.php new file mode 100644 index 0000000..f327512 --- /dev/null +++ b/src/lib/Client.php @@ -0,0 +1,286 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class Client extends Base +{ + /** + * + */ + public $uid = 0; + + /** + * @param Loop $loop + * @param resource $handle + * @param callable[] $methods + */ + function __construct(Loop $loop, $handle, $methods) + { + parent::__construct(); + + $this->_loop = $loop; + $this->_handle = $handle; + $this->_methods = $methods; + + stream_set_blocking($this->_handle, false); + $loop->addRead($this->_handle, array($this, '_onRead')); + } + + /** + * + */ + function isAuthenticated() + { + return ($this->uid !== 0); + } + + /** + * + */ + function notify($method, array $params) + { + $this->_send(array( + 'method' => $method, + 'params' => $params + )); + } + + /** + * + */ + function respond($id, $result) + { + if ($id === null) + { + trigger_error( + 'notifications do not expect responses', + E_USER_ERROR + ); + } + + $this->_send(array( + 'id' => $id, + 'result' => $result + )); + } + + //////////////////////////////////////// + // + //////////////////////////////////////// + + /** + * This function is called when data become available on the + * connection. + * + * Its name starts with a “_” because it is only supposed to be + * used internally (it is not private because of PHP limits). + */ + function _onRead() + { + // Read the message length. + if (!$this->_len) + { + $len = stream_get_line($this->_handle, 1024, "\n"); + if ($len === false) + { + if (feof($this->_handle)) + { + echo "client disconnected\n"; + + // Closes this socket and stops listening to it. + stream_socket_shutdown($this->_handle, STREAM_SHUT_RDWR); + fclose($this->_handle); + return false; + } + + $this->_error('failed to read the request length'); + } + if (!ctype_digit($len) + || ($len <= 0)) + { + $this->_error('invalid request length'); + } + + $this->_len = (int) $len; + } + + $buf = @fread($this->_handle, $this->_len); + if ($buf === false) + { + $this->_error('failed to read the request'); + } + $this->_buf .= $buf; + $this->_len -= strlen($buf); + + if ($this->_len !== 0) + { + return; + } + + $request = @json_decode($this->_buf, true); + $this->_buf = ''; + + if ($request === null) + { + $this->_warning(null, -32700, 'Parse error'); + return; + } + + if (!isset($request['jsonrpc'], $request['method']) + || ($request['jsonrpc'] !== '2.0')) + { + $this->_warning(null, -32600, 'Invalid request'); + return; + } + + $id = isset($request['id']) ? $request['id'] : null; + $method = $request['method']; + $params = isset($request['params']) ? $request['params'] : array(); + + if (isset($this->_methods[$method])) + { + $_ = $this->_methods[$method]; + $error = $_($id, $params, $this); + } + else + { + $error = array(-32601, 'Method not found', $method); + } + + if (($error === null) + || ($id === null)) + { + // No errors or this was a notification (no error handling). + return; + } + + $errors = array( + -32602 => array( + 'Invalid params', + $params + ), + ); + if (is_numeric($error) && isset($errors[$error])) + { + $error = $errors[$error]; + } + elseif (!is_array($error) || !isset($error[0], $error[1])) + { + trigger_error( + 'invalid error', + E_USER_ERROR + ); + } + $this->_warning( + $id, + $error[0], + $error[1], + isset($error[2]) ? $error[2] : null + ); + } + + /** + * @var Loop + */ + private $_loop; + + /** + * @var resource + */ + private $_handle; + + /** + * @var callable[] + */ + private $_methods; + + /** + * @var integer + */ + private $_len; + + /** + * @var string|null + */ + private $_buf; + + /** + * + */ + private function _error($message) + { + throw new Exception($message); + } + + /** + * @param array $message + */ + private function _send($message) + { + $message['jsonrpc'] = '2.0'; + $message = json_encode($message); + + $data = strlen($message)."\n".$message; + $len = strlen($data); + + $written = @fwrite($this->_handle, $data, $len); + if ($written === false) + { + $this->_error('failed to send the message'); + } + + $len -= $written; + if ($len) + { + echo "$len bytes to write, using a buffered writer\n"; + $this->_loop->addWrite( + $this->_handle, + array( + new BufferedWriter(substr($data, -$len), $len), + 'onWrite' + ) + ); + } + } + + /** + * + */ + private function _warning($id, $code, $message, $data = null) + { + $message = array( + 'id' => $id, + 'error' => array( + 'code' => $code, + 'message' => $message + ) + ); + ($data !== null) + and $message['error']['data'] = $data; + + $this->_send($message); + } +} diff --git a/src/lib/Config.php b/src/lib/Config.php new file mode 100644 index 0000000..cafe89c --- /dev/null +++ b/src/lib/Config.php @@ -0,0 +1,159 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class Config extends Base +{ + /** + * + */ + function __construct(array $entries = null) + { + parent::__construct(); + + $this->_entries = isset($entries) ? $entries : array(); + } + + /** + * Returns an entry. + * + * @param string $path + * @param mixed $default Optional. + * + * @return array + * + * @throws Exception If there is no such entry and no default value as been + * specified. + */ + function get($path, $default = 'throws an exception') + { + $entry = $this->_entries; + + $parts = explode('.', $path); + foreach ($parts as $part) + { + /* + * Nothing found. + */ + if (!isset($entry[$part]) + && !array_key_exists($part, $entry)) + { + if (func_num_args() < 2) + { + throw new Exception('no such entry ('.$path.')'); + } + + $entry = $default; + break; + } + + $entry = $entry[$part]; + } + + return $this->_resolve($entry); + } + + /** + * + * + * @param string $path + * @param array|string $value + */ + function set($path, $value) + { + $entry = &$this->_entries; + + $parts = explode('.', $path); + + $i = 0; + $n = count($parts); + while ( + ($i < $n) + && ( + isset($entry[$part = $parts[$i]]) + || ( + is_array($entry) + && array_key_exists($part, $entry) + ) + ) + ) + { + $entry = &$entry[$part]; + ++$i; + } + + while ($i < $n) + { + if (!is_array($entry)) + { + $entry = array(); + } + + $entry = &$entry[$parts[$i]]; + ++$i; + } + + $entry = $value; + } + + /** + * @var array + */ + private $_entries; + + /** + * + */ + private function _replaceCallback(array $match) + { + return $this->get($match[1]); + } + + /** + * + */ + private function _resolve($entry) + { + if (is_string($entry)) + { + return preg_replace_callback( + '/#\{([-a-zA-Z0-9_.]+)\}/', + array($this, '_replaceCallback'), + $entry + ); + } + + if (is_array($entry)) + { + foreach ($entry as &$item) + { + $item = $this->_resolve($item); + } + } + + return $entry; + } +} diff --git a/src/lib/Connection.php b/src/lib/Connection.php new file mode 100644 index 0000000..a0e1ad6 --- /dev/null +++ b/src/lib/Connection.php @@ -0,0 +1,83 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +abstract class Connection extends Base +{ + /** + * + */ + function __construct($handle, Loop $loop) + { + parent::__construct(); + + $this->_handle = $handle; + $this->_loop = $loop; + } + + /** + * + */ + function __destruct() + { + $this->close(); + + parent::__destruct(); + } + + /** + * + */ + final function close() + { + $this->_loop->removeRead($this->_handle); + $this->_loop->removeWrite($this->_handle); + + $this->handleClose(); + + if (is_resource($this->_handle)) + { + stream_socket_shutdown($this->_handle, STREAM_SHUT_RDWR); + fclose($this->_handle); + } + } + + /** + * + */ + function handleClose() + {} + + /** + * @var resource + */ + private $_handle; + + /** + * @var Loop + */ + private $_loop; +} diff --git a/src/lib/DI.php b/src/lib/DI.php new file mode 100644 index 0000000..55adff4 --- /dev/null +++ b/src/lib/DI.php @@ -0,0 +1,112 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * Dependency injector. + */ +final class DI extends Base +{ + function __construct() + { + parent::__construct(); + } + + function get($id) + { + if (isset($this->_entries[$id]) + || array_key_exists($id, $this->_entries)) + { + return $this->_entries[$id]; + } + + $tmp = str_replace(array('_', '.'), array('', '_'), $id); + + if (method_exists($this, '_get_'.$tmp)) + { + return $this->{'_get_'.$tmp}(); + } + + if (method_exists($this, '_init_'.$tmp)) + { + $value = $this->{'_init_'.$tmp}(); + $this->set($id, $value); + return $value; + } + + throw new Exception('no such entry: '.$id); + } + + function set($id, $value) + { + $this->_entries[$id] = $value; + } + + private $_entries = array(); + + //////////////////////////////////////// + + private function _init_application() + { + return new Application($this); + } + + private function _init_errorLogger() + { + return new ErrorLogger($this->get('logger')); + } + + private function _init_logger() + { + $logger = new \Monolog\Logger('main'); + + $config = $this->get('config'); + if ($email = $config->get('log.email', false)) + { + $logger->pushHandler( + new \Monolog\Handler\FingersCrossedHandler( + new \Monolog\Handler\NativeMailerHandler( + $email, + '[XO Server]', + 'no-reply@vates.fr', + \Monolog\Logger::DEBUG + ), + \Monolog\Logger::WARNING + ) + ); + } + if ($file = $config->get('log.file', false)) + { + $logger->pushHandler( + new \Monolog\Handler\StreamHandler($file) + ); + } + + return $logger; + } + + private function _init_loop() + { + return new Loop; + } +} diff --git a/src/lib/ErrorLogger.php b/src/lib/ErrorLogger.php new file mode 100644 index 0000000..f091490 --- /dev/null +++ b/src/lib/ErrorLogger.php @@ -0,0 +1,110 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +use \Monolog\Logger; + +/** + * + */ +final class ErrorLogger extends Base +{ + /** + * + */ + function __construct(Logger $logger) + { + parent::__construct(); + + $this->_logger = $logger; + } + + /** + * Handles fatal errors on shutdown. + */ + function handleShutdown() + { + $e = error_get_Last(); + if ((($e['type'] === E_ERROR) || ($e['type'] === E_USER_ERROR)) + && ($e !== $this->_last)) + { + $this->log($e['type'], $e['message'], $e['file'], $e['line']); + } + } + + /** + * + */ + function log($no, $str, $file, $line) + { + static $map = array( + E_NOTICE => Logger::NOTICE, + E_USER_NOTICE => Logger::NOTICE, + E_WARNING => Logger::WARNING, + E_CORE_WARNING => Logger::WARNING, + E_USER_WARNING => Logger::WARNING, + E_ERROR => Logger::ERROR, + E_USER_ERROR => Logger::ERROR, + E_CORE_ERROR => Logger::ERROR, + E_RECOVERABLE_ERROR => Logger::ERROR, + E_STRICT => Logger::DEBUG, + ); + + // Used to prevents the last error from being logged twice. + $this->_last = array( + 'type' => $no, + 'message' => $str, + 'file' => $file, + 'line' => $line + ); + + $priority = isset($map[$no]) + ? $map[$no] + : Logger::WARNING + ; + + // Appends the location if necessary. + if (!preg_match('/(?:at|in) [^ ]+:[0-9]+$/', $str)) + { + $str .= " in $file:$line"; + } + + $this->_logger->addRecord($priority, $str, array( + 'no' => $no, + 'file' => $file, + 'line' => $line, + )); + + return false; + } + + /** + * + */ + private $_last; + + /** + * @var Logger + */ + private $_logger; +} diff --git a/src/lib/Loop.php b/src/lib/Loop.php new file mode 100644 index 0000000..e2d5fca --- /dev/null +++ b/src/lib/Loop.php @@ -0,0 +1,176 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class Loop extends Base +{ + function __construct() + { + parent::__construct(); + } + + /** + * @param resource $handle + * @param callable $callback + */ + function addRead($handle, $callback) + { + $id = (int) $handle; + + $this->_readHandles[$id] = $handle; + $this->_readCallbacks[$id] = $callback; + } + + /** + * + */ + function removeRead($handle) + { + $id = (int) $handle; + + unset( + $this->_readHandles[$id], + $this->_readCallbacks[$id] + ); + } + + /** + * @param resource $handle + * @param callable $callback + */ + function addWrite($handle, $callback) + { + $id = (int) $handle; + + $this->_writeHandles[$id] = $handle; + $this->_writeCallbacks[$id] = $callback; + } + + /** + * + */ + function removeWrite($handle) + { + $id = (int) $handle; + + unset( + $this->_writeHandles[$id], + $this->_writeCallbacks[$id] + ); + } + + /** + * + */ + function remove($handle) + { + $id = (int) $handle; + + unset( + $this->_readHandles[$id], + $this->_readCallbacks[$id], + $this->_writeHandles[$id], + $this->_writeCallbacks[$id] + ); + } + + /** + * @param mixed $user_data + */ + function run($user_data = null) + { + $this->_running = true; + + do + { + $read = $this->_readHandles; + $write = $this->_writeHandles; + $except = null; + if (@stream_select($read, $write, $except, null) === false) + { + trigger_error( + 'error while waiting for activity', + E_USER_ERROR + ); + } + + foreach ($read as $handle) + { + $callback = $this->_readCallbacks[(int) $handle]; + $result = $callback($handle, $user_data); + + if (!is_resource($handle)) + { + $this->remove($handle); + } + elseif ($result === false) + { + $this->removeRead($handle); + } + } + foreach ($write as $handle) + { + $callback = $this->_writeCallbacks[(int) $handle]; + $result = $callback($handle, $user_data); + + if (!is_resource($handle)) + { + $this->remove($handle); + } + elseif ($result === false) + { + $this->removeWrite($handle); + } + } + } while ($this->_running + && ($this->_readHandles || $this->_writeHandles)); + } + + /** + * @var boolean + */ + private $_running; + + /** + * @var resource[] + */ + private $_readHandles = array(); + + /** + * @var callable[] + */ + private $_readCallbacks = array(); + + /** + * @var resource[] + */ + private $_writeHandles = array(); + + /** + * @var callable[] + */ + private $_writeCallbacks = array(); +} diff --git a/src/lib/XCP.php b/src/lib/XCP.php new file mode 100644 index 0000000..ff5763d --- /dev/null +++ b/src/lib/XCP.php @@ -0,0 +1,198 @@ +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * + */ +final class XCP extends Base +{ + /** + * + */ + function __construct(Loop $loop, $url, $user, $password) + { + parent::__construct(); + + $this->_loop = $loop; + $this->_url = $url; + + $this->_queue[] = array( + array($this, '_setToken'), + 'session.login_with_password', + array($user, $password) + ); + $this->_next(); + } + + /** + * + */ + function queue($method, array $params = null, $callback = null) + { + ($params !== null) + or $params = array(); + + $this->_queue[] = array($callback, $method, $params); + + if (count($this->_queue) === 1) + { + $this->_next(); + } + } + + /** + * + */ + function onData($handle) + { + $this->_buf .= stream_get_contents($handle); + + if (!feof($handle)) + { + return; + } + + // Reads the response. + list(, $response) = explode("\r\n\r\n", $this->_buf, 2); + $this->_buf = ''; + $response = xmlrpc_decode($response); + if (!isset($response['Value'])) + { + var_export($response); + trigger_error( + 'Invalid response', + E_USER_ERROR + ); + } + $response = $response['Value']; + + // Notifies. + $request = array_shift($this->_queue); + if ($request[0] !== null) + { + $callback = $request[0]; + $result = $callback($response, $this); + if ($result === true) + { + $this->_queue[] = $request; + } + } + + // Sends the next request if any. + if ($this->_queue) + { + $this->_next(); + } + + // Stops reading this socket. + return false; + } + + /** + * + */ + private function _next() + { + $_ = parse_url($this->_url); + $https = isset($_['scheme']) && !strcasecmp($_['scheme'], 'https'); + $port = isset($_['port']) ? $_['port'] : ($https ? 443 : 80); + $host = isset($_['host']) ? $_['host'] : '127.0.0.1'; + $query = isset($_['query']) ? '?'.$_['query'] : ''; + $path = isset($_['path']) ? $_['path'] : '/'; + + list(, $method, $params) = reset($this->_queue); + isset($this->_tok) + and array_unshift($params, $this->_tok); + + $data = xmlrpc_encode_request($method, $params, array( + 'verbosity' => 'no_white_space', + 'encoding' => 'UTF-8', + )); + + $request = implode("\r\n", array( + "POST $path HTTP/1.1", + "Host: $host:$port", + 'Connection: close', + 'Content-Type: text/xml; charset=UTF-8', + 'Content-Length: '.strlen($data), + '', + $data + )); + + $hdl = @stream_socket_client( + ($https ? 'tls' : 'tcp')."://$host:$port", + /* out */ $errno, + /* out */ $errstr, + ini_get('default_socket_timeout'), // Default value. + STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT + ); + if (!$hdl) + { + trigger_error( + "cannot connect to {$this->_url}: $errno - $errstr", + E_USER_ERROR + ); + } + + stream_set_blocking($hdl, false); + fwrite($hdl, $request); + + $this->_loop->addRead($hdl, array($this, 'onData')); + } + + /** + * + */ + private function _setToken($token) + { + $this->_tok = $token; + } + + /** + * @var string + */ + private $_buf = ''; + + /** + * @var Loop + */ + private $_loop; + + /** + * @var string + */ + private $_url; + + /** + * Session token. + * + * @var string + */ + private $_tok; + + /** + * @var array + */ + private $_queue = array(); +} diff --git a/src/xo-server b/src/xo-server new file mode 100755 index 0000000..de2f341 --- /dev/null +++ b/src/xo-server @@ -0,0 +1,73 @@ +#!/usr/bin/php +. + * + * @author Julien Fontanet + * @license http://www.gnu.org/licenses/gpl-3.0-standalone.html GPLv3 + * + * @package Xen Orchestra Server + */ + +/** + * Bootstraps and returns the application singleton. + */ +function _bootstrap() +{ + static $application; + + if (!isset($application)) + { + // Variables definition. + $root_dir = defined('__DIR__') + ? __DIR__ + : dirname(__FILE__) + ; + + // Class autoloading is done by composer. + require($root_dir.'/../vendor/autoload.php'); + + // Reads configuration. + $config = new Config(array_merge_recursive( + require($root_dir.'/config/global.php'), + require($root_dir.'/config/local.php') + )); + + // Injects some variables. + $config->set('root_dir', $root_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; +} + +_bootstrap()->run(); + +// Local Variables: +// mode: php +// End: