diff --git a/.travis.yml b/.travis.yml index c65dedb..f8f7b7c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: php +services: docker + php: - 5.6 - 7.0 @@ -7,11 +9,18 @@ php: - 7.2 - 7.3 - 7.4 + - nightly + +env: + - ABTEST=client + - ABTEST=server + +matrix: + allow_failures: + - php: nightly before_install: - - export PATH=$HOME/.local/bin:$PATH - - pip install --user autobahntestsuite - - pip list --user autobahntestsuite + - docker pull crossbario/autobahn-testsuite before_script: - composer install diff --git a/README.md b/README.md index 0f17c14..fba5093 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RFC6455 - The WebSocket Protocol [![Build Status](https://travis-ci.org/ratchetphp/RFC6455.svg?branch=master)](https://travis-ci.org/ratchetphp/RFC6455) -![Autobahn Testsuite](https://img.shields.io/badge/Autobahn-passing-brightgreen.svg) +[![Autobahn Testsuite](https://img.shields.io/badge/Autobahn-passing-brightgreen.svg)](http://socketo.me/reports/rfc-server/index.html) This library a protocol handler for the RFC6455 specification. It contains components for both server and client side handshake and messaging protocol negotation. diff --git a/composer.json b/composer.json index b758876..0416b58 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,20 @@ "keywords": ["WebSockets", "websocket", "RFC6455"], "homepage": "http://socketo.me", "license": "MIT", - "authors": [{ - "name": "Chris Boden" - , "email": "cboden@gmail.com" - , "role": "Developer" - }], + "authors": [ + { + "name": "Chris Boden" + , "email": "cboden@gmail.com" + , "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], "support": { - "forum": "https://groups.google.com/forum/#!forum/ratchet-php" - , "issues": "https://github.com/ratchetphp/RFC6455/issues" - , "irc": "irc://irc.freenode.org/reactphp" + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "chat": "https://gitter.im/reactphp/reactphp" }, "autoload": { "psr-4": { @@ -25,14 +30,16 @@ "guzzlehttp/psr7": "^1.0" }, "require-dev": { - "phpunit/phpunit": "4.8.*", + "phpunit/phpunit": "5.7.*", "react/socket": "^1.3" }, "scripts": { - "abtests": "sh tests/ab/run_ab_tests.sh", + "abtest-client": "ABTEST=client && sh tests/ab/run_ab_tests.sh", + "abtest-server": "ABTEST=server && sh tests/ab/run_ab_tests.sh", "phpunit": "phpunit --colors=always", "test": [ - "@abtests", + "@abtest-client", + "@abtest-server", "@phpunit" ] } diff --git a/src/Handshake/ClientNegotiator.php b/src/Handshake/ClientNegotiator.php index 70856df..c32a1cf 100644 --- a/src/Handshake/ClientNegotiator.php +++ b/src/Handshake/ClientNegotiator.php @@ -16,7 +16,7 @@ class ClientNegotiator { */ private $defaultHeader; - function __construct() { + function __construct(PermessageDeflateOptions $perMessageDeflateOptions = null) { $this->verifier = new ResponseVerifier; $this->defaultHeader = new Request('GET', '', [ @@ -25,6 +25,24 @@ class ClientNegotiator { , 'Sec-WebSocket-Version' => $this->getVersion() , 'User-Agent' => "Ratchet" ]); + + if ($perMessageDeflateOptions === null) { + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + + // https://bugs.php.net/bug.php?id=73373 + // https://bugs.php.net/bug.php?id=74240 - need >=7.1.4 or >=7.0.18 + if ($perMessageDeflateOptions->isEnabled() && + !PermessageDeflateOptions::permessageDeflateSupported()) { + trigger_error('permessage-deflate is being disabled because it is not support by your PHP version.', E_USER_NOTICE); + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + if ($perMessageDeflateOptions->isEnabled() && !function_exists('deflate_add')) { + trigger_error('permessage-deflate is being disabled because you do not have the zlib extension.', E_USER_NOTICE); + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + + $this->defaultHeader = $perMessageDeflateOptions->addHeaderToRequest($this->defaultHeader); } public function generateRequest(UriInterface $uri) { diff --git a/src/Handshake/InvalidPermessageDeflateOptionsException.php b/src/Handshake/InvalidPermessageDeflateOptionsException.php new file mode 100644 index 0000000..191e7a5 --- /dev/null +++ b/src/Handshake/InvalidPermessageDeflateOptionsException.php @@ -0,0 +1,7 @@ +deflateEnabled = true; + $new->client_max_window_bits = self::MAX_WINDOW_BITS; + $new->client_no_context_takeover = false; + $new->server_max_window_bits = self::MAX_WINDOW_BITS; + $new->server_no_context_takeover = false; + + return $new; + } + + public static function createDisabled() { + return new static(); + } + + public function withClientNoContextTakeover() { + $new = clone $this; + $new->client_no_context_takeover = true; + } + + public function withoutClientNoContextTakeover() { + $new = clone $this; + $new->client_no_context_takeover = false; + } + + public function withServerNoContextTakeover() { + $new = clone $this; + $new->server_no_context_takeover = true; + } + + public function withoutServerNoContextTakeover() { + $new = clone $this; + $new->server_no_context_takeover = false; + } + + public function withServerMaxWindowBits($bits = self::MAX_WINDOW_BITS) { + if (!in_array($bits, self::$VALID_BITS)) { + throw new \Exception('server_max_window_bits must have a value between 8 and 15.'); + } + $new = clone $this; + $new->server_max_window_bits = $bits; + } + + public function withClientMaxWindowBits($bits = self::MAX_WINDOW_BITS) { + if (!in_array($bits, self::$VALID_BITS)) { + throw new \Exception('client_max_window_bits must have a value between 8 and 15.'); + } + $new = clone $this; + $new->client_max_window_bits = $bits; + } + + /** + * https://tools.ietf.org/html/rfc6455#section-9.1 + * https://tools.ietf.org/html/rfc7692#section-7 + * + * @param MessageInterface $requestOrResponse + * @return PermessageDeflateOptions[] + * @throws \Exception + */ + public static function fromRequestOrResponse(MessageInterface $requestOrResponse) { + $optionSets = []; + + $extHeader = preg_replace('/\s+/', '', join(', ', $requestOrResponse->getHeader('Sec-Websocket-Extensions'))); + + $configurationRequests = explode(',', $extHeader); + foreach ($configurationRequests as $configurationRequest) { + $parts = explode(';', $configurationRequest); + if (count($parts) == 0) { + continue; + } + + if ($parts[0] !== 'permessage-deflate') { + continue; + } + + array_shift($parts); + $options = new static(); + $options->deflateEnabled = true; + foreach ($parts as $part) { + $kv = explode('=', $part); + $key = $kv[0]; + $value = count($kv) > 1 ? $kv[1] : null; + + switch ($key) { + case "server_no_context_takeover": + case "client_no_context_takeover": + if ($value !== null) { + throw new InvalidPermessageDeflateOptionsException($key . ' must not have a value.'); + } + $value = true; + break; + case "server_max_window_bits": + if (!in_array($value, self::$VALID_BITS)) { + throw new InvalidPermessageDeflateOptionsException($key . ' must have a value between 8 and 15.'); + } + break; + case "client_max_window_bits": + if ($value === null) { + $value = '15'; + } + if (!in_array($value, self::$VALID_BITS)) { + throw new InvalidPermessageDeflateOptionsException($key . ' must have no value or a value between 8 and 15.'); + } + break; + default: + throw new InvalidPermessageDeflateOptionsException('Option "' . $key . '"is not valid for permessage deflate'); + } + + if ($options->$key !== null) { + throw new InvalidPermessageDeflateOptionsException($key . ' specified more than once. Connection must be declined.'); + } + + $options->$key = $value; + } + + if ($options->getClientMaxWindowBits() === null) { + $options->client_max_window_bits = 15; + } + + if ($options->getServerMaxWindowBits() === null) { + $options->server_max_window_bits = 15; + } + + $optionSets[] = $options; + } + + // always put a disabled on the end + $optionSets[] = new static(); + + return $optionSets; + } + + /** + * @return mixed + */ + public function getServerNoContextTakeover() + { + return $this->server_no_context_takeover; + } + + /** + * @return mixed + */ + public function getClientNoContextTakeover() + { + return $this->client_no_context_takeover; + } + + /** + * @return mixed + */ + public function getServerMaxWindowBits() + { + return $this->server_max_window_bits; + } + + /** + * @return mixed + */ + public function getClientMaxWindowBits() + { + return $this->client_max_window_bits; + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->deflateEnabled; + } + + /** + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function addHeaderToResponse(ResponseInterface $response) + { + if (!$this->deflateEnabled) { + return $response; + } + + $header = 'permessage-deflate'; + if ($this->client_max_window_bits != 15) { + $header .= '; client_max_window_bits='. $this->client_max_window_bits; + } + if ($this->client_no_context_takeover) { + $header .= '; client_no_context_takeover'; + } + if ($this->server_max_window_bits != 15) { + $header .= '; server_max_window_bits=' . $this->server_max_window_bits; + } + if ($this->server_no_context_takeover) { + $header .= '; server_no_context_takeover'; + } + + return $response->withAddedHeader('Sec-Websocket-Extensions', $header); + } + + public function addHeaderToRequest(RequestInterface $request) { + if (!$this->deflateEnabled) { + return $request; + } + + $header = 'permessage-deflate'; + if ($this->server_no_context_takeover) { + $header .= '; server_no_context_takeover'; + } + if ($this->client_no_context_takeover) { + $header .= '; client_no_context_takeover'; + } + if ($this->server_max_window_bits != 15) { + $header .= '; server_max_window_bits=' . $this->server_max_window_bits; + } + $header .= '; client_max_window_bits'; + if ($this->client_max_window_bits != 15) { + $header .= '='. $this->client_max_window_bits; + } + + return $request->withAddedHeader('Sec-Websocket-Extensions', $header); + } + + public static function permessageDeflateSupported($version = PHP_VERSION) { + if (!function_exists('deflate_init')) { + return false; + } + if (version_compare($version, '7.1.3', '>')) { + return true; + } + if (version_compare($version, '7.0.18', '>=') + && version_compare($version, '7.1.0', '<')) { + return true; + } + + return false; + } +} diff --git a/src/Handshake/RequestVerifier.php b/src/Handshake/RequestVerifier.php index 1ace489..dbce9a9 100644 --- a/src/Handshake/RequestVerifier.php +++ b/src/Handshake/RequestVerifier.php @@ -137,4 +137,27 @@ class RequestVerifier { */ public function verifyExtensions($val) { } + + public function getPermessageDeflateOptions(array $requestHeader, array $responseHeader) { + $deflate = true; + if (!isset($requestHeader['Sec-WebSocket-Extensions']) || count(array_filter($requestHeader['Sec-WebSocket-Extensions'], function ($val) { + return 'permessage-deflate' === substr($val, 0, strlen('permessage-deflate')); + })) === 0) { + $deflate = false; + } + + if (!isset($responseHeader['Sec-WebSocket-Extensions']) || count(array_filter($responseHeader['Sec-WebSocket-Extensions'], function ($val) { + return 'permessage-deflate' === substr($val, 0, strlen('permessage-deflate')); + })) === 0) { + $deflate = false; + } + + return [ + 'deflate' => $deflate, + 'no_context_takeover' => false, + 'max_window_bits' => null, + 'request_no_context_takeover' => false, + 'request_max_window_bits' => null + ]; + } } diff --git a/src/Handshake/ResponseVerifier.php b/src/Handshake/ResponseVerifier.php index de03f53..38904e8 100644 --- a/src/Handshake/ResponseVerifier.php +++ b/src/Handshake/ResponseVerifier.php @@ -18,8 +18,12 @@ class ResponseVerifier { $request->getHeader('Sec-WebSocket-Protocol') , $response->getHeader('Sec-WebSocket-Protocol') ); + $passes += (int)$this->verifyExtensions( + $request->getHeader('Sec-WebSocket-Extensions') + , $response->getHeader('Sec-WebSocket-Extensions') + ); - return (5 === $passes); + return (6 === $passes); } public function verifyStatus($status) { @@ -49,4 +53,12 @@ class ResponseVerifier { public function verifySubProtocol(array $requestHeader, array $responseHeader) { return 0 === count($responseHeader) || count(array_intersect($responseHeader, $requestHeader)) > 0; } -} \ No newline at end of file + + public function verifyExtensions(array $requestHeader, array $responseHeader) { + if (in_array('permessage-deflate', $responseHeader)) { + return strpos(implode(',', $requestHeader), 'permessage-deflate') !== false ? 1 : 0; + } + + return 1; + } +} diff --git a/src/Handshake/ServerNegotiator.php b/src/Handshake/ServerNegotiator.php index 618b8e2..e4ce79b 100644 --- a/src/Handshake/ServerNegotiator.php +++ b/src/Handshake/ServerNegotiator.php @@ -17,8 +17,22 @@ class ServerNegotiator implements NegotiatorInterface { private $_strictSubProtocols = false; - public function __construct(RequestVerifier $requestVerifier) { + private $enablePerMessageDeflate = false; + + public function __construct(RequestVerifier $requestVerifier, $enablePerMessageDeflate = false) { $this->verifier = $requestVerifier; + + // https://bugs.php.net/bug.php?id=73373 + // https://bugs.php.net/bug.php?id=74240 - need >=7.1.4 or >=7.0.18 + $supported = PermessageDeflateOptions::permessageDeflateSupported(); + if ($enablePerMessageDeflate && !$supported) { + throw new \Exception('permessage-deflate is not supported by your PHP version (need >=7.1.4 or >=7.0.18).'); + } + if ($enablePerMessageDeflate && !function_exists('deflate_add')) { + throw new \Exception('permessage-deflate is not supported because you do not have the zlib extension.'); + } + + $this->enablePerMessageDeflate = $enablePerMessageDeflate; } /** @@ -97,12 +111,24 @@ class ServerNegotiator implements NegotiatorInterface { } } - return new Response(101, array_merge($headers, [ + $response = new Response(101, array_merge($headers, [ 'Upgrade' => 'websocket' - , 'Connection' => 'Upgrade' - , 'Sec-WebSocket-Accept' => $this->sign((string)$request->getHeader('Sec-WebSocket-Key')[0]) - , 'X-Powered-By' => 'Ratchet' + , 'Connection' => 'Upgrade' + , 'Sec-WebSocket-Accept' => $this->sign((string)$request->getHeader('Sec-WebSocket-Key')[0]) + , 'X-Powered-By' => 'Ratchet' ])); + + try { + $perMessageDeflateRequest = PermessageDeflateOptions::fromRequestOrResponse($request)[0]; + } catch (InvalidPermessageDeflateOptionsException $e) { + return new Response(400, [], null, '1.1', $e->getMessage()); + } + + if ($this->enablePerMessageDeflate && $perMessageDeflateRequest->isEnabled()) { + $response = $perMessageDeflateRequest->addHeaderToResponse($response); + } + + return $response; } /** diff --git a/src/Messaging/Frame.php b/src/Messaging/Frame.php index 360b70f..24b491e 100644 --- a/src/Messaging/Frame.php +++ b/src/Messaging/Frame.php @@ -149,6 +149,23 @@ class Frame implements FrameInterface { return 128 === ($this->firstByte & 128); } + public function setRsv1($value = true) { + if (strlen($this->data) == 0) { + throw new \UnderflowException("Cannot set Rsv1 because there is no data."); + } + + $this->firstByte = + ($this->isFinal() ? 128 : 0) + + $this->getOpcode() + + ($value ? 64 : 0) + + ($this->getRsv2() ? 32 : 0) + + ($this->getRsv3() ? 16 : 0) + ; + + $this->data[0] = chr($this->firstByte); + return $this; + } + /** * @return boolean * @throws \UnderflowException diff --git a/src/Messaging/Message.php b/src/Messaging/Message.php index 33a6337..07c1fba 100644 --- a/src/Messaging/Message.php +++ b/src/Messaging/Message.php @@ -117,4 +117,16 @@ class Message implements \IteratorAggregate, MessageInterface { return Frame::OP_BINARY === $this->_frames->bottom()->getOpcode(); } + + /** + * @return boolean + */ + public function getRsv1() { + if ($this->_frames->isEmpty()) { + return false; + //throw new \UnderflowException('Not enough data has been received to determine if message is binary'); + } + + return $this->_frames->bottom()->getRsv1(); + } } diff --git a/src/Messaging/MessageBuffer.php b/src/Messaging/MessageBuffer.php index 9b1a23b..d761786 100644 --- a/src/Messaging/MessageBuffer.php +++ b/src/Messaging/MessageBuffer.php @@ -1,6 +1,8 @@ closeFrameChecker = $frameChecker; $this->checkForMask = (bool)$expectMask; - $this->exceptionFactory ?: $this->exceptionFactory = function($msg) { + $this->exceptionFactory ?: $exceptionFactory = function($msg) { return new \UnderflowException($msg); }; $this->onMessage = $onMessage; $this->onControl = $onControl ?: function() {}; + $this->sender = $sender; + + $this->permessageDeflateOptions = $permessageDeflateOptions ?: PermessageDeflateOptions::createDisabled(); + + $this->deflateEnabled = $this->permessageDeflateOptions->isEnabled(); + + if ($this->deflateEnabled && !is_callable($this->sender)) { + throw new \InvalidArgumentException('sender must be set when deflate is enabled'); + } + + $this->compressedMessage = false; + $this->leftovers = ''; $memory_limit_bytes = static::getMemoryLimit(); @@ -177,12 +218,19 @@ class MessageBuffer { $opcode = $this->frameBuffer->getOpcode(); if ($opcode > 2) { - $onControl($this->frameBuffer); + $onControl($this->frameBuffer, $this); if (Frame::OP_CLOSE === $opcode) { return ''; } } else { + if ($this->messageBuffer->count() === 0 && $this->frameBuffer->getRsv1()) { + $this->compressedMessage = true; + } + if ($this->compressedMessage) { + $this->frameBuffer = $this->inflateFrame($this->frameBuffer); + } + $this->messageBuffer->addFrame($this->frameBuffer); } @@ -195,9 +243,16 @@ class MessageBuffer { $this->messageBuffer = null; if (true !== $msgCheck) { - $onControl($this->newCloseFrame($msgCheck, 'Ratchet detected an invalid UTF-8 payload')); + $onControl($this->newCloseFrame($msgCheck, 'Ratchet detected an invalid UTF-8 payload'), $this); } else { - $onMessage($msgBuffer); + $onMessage($msgBuffer, $this); + } + + $this->messageBuffer = null; + $this->compressedMessage = false; + + if ($this->permessageDeflateOptions->getServerNoContextTakeover()) { + $this->inflator = null; } } } @@ -208,7 +263,7 @@ class MessageBuffer { * @return \Ratchet\RFC6455\Messaging\FrameInterface|FrameInterface */ public function frameCheck(FrameInterface $frame) { - if (false !== $frame->getRsv1() || + if ((false !== $frame->getRsv1() && !$this->deflateEnabled) || false !== $frame->getRsv2() || false !== $frame->getRsv3() ) { @@ -321,6 +376,158 @@ class MessageBuffer { return $this->newFrame(pack('n', $code) . $reason, true, Frame::OP_CLOSE); } + public function sendFrame(Frame $frame) { + if ($this->sender === null) { + throw new \Exception('To send frames using the MessageBuffer, sender must be set.'); + } + + if ($this->deflateEnabled && + ($frame->getOpcode() === Frame::OP_TEXT || $frame->getOpcode() === Frame::OP_BINARY)) { + $frame = $this->deflateFrame($frame); + } + + if (!$this->checkForMask) { + $frame->maskPayload(); + } + + $sender = $this->sender; + $sender($frame->getContents()); + } + + public function sendMessage($messagePayload, $final = true, $isBinary = false) { + $opCode = $isBinary ? Frame::OP_BINARY : Frame::OP_TEXT; + if ($this->streamingMessageOpCode === -1) { + $this->streamingMessageOpCode = $opCode; + } + + if ($this->streamingMessageOpCode !== $opCode) { + throw new \Exception('Binary and text message parts cannot be streamed together.'); + } + + $frame = $this->newFrame($messagePayload, $final, $opCode); + + $this->sendFrame($frame); + + if ($final) { + // reset deflator if client doesn't remember contexts + if ($this->getDeflateNoContextTakeover()) { + $this->deflator = null; + } + $this->streamingMessageOpCode = -1; + } + } + + private $inflator; + + private function getDeflateNoContextTakeover() { + return $this->checkForMask ? + $this->permessageDeflateOptions->getServerNoContextTakeover() : + $this->permessageDeflateOptions->getClientNoContextTakeover(); + } + + private function getDeflateWindowBits() { + return $this->checkForMask ? $this->permessageDeflateOptions->getServerMaxWindowBits() : $this->permessageDeflateOptions->getClientMaxWindowBits(); + } + + private function getInflateNoContextTakeover() { + return $this->checkForMask ? + $this->permessageDeflateOptions->getClientNoContextTakeover() : + $this->permessageDeflateOptions->getServerNoContextTakeover(); + } + + private function getInflateWindowBits() { + return $this->checkForMask ? $this->permessageDeflateOptions->getClientMaxWindowBits() : $this->permessageDeflateOptions->getServerMaxWindowBits(); + } + + private function inflateFrame(Frame $frame) { + if ($this->inflator === null) { + $this->inflator = inflate_init( + ZLIB_ENCODING_RAW, + [ + 'level' => -1, + 'memory' => 8, + 'window' => $this->getInflateWindowBits(), + 'strategy' => ZLIB_DEFAULT_STRATEGY + ] + ); + } + + $terminator = ''; + if ($frame->isFinal()) { + $terminator = "\x00\x00\xff\xff"; + } + + gc_collect_cycles(); // memory runs away if we don't collect ?? + + return new Frame( + inflate_add($this->inflator, $frame->getPayload() . $terminator), + $frame->isFinal(), + $frame->getOpcode() + ); + } + + private $deflator; + + private function deflateFrame(Frame $frame) + { + if ($frame->getRsv1()) { + return $frame; // frame is already deflated + } + + if ($this->deflator === null) { + $bits = (int)$this->getDeflateWindowBits(); + if ($bits === 8) { + $bits = 9; + } + $this->deflator = deflate_init( + ZLIB_ENCODING_RAW, + [ + 'level' => -1, + 'memory' => 8, + 'window' => $bits, + 'strategy' => ZLIB_DEFAULT_STRATEGY + ] + ); + } + + // there is an issue in the zlib extension for php where + // deflate_add does not check avail_out to see if the buffer filled + // this only seems to be an issue for payloads between 16 and 64 bytes + // This if statement is a hack fix to break the output up allowing us + // to call deflate_add twice which should clear the buffer issue +// if ($frame->getPayloadLength() >= 16 && $frame->getPayloadLength() <= 64) { +// // try processing in 8 byte chunks +// // https://bugs.php.net/bug.php?id=73373 +// $payload = ""; +// $orig = $frame->getPayload(); +// $partSize = 8; +// while (strlen($orig) > 0) { +// $part = substr($orig, 0, $partSize); +// $orig = substr($orig, strlen($part)); +// $flags = strlen($orig) > 0 ? ZLIB_PARTIAL_FLUSH : ZLIB_SYNC_FLUSH; +// $payload .= deflate_add($this->deflator, $part, $flags); +// } +// } else { + $payload = deflate_add( + $this->deflator, + $frame->getPayload(), + ZLIB_SYNC_FLUSH + ); +// } + + $deflatedFrame = new Frame( + substr($payload, 0, $frame->isFinal() ? -4 : strlen($payload)), + $frame->isFinal(), + $frame->getOpcode() + ); + + if ($frame->isFinal()) { + $deflatedFrame->setRsv1(); + } + + return $deflatedFrame; + } + /** * This is a separate function for testing purposes * $memory_limit is only used for testing diff --git a/tests/AbResultsTest.php b/tests/AbResultsTest.php index 9bc502d..9bd799e 100644 --- a/tests/AbResultsTest.php +++ b/tests/AbResultsTest.php @@ -1,7 +1,9 @@ markTestSkipped('Autobahn TestSuite results not found'); diff --git a/tests/ab/clientRunner.php b/tests/ab/clientRunner.php index 274f82d..8dd964b 100644 --- a/tests/ab/clientRunner.php +++ b/tests/ab/clientRunner.php @@ -1,9 +1,10 @@ maskPayload(); - } - $conn->write($msg->getContents()); + $permessageDeflateOptions = $permessageDeflateOptions ?: PermessageDeflateOptions::createDisabled(); + + return new \Ratchet\RFC6455\Messaging\MessageBuffer( + new \Ratchet\RFC6455\Messaging\CloseFrameChecker, + function (\Ratchet\RFC6455\Messaging\MessageInterface $msg, MessageBuffer $messageBuffer) use ($conn) { + $messageBuffer->sendMessage($msg->getPayload(), true, $msg->isBinary()); }, - function (FrameInterface $frame) use ($conn) { + function (\Ratchet\RFC6455\Messaging\FrameInterface $frame, MessageBuffer $messageBuffer) use ($conn) { switch ($frame->getOpcode()) { case Frame::OP_PING: return $conn->write((new Frame($frame->getPayload(), true, Frame::OP_PONG))->maskPayload()->getContents()); @@ -41,7 +40,12 @@ function echoStreamerFactory($conn) break; } }, - false + false, + null, + null, + null, + [$conn, 'write'], + $permessageDeflateOptions ); } @@ -81,7 +85,11 @@ function getTestCases() { $connection->close(); }, null, - false + false, + null, + null, + null, + function () {} ); } } @@ -99,17 +107,22 @@ function getTestCases() { return $deferred->promise(); } +$cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator( + PermessageDeflateOptions::permessageDeflateSupported() ? PermessageDeflateOptions::createEnabled() : null); + function runTest($case) { global $connector; global $testServer; + global $cn; $casePath = "/runCase?case={$case}&agent=" . AGENT; $deferred = new Deferred(); $connector->connect($testServer . ':9001')->then(function (ConnectionInterface $connection) use ($deferred, $casePath, $case) { - $cn = new ClientNegotiator(); + $cn = new ClientNegotiator( + PermessageDeflateOptions::permessageDeflateSupported() ? PermessageDeflateOptions::createEnabled() : null); $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $casePath)); $rawResponse = ""; @@ -127,10 +140,19 @@ function runTest($case) $response = \GuzzleHttp\Psr7\parse_response($rawResponse); if (!$cn->validateResponse($cnRequest, $response)) { + echo "Invalid response.\n"; $connection->end(); $deferred->reject(); } else { - $ms = echoStreamerFactory($connection); + try { + $permessageDeflateOptions = PermessageDeflateOptions::fromRequestOrResponse($response)[0]; + $ms = echoStreamerFactory( + $connection, + $permessageDeflateOptions + ); + } catch (InvalidPermessageDeflateOptionsException $e) { + $connection->end(); + } } } } @@ -158,7 +180,9 @@ function createReport() { $deferred = new Deferred(); $connector->connect($testServer . ':9001')->then(function (ConnectionInterface $connection) use ($deferred) { - $reportPath = "/updateReports?agent=" . AGENT . "&shutdownOnComplete=true"; + // $reportPath = "/updateReports?agent=" . AGENT . "&shutdownOnComplete=true"; + // we will stop it using docker now instead of just shutting down + $reportPath = "/updateReports?agent=" . AGENT; $cn = new ClientNegotiator(); $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $reportPath)); @@ -183,12 +207,16 @@ function createReport() { } else { $ms = new MessageBuffer( new CloseFrameChecker, - function (MessageInterface $msg) use ($deferred, $stream) { + function (MessageInterface $msg) use ($deferred, $connection) { $deferred->resolve($msg->getPayload()); - $stream->close(); + $connection->close(); }, null, - false + false, + null, + null, + null, + function () {} ); } } @@ -218,7 +246,13 @@ getTestCases()->then(function ($count) use ($loop) { $allDeferred->resolve(); return; } - runTest($i)->then($runNextCase); + echo "Running test $i/$count..."; + $startTime = microtime(true); + runTest($i) + ->then(function () use ($startTime) { + echo " completed " . round((microtime(true) - $startTime) * 1000) . " ms\n"; + }) + ->then($runNextCase); }; $i = 0; diff --git a/tests/ab/docker_bootstrap.sh b/tests/ab/docker_bootstrap.sh new file mode 100644 index 0000000..44d4581 --- /dev/null +++ b/tests/ab/docker_bootstrap.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -x + +echo "Running $0" + +echo Adding "$1 host.ratchet.internal" to /etc/hosts file + +echo $1 host.ratchet.internal >> /etc/hosts + +echo /etc/hosts contains: +cat /etc/hosts +echo diff --git a/tests/ab/fuzzingclient.json b/tests/ab/fuzzingclient.json index d2fd0d0..d410be3 100644 --- a/tests/ab/fuzzingclient.json +++ b/tests/ab/fuzzingclient.json @@ -2,13 +2,15 @@ "options": { "failByDrop": false } - , "outdir": "./reports/servers" + , "outdir": "/reports/servers" , "servers": [{ - "agent": "RatchetRFC/0.1.0" - , "url": "ws://localhost:9001" + "agent": "RatchetRFC/0.3" + , "url": "ws://host.ratchet.internal:9001" , "options": {"version": 18} }] - , "cases": ["*"] - , "exclude-cases": ["6.4.*", "12.*","13.*"] + , "cases": [ + "*" + ] + , "exclude-cases": [] , "exclude-agent-cases": {} } diff --git a/tests/ab/fuzzingclient_skip_deflate.json b/tests/ab/fuzzingclient_skip_deflate.json new file mode 100644 index 0000000..b1fddbe --- /dev/null +++ b/tests/ab/fuzzingclient_skip_deflate.json @@ -0,0 +1,14 @@ +{ + "options": { + "failByDrop": false + } + , "outdir": "/reports/servers" + , "servers": [{ + "agent": "RatchetRFC/0.3" + , "url": "ws://host.ratchet.internal:9001" + , "options": {"version": 18} + }] + , "cases": ["*"] + , "exclude-cases": ["12.*", "13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/fuzzingserver.json b/tests/ab/fuzzingserver.json index 0422560..3a59bab 100644 --- a/tests/ab/fuzzingserver.json +++ b/tests/ab/fuzzingserver.json @@ -4,7 +4,9 @@ "failByDrop": false } , "outdir": "./reports/clients" - , "cases": ["*"] - , "exclude-cases": ["6.4.*", "12.*", "13.*"] + , "cases": [ + "*" + ] + , "exclude-cases": [] , "exclude-agent-cases": {} } diff --git a/tests/ab/fuzzingserver_skip_deflate.json b/tests/ab/fuzzingserver_skip_deflate.json new file mode 100644 index 0000000..3b90fc3 --- /dev/null +++ b/tests/ab/fuzzingserver_skip_deflate.json @@ -0,0 +1,10 @@ +{ + "url": "ws://127.0.0.1:9001" + , "options": { + "failByDrop": false + } + , "outdir": "./reports/clients" + , "cases": ["*"] + , "exclude-cases": ["12.*", "13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/run_ab_tests.sh b/tests/ab/run_ab_tests.sh index f06a343..b924480 100644 --- a/tests/ab/run_ab_tests.sh +++ b/tests/ab/run_ab_tests.sh @@ -1,13 +1,58 @@ +set -x cd tests/ab -wstest -m fuzzingserver -s fuzzingserver.json & -sleep 5 -php clientRunner.php +SKIP_DEFLATE= +if [ "$TRAVIS" = "true" ]; then +if [ $(phpenv version-name) = "hhvm" -o $(phpenv version-name) = "5.4" -o $(phpenv version-name) = "5.5" -o $(phpenv version-name) = "5.6" ]; then + echo "Skipping deflate autobahn tests for $(phpenv version-name)" + SKIP_DEFLATE=_skip_deflate +fi +fi + +if [ "$ABTEST" = "client" ]; then + docker run --rm \ + -d \ + -v ${PWD}:/config \ + -v ${PWD}/reports:/reports \ + -p 9001:9001 \ + --name fuzzingserver \ + crossbario/autobahn-testsuite wstest -m fuzzingserver -s /config/fuzzingserver$SKIP_DEFLATE.json + sleep 5 + if [ "$TRAVIS" != "true" ]; then + echo "Running tests vs Autobahn test client" + ###docker run -it --rm --name abpytest crossbario/autobahn-testsuite wstest --mode testeeclient -w ws://host.docker.internal:9001 + fi + php -d memory_limit=256M clientRunner.php + + docker ps -a + + docker logs fuzzingserver + + docker stop fuzzingserver + + sleep 2 +fi + +if [ "$ABTEST" = "server" ]; then + php -d memory_limit=256M startServer.php & + sleep 3 + + if [ "$OSTYPE" = "linux-gnu" ]; then + IPADDR=`hostname -I | cut -f 1 -d ' '` + else + IPADDR=`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -1 | tr -d 'adr:'` + fi + + docker run --rm \ + -it \ + -v ${PWD}:/config \ + -v ${PWD}/reports:/reports \ + --name fuzzingclient \ + crossbario/autobahn-testsuite /bin/sh -c "sh /config/docker_bootstrap.sh $IPADDR; wstest -m fuzzingclient -s /config/fuzzingclient$SKIP_DEFLATE.json" + sleep 1 + + # send the shutdown command to the PHP echo server + wget -O - -q http://127.0.0.1:9001/shutdown +fi -sleep 2 -php startServer.php & -sleep 3 -wstest -m fuzzingclient -s fuzzingclient.json -sleep 1 -kill $(ps aux | grep 'php startServer.php' | awk '{print $2}' | head -n 1) diff --git a/tests/ab/startServer.php b/tests/ab/startServer.php index 4baf884..f1d3b66 100644 --- a/tests/ab/startServer.php +++ b/tests/ab/startServer.php @@ -1,4 +1,8 @@ on('connection', function (React\Socket\ConnectionInterface $connection) use ($negotiator, $closeFrameChecker, $uException) { + +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) use ($negotiator, $closeFrameChecker, $uException, $socket) { $headerComplete = false; $buffer = ''; $parser = null; - $connection->on('data', function ($data) use ($connection, &$parser, &$headerComplete, &$buffer, $negotiator, $closeFrameChecker, $uException) { + $connection->on('data', function ($data) use ($connection, &$parser, &$headerComplete, &$buffer, $negotiator, $closeFrameChecker, $uException, $socket) { if ($headerComplete) { $parser->onData($data); return; @@ -35,6 +40,12 @@ $socket->on('connection', function (React\Socket\ConnectionInterface $connection $negotiatorResponse = $negotiatorResponse->withAddedHeader("Content-Length", "0"); + if ($negotiatorResponse->getStatusCode() !== 101 && $psrRequest->getUri()->getPath() === '/shutdown') { + $connection->end(\GuzzleHttp\Psr7\str(new Response(200, [], 'Shutting down echo server.' . PHP_EOL))); + $socket->close(); + return; + }; + $connection->write(\GuzzleHttp\Psr7\str($negotiatorResponse)); if ($negotiatorResponse->getStatusCode() !== 101) { @@ -42,9 +53,13 @@ $socket->on('connection', function (React\Socket\ConnectionInterface $connection return; } + // there is no need to look through the client requests + // we support any valid permessage deflate + $deflateOptions = PermessageDeflateOptions::fromRequestOrResponse($psrRequest)[0]; + $parser = new \Ratchet\RFC6455\Messaging\MessageBuffer($closeFrameChecker, - function (MessageInterface $message) use ($connection) { - $connection->write($message->getContents()); + function (MessageInterface $message, MessageBuffer $messageBuffer) { + $messageBuffer->sendMessage($message->getPayload(), true, $message->isBinary()); }, function (FrameInterface $frame) use ($connection, &$parser) { switch ($frame->getOpCode()) { case Frame::OP_CLOSE: @@ -56,7 +71,11 @@ $socket->on('connection', function (React\Socket\ConnectionInterface $connection } }, true, function () use ($uException) { return $uException; - }); + }, + null, + null, + [$connection, 'write'], + $deflateOptions); array_shift($parts); $parser->onData(implode("\r\n\r\n", $parts)); diff --git a/tests/unit/Handshake/PermessageDeflateOptionsTest.php b/tests/unit/Handshake/PermessageDeflateOptionsTest.php new file mode 100644 index 0000000..11d3739 --- /dev/null +++ b/tests/unit/Handshake/PermessageDeflateOptionsTest.php @@ -0,0 +1,30 @@ +assertEquals($supported, PermessageDeflateOptions::permessageDeflateSupported($version)); + } +} \ No newline at end of file diff --git a/tests/unit/Handshake/RequestVerifierTest.php b/tests/unit/Handshake/RequestVerifierTest.php index 239de33..5ba26b6 100644 --- a/tests/unit/Handshake/RequestVerifierTest.php +++ b/tests/unit/Handshake/RequestVerifierTest.php @@ -1,11 +1,14 @@