Moved frame logic to WebSocketCodec

This commit is contained in:
Chris 2024-02-21 21:23:24 +01:00
parent 065d96f90a
commit 1caeb3c29f
6 changed files with 341 additions and 64 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/vendor/ /vendor/
/composer.lock /composer.lock
/.phpunit.*

View File

@ -16,5 +16,8 @@
], ],
"require": { "require": {
"react/http": "^1.9.0" "react/http": "^1.9.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
} }
} }

23
phpunit.xml Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source restrictDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

189
src/WebSocketCodec.php Normal file
View File

@ -0,0 +1,189 @@
<?php
namespace NoccyLabs\React\WebSocket;
use Evenement\EventEmitterTrait;
use NoccyLabs\React\WebSocket\Group\ConnectionGroup;
use NoccyLabs\React\WebSocket\Group\GroupManager;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Socket\ConnectionInterface;
use React\Stream\CompositeStream;
use React\Stream\DuplexStreamInterface;
use React\Stream\ReadableStreamInterface;
use React\Stream\ThroughStream;
use React\Stream\WritableStreamInterface;
class WebSocketCodec
{
const OP_CONTINUATION = 0x0;
const OP_FRAME_TEXT = 0x1;
const OP_FRAME_BINARY = 0x2;
const OP_CLOSE = 0x8;
const OP_PING = 0x9;
const OP_PONG = 0xA;
public function encode(array $frame): string
{
// Encoded frame
$encoded = null;
// Unpack frame with defaults
$frame = [
...[
'final' => 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));
}
}
}

View File

@ -27,6 +27,8 @@ class WebSocketConnection implements WebSocketInterface
private ?string $groupName = null; private ?string $groupName = null;
private WebSocketCodec $codec;
private ?ConnectionGroup $group = null; private ?ConnectionGroup $group = null;
private GroupManager $groupManager; private GroupManager $groupManager;
@ -43,6 +45,9 @@ class WebSocketConnection implements WebSocketInterface
public function __construct(ServerRequestInterface $request, ReadableStreamInterface $inStream, WritableStreamInterface $outStream, GroupManager $groupManager) 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->request = $request;
$this->inStream = $inStream; $this->inStream = $inStream;
$this->outStream = $outStream; $this->outStream = $outStream;
@ -54,42 +59,10 @@ class WebSocketConnection implements WebSocketInterface
private function onWebSocketData($data) private function onWebSocketData($data)
{ {
// Keep track of the number of bytes in the header $decoded = $this->codec->decode($data);
$header = 2; $opcode = $decoded['opcode'];
$final = $decoded['final'];
// Peek at the first byte, holding flags and opcode $payload = $decoded['payload'];
$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);
}
if (!$final) { if (!$final) {
if ($this->bufferedOp === null) { if ($this->bufferedOp === null) {
@ -111,7 +84,12 @@ class WebSocketConnection implements WebSocketInterface
switch ($opcode) { switch ($opcode) {
case self::OP_PING: case self::OP_PING:
$this->sendPong(); $this->sendPong($payload);
return;
case self::OP_PONG:
return;
case self::OP_CLOSE:
// TODO implement
return; return;
case self::OP_CONTINUATION: case self::OP_CONTINUATION:
$this->buffer .= $payload; $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)); $this->send(self::OP_PONG, $data, true);
$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
{
} }
public function setGroup(?string $name): void 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); $frame = $this->codec->encode([
if ($len > 126) { 'opcode' => $opcode,
$frame .= chr(0x7E) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF); 'payload' => $data,
} else { 'final' => $final,
$frame .= chr($len); 'masked' => $masked
} ]);
$frame .= $data;
$this->outStream->write($frame); $this->outStream->write($frame);

View File

@ -0,0 +1,100 @@
<?php
namespace NoccyLabs\React\WebSocket;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(WebSocketCodec::class)]
class WebSocketCodecTest extends \PHPUnit\Framework\TestCase
{
public function testEncodingFrames()
{
$codec = new WebSocketCodec();
$msg = $codec->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);
}
}