diff --git a/.gitignore b/.gitignore index d38af3e..64e2bcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ vendor/ -.project/ -.vagrant/ .idea/ +config.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4b0342 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM alpine:3.6 +MAINTAINER Spring Signage Ltd + +ENV XMR_DEBUG false +ENV XMR_QUEUE_POLL 5 +ENV XMR_QUEUE_SIZE 10 +ENV XMR_IPV6RESPSUPPORT false +ENV XMR_IPV6PUBSUPPORT false + +RUN apk update && apk upgrade && apk add tar php7 curl php7-zmq php7-phar php7-json php7-openssl && rm -rf /var/cache/apk/* + +EXPOSE 9505 50001 + +COPY ./entrypoint.sh /entrypoint.sh +COPY . /opt/xmr + +RUN chown -R nobody /opt/xmr && chmod 755 /entrypoint.sh + +# Start XMR +USER nobody + +CMD ["/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index e3716aa..bd23335 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,77 @@ # Introduction Xibo - Digital Signage - http://www.xibo.org.uk -Copyright (C) 2006-2015 Daniel Garner and Contributors. +Copyright (C) 2006-2018 Spring Signage Ltd and Contributors. This is the Xibo Message Relay (XMR) repository. -XMR is a php application built on ReactPHP which acts as a ZeroMQ message exchange between the Xibo CMS and connected -Xibo Players. It doesn't do anything beyond forward messages from the CMS to a pub/sub socket. +XMR is a php application built on ReactPHP which acts as a ZeroMQ message exchange between the Xibo CMS and connected Xibo Players. It doesn't do anything beyond forward messages from the CMS to a pub/sub socket. + +It is packaged into a PHAR file which is included in the [Xibo CMS](https://github.com/xibosignage/xibo-cms) release files. + +**If you are here for anything other than software development purposes, it is unlikely you are in the right place. XMR is shipped with the Xibo CMS installation and you would usually install it from there.** + -It is packaged into a PHAR file which is included in the [Xibo CMS](https://github.com/xibosignage/xibo-cms) release -files. ## Installation -The install, use composer: +XMR can be run using Docker and Compose, for example: + +```yaml +version: "3" +services: + xmr: + image: xibosignage/xibo-xmr:latest + ports: + - "9505:9505" + - "50001:50001" ``` + + + +You may also build this library from source code: + +1. Clone this repository +2. Run `./build.sh` +3. Run `docker-compose up --build` + + + +You may also reference this code in your own projects via Composer: + +```bash composer require xibosignage/xibo-xmr ``` + + +### Ports + +XMR requires a listen address and a publish address and therefore needs 2 ports. The listen address is used for communication with the CMS (incoming comms) and the publish address is used for outgoing messages. + +When running in Docker, you will want to expose these ports to your machine OR connect your container to a Docker network which will facilitate communication with these ports. + +An example ports directive would be: + +``` yaml +ports: + - "9505:9505" #Publish + - "50001:50001" #Listen +``` + + + + + ## Licence -Xibo is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -any later version. - -Xibo 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 Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with Xibo. If not, see . \ No newline at end of file + +Xibo is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. + +Xibo 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License along with Xibo. If not, see . + + + +#### 3rd Party + +We use BOX to package the PHAR file - see https://github.com/box-project/box2 \ No newline at end of file diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index 25ddba3..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,19 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! -VAGRANTFILE_API_VERSION = "2" - -$ip = < /usr/local/etc/php/php.ini; curl -LSs https://box-project.github.io/box2/installer.php | php; php box.phar build; rm box.phar" + +#docker rmi composer +#docker rmi php:7.0-cli \ No newline at end of file diff --git a/composer.lock b/composer.lock index 91a3e07..51bb541 100644 --- a/composer.lock +++ b/composer.lock @@ -9,21 +9,24 @@ "packages": [ { "name": "evenement/evenement", - "version": "v2.0.0", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/igorw/evenement.git", - "reference": "f6e843799fd4f4184d54d8fc7b5b3551c9fa803e" + "reference": "6ba9a777870ab49f417e703229d53931ed40fd7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/f6e843799fd4f4184d54d8fc7b5b3551c9fa803e", - "reference": "f6e843799fd4f4184d54d8fc7b5b3551c9fa803e", + "url": "https://api.github.com/repos/igorw/evenement/zipball/6ba9a777870ab49f417e703229d53931ed40fd7a", + "reference": "6ba9a777870ab49f417e703229d53931ed40fd7a", "shasum": "" }, "require": { "php": ">=5.4.0" }, + "require-dev": { + "phpunit/phpunit": "^6.0||^5.7||^4.8.35" + }, "type": "library", "extra": { "branch-alias": { @@ -42,8 +45,7 @@ "authors": [ { "name": "Igor Wiedler", - "email": "igor@wiedler.ch", - "homepage": "http://wiedler.ch/igor/" + "email": "igor@wiedler.ch" } ], "description": "Événement is a very simple event dispatching library for PHP", @@ -51,20 +53,20 @@ "event-dispatcher", "event-emitter" ], - "time": "2012-11-02 14:49:47" + "time": "2017-07-17 17:39:19" }, { "name": "monolog/monolog", - "version": "1.19.0", + "version": "1.23.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "5f56ed5212dc509c8dc8caeba2715732abb32dbf" + "reference": "fd8c787753b3a2ad11bc60c063cff1358a32a3b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5f56ed5212dc509c8dc8caeba2715732abb32dbf", - "reference": "5f56ed5212dc509c8dc8caeba2715732abb32dbf", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd8c787753b3a2ad11bc60c063cff1358a32a3b4", + "reference": "fd8c787753b3a2ad11bc60c063cff1358a32a3b4", "shasum": "" }, "require": { @@ -75,7 +77,7 @@ "psr/log-implementation": "1.0.0" }, "require-dev": { - "aws/aws-sdk-php": "^2.4.9", + "aws/aws-sdk-php": "^2.4.9 || ^3.0", "doctrine/couchdb": "~1.0@dev", "graylog2/gelf-php": "~1.0", "jakub-onderka/php-parallel-lint": "0.9", @@ -83,9 +85,9 @@ "php-console/php-console": "^3.1.3", "phpunit/phpunit": "~4.5", "phpunit/phpunit-mock-objects": "2.3.0", - "raven/raven": "^0.13", "ruflin/elastica": ">=0.90 <3.0", - "swiftmailer/swiftmailer": "~5.3" + "sentry/sentry": "^0.13", + "swiftmailer/swiftmailer": "^5.3|^6.0" }, "suggest": { "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", @@ -96,9 +98,9 @@ "mongodb/mongodb": "Allow sending log messages to a MongoDB server via PHP Driver", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", "php-console/php-console": "Allow sending log messages to Google Chrome", - "raven/raven": "Allow sending log messages to a Sentry server", "rollbar/rollbar": "Allow sending log messages to Rollbar", - "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + "ruflin/elastica": "Allow sending log messages to an Elastic Search server", + "sentry/sentry": "Allow sending log messages to a Sentry server" }, "type": "library", "extra": { @@ -129,26 +131,34 @@ "logging", "psr-3" ], - "time": "2016-04-12 18:29:35" + "time": "2017-06-19 01:22:40" }, { "name": "psr/log", - "version": "1.0.0", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b" + "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b", - "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b", + "url": "https://api.github.com/repos/php-fig/log/zipball/4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", + "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", "shasum": "" }, + "require": { + "php": ">=5.3.0" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { - "psr-0": { - "Psr\\Log\\": "" + "psr-4": { + "Psr\\Log\\": "Psr/Log/" } }, "notification-url": "https://packagist.org/downloads/", @@ -162,41 +172,40 @@ } ], "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", "keywords": [ "log", "psr", "psr-3" ], - "time": "2012-12-21 11:40:51" + "time": "2016-10-10 12:19:37" }, { "name": "react/event-loop", - "version": "v0.4.2", + "version": "v0.4.3", "source": { "type": "git", "url": "https://github.com/reactphp/event-loop.git", - "reference": "164799f73175e1c80bba92a220ea35df6ca371dd" + "reference": "8bde03488ee897dc6bb3d91e4e17c353f9c5252f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/164799f73175e1c80bba92a220ea35df6ca371dd", - "reference": "164799f73175e1c80bba92a220ea35df6ca371dd", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/8bde03488ee897dc6bb3d91e4e17c353f9c5252f", + "reference": "8bde03488ee897dc6bb3d91e4e17c353f9c5252f", "shasum": "" }, "require": { "php": ">=5.4.0" }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, "suggest": { "ext-event": "~1.0", "ext-libev": "*", "ext-libevent": ">=0.1.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.5-dev" - } - }, "autoload": { "psr-4": { "React\\EventLoop\\": "src" @@ -211,7 +220,7 @@ "asynchronous", "event-loop" ], - "time": "2016-03-08 02:09:32" + "time": "2017-04-27 10:56:23" }, { "name": "react/zmq", diff --git a/config.json b/config.json deleted file mode 100644 index deca50a..0000000 --- a/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "listenOn": "tcp://localhost:50001", - "pubOn": ["tcp://localhost:9505", "tcp://localhost:50002"], - "debug": true -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..95a690f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3" + +services: + xmr: + build: . + environment: + XMR_DEBUG: "true" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..b306494 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# Get our running IP address +ip=$(ip -f inet -o addr show eth0|cut -d\ -f 7 | cut -d/ -f 1) + +# Write config.json +echo '{' > /opt/xmr/config.json +echo ' "listenOn": "tcp://'$ip':50001",' >> /opt/xmr/config.json +echo ' "pubOn": ["tcp://'$ip':9505"],' >> /opt/xmr/config.json +echo ' "queuePoll": '$XMR_QUEUE_POLL',' >> /opt/xmr/config.json +echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json +echo ' "debug": '$XMR_DEBUG',' >> /opt/xmr/config.json +echo ' "ipv6RespSupport": '$XMR_IPV6RESPSUPPORT',' >> /opt/xmr/config.json +echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT >> /opt/xmr/config.json +echo '}' >> /opt/xmr/config.json + +/usr/bin/php7 /opt/xmr/index.php \ No newline at end of file diff --git a/index.php b/index.php index d78d83f..676975c 100644 --- a/index.php +++ b/index.php @@ -2,8 +2,8 @@ > CMS: Register @@ -24,52 +24,75 @@ function exception_error_handler($severity, $message, $file, $line) { } set_error_handler("exception_error_handler"); -$config = 'config.json'; - -if (!file_exists('config.json')) - $config = (Phar::running(false) == '') ? __DIR__ : dirname(Phar::running(false)) . '/config.json'; +// Decide where to look for the config file +$dirname = (Phar::running(false) == '') ? __DIR__ : dirname(Phar::running(false)); +$config = $dirname . '/config.json'; if (!file_exists($config)) - throw new InvalidArgumentException('Missing ' . $config . ' file, please create one in the same folder as the application'); + throw new InvalidArgumentException('Missing ' . $config . ' file, please create one in ' . $dirname); -chdir(dirname($config)); +$configString = file_get_contents($config); +$config = json_decode($configString); -$config = json_decode(file_get_contents($config)); +if ($config === null) + throw new InvalidArgumentException('Cannot decode config file ' . json_last_error_msg() . ' config string is [' . $configString . ']'); if ($config->debug) $logLevel = \Monolog\Logger::DEBUG; else $logLevel = \Monolog\Logger::WARNING; +// Queue settings +$queuePoll = (property_exists($config, 'queuePoll')) ? $config->queuePoll : 5; +$queueSize = (property_exists($config, 'queueSize')) ? $config->queueSize : 10; + // Set up logging to file $log = new \Monolog\Logger('xmr'); -$log->pushHandler(new \Monolog\Handler\StreamHandler('log.txt', $logLevel)); $log->pushHandler(new \Monolog\Handler\StreamHandler(STDOUT, $logLevel)); $log->info(sprintf('Starting up - listening for CMS on %s.', $config->listenOn)); try { - $loop = React\EventLoop\Factory::create(); + $loop = \React\EventLoop\Factory::create(); + /** + * ZMQ context wraps the PHP implementation. + * @var \ZMQContext $context + */ $context = new React\ZMQ\Context($loop); // Reply socket for requests from CMS $responder = $context->getSocket(ZMQ::SOCKET_REP); $responder->bind($config->listenOn); + // Set RESP socket options + if (isset($config->ipv6RespSupport) && $config->ipv6RespSupport === true) { + $log->debug('RESP MQ Setting socket option for IPv6 to TRUE'); + $responder->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); + } + // Pub socket for messages to Players (subs) $publisher = $context->getSocket(ZMQ::SOCKET_PUB); + // Set PUB socket options + if (isset($config->ipv6PubSupport) && $config->ipv6PubSupport === true) { + $log->debug('Pub MQ Setting socket option for IPv6 to TRUE'); + $publisher->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); + } + foreach ($config->pubOn as $pubOn) { $log->info(sprintf('Bind to %s for Publish.', $pubOn)); $publisher->bind($pubOn); } + // Create an in memory message queue. + $messageQueue = []; + // REP $responder->on('error', function ($e) use ($log) { $log->error($e->getMessage()); }); - $responder->on('message', function ($msg) use ($log, $responder, $publisher) { + $responder->on('message', function ($msg) use ($log, $responder, $publisher, &$messageQueue) { try { // Log incoming message @@ -90,9 +113,22 @@ function exception_error_handler($severity, $message, $file, $line) { // Respond to this message $responder->send(true); - // Push message out to subscribers - $publisher->sendmulti([$msg->channel, $msg->key, $msg->message]); - //$publisher->send('cms ' . $msg); + // Make sure QOS is set + if (!isset($msg->qos)) { + // Default to highest priority for messages missing a QOS + $msg->qos = 10; + } + + // Decide whether we should queue the message or send it immediately. + if ($msg->qos != 10) { + // Queue for the periodic poll to send + $log->debug('Queuing'); + $messageQueue[] = $msg; + } else { + // Send Immediately + $log->debug('Sending Immediately'); + $publisher->sendmulti([$msg->channel, $msg->key, $msg->message]); + } } catch (InvalidArgumentException $e) { // Return false @@ -102,6 +138,30 @@ function exception_error_handler($severity, $message, $file, $line) { } }); + // Queue Processor + $log->debug('Adding a queue processor for every ' . $queuePoll . ' seconds'); + $loop->addPeriodicTimer($queuePoll, function() use ($log, $publisher, &$messageQueue, $queueSize) { + // Is there work to be done + if (count($messageQueue) > 0) { + $log->debug('Queue Poll - work to be done.'); + // Order the message queue according to QOS + usort($messageQueue, function($a, $b) { + return ($a->qos === $b->qos) ? 0 : ($a->qos < $b->qos) ? -1 : 1; + }); + + // Send up to X messages. + for ($i = 0; $i < $queueSize; $i++) { + // Pop an element + $msg = array_pop($messageQueue); + + // Send + $publisher->sendmulti([$msg->channel, $msg->key, $msg->message]); + + $log->debug('Popped ' . $i . ' from the queue, new queue size ' . count($messageQueue)); + } + } + }); + // Periodic updater $loop->addPeriodicTimer(30, function() use ($log, $publisher) { $log->debug('Heartbeat...'); @@ -115,3 +175,5 @@ function exception_error_handler($severity, $message, $file, $line) { $log->error($e->getMessage()); $log->error($e->getTraceAsString()); } + +// This ends - causing Docker to restart if we're in a container. \ No newline at end of file diff --git a/tests/cmsSend.php b/tests/cmsSend.php index e031c45..4974b9d 100644 --- a/tests/cmsSend.php +++ b/tests/cmsSend.php @@ -1,20 +1,104 @@ setIdentity('player1', $publicKey)->send($config->listenOn); -echo 'Reply received:' . $reply . PHP_EOL; +try { + + // Queue up a bunch of messages to see what happens + for ($i = 0; $i < 1; $i++) { + + // Reference params + $message = null; + $eKeys = null; + + // Encrypt a message + openssl_seal($i . ' - QOS1', $message, $eKeys, [$publicKey]); + + // Create a message and send. + send($config->listenOn, [ + 'channel' => $identity, + 'key' => base64_encode($eKeys[0]), + 'message' => base64_encode($message), + 'qos' => 10 + ]); + + usleep(100); + } + +} catch (Exception $e) { + echo $e->getMessage() . PHP_EOL; +} + +openssl_free_key($publicKey); + +/** + * @param $connection + * @param $message + * @return bool|string + * @throws ZMQSocketException + */ +function send($connection, $message) +{ + echo 'Sending to ' . $connection . PHP_EOL; + + // Issue a message payload to XMR. + $context = new \ZMQContext(); + + // Connect to socket + $socket = new \ZMQSocket($context, \ZMQ::SOCKET_REQ); + $socket->connect($connection); + + // Send the message to the socket + $socket->send(json_encode($message)); + + // Need to replace this with a non-blocking recv() with a retry loop + $retries = 15; + $reply = false; + + do { + try { + // Try and receive + // if ZMQ::MODE_NOBLOCK/MODE_DONTWAIT is used and the operation would block boolean false + // shall be returned. + $reply = $socket->recv(\ZMQ::MODE_DONTWAIT); + + echo 'Received ' . var_export($reply, true) . PHP_EOL; + + if ($reply !== false) + break; + + } catch (\ZMQSocketException $sockEx) { + if ($sockEx->getCode() !== \ZMQ::ERR_EAGAIN) + throw $sockEx; + } + + usleep(100000); + + } while (--$retries); + + // Disconnect socket + //$socket->disconnect($connection); -$reply = (new \Xibo\XMR\CollectNowAction())->setIdentity('unknown', $publicKey)->send($config->listenOn); -echo 'Reply received:' . $reply . PHP_EOL; + return $reply; +} \ No newline at end of file diff --git a/tests/playerSub.php b/tests/playerSub.php index 040a1fe..8fe426a 100644 --- a/tests/playerSub.php +++ b/tests/playerSub.php @@ -1,8 +1,12 @@ pubOn[0] . PHP_EOL; - $fp = fopen('key.pem', 'r'); $privateKey = openssl_get_privatekey(fread($fp, 8192)); fclose($fp); @@ -31,7 +34,7 @@ $sub->on('messages', function ($msg) use ($identity, $privateKey) { try { - echo 'Received: ' . json_encode($msg) . PHP_EOL; + echo '[' . date('Y-m-d H:i:s') . '] Received: ' . json_encode($msg) . PHP_EOL; if ($msg[0] == "H") return; @@ -51,12 +54,12 @@ $message = base64_decode($msg[2]); if (!openssl_open($message, $opened, $key, $privateKey)) - throw new \Xibo\XMR\PlayerActionException('Encryption Error'); + throw new Exception('Encryption Error'); - echo 'Message: ' . $opened; + echo 'Message: ' . $opened . PHP_EOL; } catch (InvalidArgumentException $e) { - echo $e->getMessage(); + echo $e->getMessage() . PHP_EOL; } });