diff --git a/.gitignore b/.gitignore index 4fbb073..235a653 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor/ /composer.lock +/.phpunit.* diff --git a/composer.json b/composer.json index d447569..f9c84b5 100644 --- a/composer.json +++ b/composer.json @@ -16,5 +16,8 @@ ], "require": { "react/http": "^1.9.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0e66c06 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + diff --git a/src/WebSocketCodec.php b/src/WebSocketCodec.php new file mode 100644 index 0000000..de9cbcd --- /dev/null +++ b/src/WebSocketCodec.php @@ -0,0 +1,189 @@ + true, + 'opcode' => null, + 'masked' => false, + 'mask' => null, + 'rsv1' => false, + 'rsv2' => false, + 'rsv3' => false, + 'payload' => null + ], + ...$frame + ]; + + $len = strlen($frame['payload']); + if ($len > 65535) { + $size0 = 127; + $size1 = ($len >> 24) & 0xFF; + $size2 = ($len >> 16) & 0xFF; + $size3 = ($len >> 8) & 0xFF; + $size4 = $len & 0xFF; + } elseif ($len > 126) { + $size0 = 126; + $size1 = ($len >> 8) & 0xFF; + $size2 = $len & 0xFF; + $size3 = null; + } else { + $size0 = $len; + $size1 = null; + $size3 = null; + } + + $encoded .= chr(($frame['final']?0x80:0x00) + | ($frame['rsv1']?0x40:0x00) + | ($frame['rsv2']?0x20:0x00) + | ($frame['rsv3']?0x10:0x00) + | ($frame['opcode'] & 0xF)); + $encoded .= chr(($frame['masked']?0x80:0x00) + | ($size0 & 0x7F)); + if ($size1 !== null) { + $encoded .= chr($size1) . chr($size2); + } + if ($size3 !== null) { + $encoded .= chr($size3) . chr($size4); + } + + if ($frame['masked'] === true) { + if ($frame['mask'] === null || strlen($frame['mask']) !== 4) { + $frame['mask'] = chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255)); + } + $encoded .= $frame['mask']; + $encoded .= $this->mask($frame['payload'], $frame['mask']); + } else { + $encoded .= $frame['payload']; + } + + //$this->hexdump($encoded); + + return $encoded; + } + + /** + * Decode a websocket frame and return an array with the keys: + * opcode (int) - the opcode + * fin (bool) - final frame + * rsv1-3 (bool) - reserved bits + * masked (bool) - if the frame was masked + * length (int) - length of payload + * payload (string) - the payload + */ + public function decode($frame): array + { + // Decoded frame + $decoded = []; + + // Keep track of the number of bytes in the header + $header = 2; + + // Peek at the first byte, holding flags and opcode + $byte0 = ord($frame[0]); + $decoded['final'] = !!($byte0 & 0x80); + $decoded['opcode'] = $byte0 & 0x0F; + // Peek at the second byte, holding mask bit and len + $byte1 = ord($frame[1]); + $decoded['masked'] = $masked = !!($byte1 & 0x80); + $decoded['rsv1'] = !!($byte1 & 0x40); + $decoded['rsv2'] = !!($byte1 & 0x20); + $decoded['rsv3'] = !!($byte1 & 0x10); + $len = $byte1 & 0x7F; + + // Read extended length if present + if ($len == 126) { + $len = (ord($frame[$header+0]) << 8) + | (ord($frame[$header+1])); + $header += 2; + } elseif ($len == 127) { + $len = (ord($frame[$header+0]) << 24) + | (ord($frame[$header+1]) << 16) + | (ord($frame[$header+2]) << 8) + | (ord($frame[$header+3])); + $header += 4; + } + + // Now for the masking + if ($masked) { + $mask = substr($frame, $header, 4); + $header += 4; + } + + // Extract and unmask payload + $payload = substr($frame, $header, $len); + if ($masked) { + $payload = $this->mask($payload, $mask); + $decoded['mask'] = $mask; + } + + $decoded['length'] = $len; + $decoded['payload'] = $payload; + + return $decoded; + } + + private function mask(string $payload, string $mask): string + { + $payloadData = array_map("ord", str_split($payload,1)); + $maskData = array_map("ord", str_split($mask,1)); + + //printf("Mask: %02x %02x %02x %02x\n", ...$maskData); + + $unmasked = []; + for ($n = 0; $n < count($payloadData); $n++) { + $unmasked[] = $payloadData[$n] ^ $maskData[$n % 4]; + } + return join("", array_map("chr", $unmasked)); + } + + private function hexdump($data): void + { + printf("%4d .\n", strlen($data)); + $rows = str_split($data, 16); + $offs = 0; + foreach ($rows as $row) { + $h = []; $a = []; + for ($n = 0; $n < 16; $n++) { + if ($n < strlen($row)) { + $h[] = sprintf("%02x%s", ord($row[$n]), ($n==7)?" ":" "); + $a[] = sprintf("%s%s", (ctype_print($row[$n])?$row[$n]:"."), ($n==7)?" ":""); + } else { + $h[] = (($n==7)?" ":" "); + $a[] = (($n==7)?" ":" "); + } + } + printf("%04x | %s | %s\n", 16 * $offs++, join("", $h), join("", $a)); + } + } + +} \ No newline at end of file diff --git a/src/WebSocketConnection.php b/src/WebSocketConnection.php index e5586dd..4490680 100644 --- a/src/WebSocketConnection.php +++ b/src/WebSocketConnection.php @@ -27,6 +27,8 @@ class WebSocketConnection implements WebSocketInterface private ?string $groupName = null; + private WebSocketCodec $codec; + private ?ConnectionGroup $group = null; private GroupManager $groupManager; @@ -43,6 +45,9 @@ class WebSocketConnection implements WebSocketInterface public function __construct(ServerRequestInterface $request, ReadableStreamInterface $inStream, WritableStreamInterface $outStream, GroupManager $groupManager) { + // The codec is used to encode and decode frames + $this->codec = new WebSocketCodec(); + $this->request = $request; $this->inStream = $inStream; $this->outStream = $outStream; @@ -54,42 +59,10 @@ class WebSocketConnection implements WebSocketInterface private function onWebSocketData($data) { - // Keep track of the number of bytes in the header - $header = 2; - - // Peek at the first byte, holding flags and opcode - $byte0 = ord($data[0]); - $final = !!($byte0 & 0x80); - $opcode = $byte0 & 0x0F; - // Peek at the second byte, holding mask bit and len - $byte1 = ord($data[1]); - $masked = !!($byte1 & 0x80); - $len = $byte1 & 0x7F; - - // Read extended length if present - if ($len == 126) { - $len = (ord($data[$header+0]) << 8) - | (ord($data[$header+1])); - $header += 2; - } elseif ($len == 127) { - $len = (ord($data[$header+0]) << 24) - | (ord($data[$header+1]) << 16) - | (ord($data[$header+2]) << 8) - | (ord($data[$header+3])); - $header += 4; - } - - // Now for the masking - if ($masked) { - $mask = substr($data, $header, 4); - $header += 4; - } - - // Extract and unmask payload - $payload = substr($data, $header, $len); - if ($masked) { - $payload = $this->unmask($payload, $mask); - } + $decoded = $this->codec->decode($data); + $opcode = $decoded['opcode']; + $final = $decoded['final']; + $payload = $decoded['payload']; if (!$final) { if ($this->bufferedOp === null) { @@ -111,7 +84,12 @@ class WebSocketConnection implements WebSocketInterface switch ($opcode) { case self::OP_PING: - $this->sendPong(); + $this->sendPong($payload); + return; + case self::OP_PONG: + return; + case self::OP_CLOSE: + // TODO implement return; case self::OP_CONTINUATION: $this->buffer .= $payload; @@ -125,24 +103,9 @@ class WebSocketConnection implements WebSocketInterface } } - private function unmask(string $payload, string $mask): string + private function sendPong(string $data): void { - $payloadData = array_map("ord", str_split($payload,1)); - $maskData = array_map("ord", str_split($mask,1)); - - //printf("Mask: %02x %02x %02x %02x\n", ...$maskData); - - $unmasked = []; - for ($n = 0; $n < count($payloadData); $n++) { - $unmasked[] = $payloadData[$n] ^ $maskData[$n % 4]; - //printf("%02x ^ %02x = %02x %s\n", $payloadData[$n], $maskData[$n%4], $payloadData[$n]^$maskData[$n%4], chr($payloadData[$n]^$maskData[$n%4])); - } - return join("", array_map("chr", $unmasked)); - } - - private function sendPong(): void - { - + $this->send(self::OP_PONG, $data, true); } public function setGroup(?string $name): void @@ -221,17 +184,15 @@ class WebSocketConnection implements WebSocketInterface /** * */ - public function send(int $opcode, string $data, bool $final = true) + public function send(int $opcode, string $data, bool $final = true, bool $masked = false) { - $frame = chr(($final?0x80:0x00) | ($opcode & 0xF)); - - $len = strlen($data); - if ($len > 126) { - $frame .= chr(0x7E) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF); - } else { - $frame .= chr($len); - } - $frame .= $data; + + $frame = $this->codec->encode([ + 'opcode' => $opcode, + 'payload' => $data, + 'final' => $final, + 'masked' => $masked + ]); $this->outStream->write($frame); diff --git a/tests/WebSocketCodecTest.php b/tests/WebSocketCodecTest.php new file mode 100644 index 0000000..3a433d2 --- /dev/null +++ b/tests/WebSocketCodecTest.php @@ -0,0 +1,100 @@ +encode([ + 'opcode'=>WebSocketCodec::OP_PING, + 'payload'=>"ping" + ]); + $this->assertEquals("\x89\x04ping", $msg); + + $msg = $codec->encode([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh"]); + $this->assertEquals("\x81\x08abcdefgh", $msg); + + $msg = $codec->encode([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh", + 'masked'=>true, + 'mask'=>"\x00\x00\x00\x00" + ]); + $this->assertEquals("\x81\x88\x00\x00\x00\x00abcdefgh", $msg); + + $msg = $codec->encode([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh", + 'masked'=>true, + 'mask'=>"\x00\xFF\x00\xFF" + ]); + $this->assertEquals("\x81\x88\x00\xFF\x00\xFFa\x9dc\x9be\x99g\x97", $msg); + + } + + public function testDecodingFrames() + { + $codec = new WebSocketCodec(); + + $msg = $codec->decode("\x89\x04ping"); + $this->assertEquals([ + 'opcode'=>WebSocketCodec::OP_PING, + 'payload'=>"ping", + 'final'=>true, + 'rsv1'=>false, + 'rsv2'=>false, + 'rsv3'=>false, + 'length'=>4, + 'masked'=>false + ], $msg); + + $msg = $codec->decode("\x81\x08abcdefgh"); + $this->assertEquals([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh", + 'final'=>true, + 'rsv1'=>false, + 'rsv2'=>false, + 'rsv3'=>false, + 'length'=>8, + 'masked'=>false + ], $msg); + + $msg = $codec->decode("\x81\x88\x00\x00\x00\x00abcdefgh"); + $this->assertEquals([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh", + 'final'=>true, + 'rsv1'=>false, + 'rsv2'=>false, + 'rsv3'=>false, + 'length'=>8, + 'masked'=>true, + 'mask'=>"\x00\x00\x00\x00" + ], $msg); + + $msg = $codec->decode("\x81\x88\x00\xFF\x00\xFFa\x9dc\x9be\x99g\x97"); + $this->assertEquals([ + 'opcode'=>WebSocketCodec::OP_FRAME_TEXT, + 'payload'=>"abcdefgh", + 'final'=>true, + 'rsv1'=>false, + 'rsv2'=>false, + 'rsv3'=>false, + 'length'=>8, + 'masked'=>true, + 'mask'=>"\x00\xFF\x00\xFF" + ], $msg); + + } + +} \ No newline at end of file